From cc7d9ee6f1c40753a0b6c3e11869f6ff4fcb7510 Mon Sep 17 00:00:00 2001 From: buck Date: Tue, 10 Sep 2024 13:59:07 -0500 Subject: [PATCH 01/19] unsignedPsbt in redux store should include global xpubs (#132) --- apps/coordinator/src/reducers/transactionReducer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/coordinator/src/reducers/transactionReducer.js b/apps/coordinator/src/reducers/transactionReducer.js index d298dfab..2c96f5f8 100644 --- a/apps/coordinator/src/reducers/transactionReducer.js +++ b/apps/coordinator/src/reducers/transactionReducer.js @@ -289,6 +289,7 @@ function finalizeOutputs(state, action) { network: state.network, inputs: state.inputs.map(convertLegacyInput), outputs: state.outputs.map(convertLegacyOutput), + includeGlobalXpubs: true, }; const psbt = getUnsignedMultisigPsbtV0(args); unsignedTransaction = Transaction.fromHex( From 4ecc56aefc1ecdde7a2ffa157e0d1e29515a5bc4 Mon Sep 17 00:00:00 2001 From: buck Date: Thu, 26 Sep 2024 12:43:30 -0500 Subject: [PATCH 02/19] bump for coordinator (#138) --- .changeset/old-mugs-invent.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/old-mugs-invent.md diff --git a/.changeset/old-mugs-invent.md b/.changeset/old-mugs-invent.md new file mode 100644 index 00000000..72686868 --- /dev/null +++ b/.changeset/old-mugs-invent.md @@ -0,0 +1,5 @@ +--- +"caravan-coordinator": minor +--- + +Add bip32 package and UI for blinded xpub support in wallet creation From 246b9da1f5174cd643d05b9f436e485106c987de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:47:51 -0500 Subject: [PATCH 03/19] Version Packages (#139) Co-authored-by: github-actions[bot] --- .changeset/old-mugs-invent.md | 5 ----- apps/coordinator/CHANGELOG.md | 6 ++++++ apps/coordinator/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/old-mugs-invent.md diff --git a/.changeset/old-mugs-invent.md b/.changeset/old-mugs-invent.md deleted file mode 100644 index 72686868..00000000 --- a/.changeset/old-mugs-invent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"caravan-coordinator": minor ---- - -Add bip32 package and UI for blinded xpub support in wallet creation diff --git a/apps/coordinator/CHANGELOG.md b/apps/coordinator/CHANGELOG.md index 2a09c0b7..dbef99b2 100644 --- a/apps/coordinator/CHANGELOG.md +++ b/apps/coordinator/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.3.0 + +### Minor Changes + +- [#138](https://github.com/caravan-bitcoin/caravan/pull/138) [`4ecc56a`](https://github.com/caravan-bitcoin/caravan/commit/4ecc56aefc1ecdde7a2ffa157e0d1e29515a5bc4) Thanks [@bucko13](https://github.com/bucko13)! - Add bip32 package and UI for blinded xpub support in wallet creation + ## 1.2.0 ### Minor Changes diff --git a/apps/coordinator/package.json b/apps/coordinator/package.json index ef8606b8..9e2caed9 100644 --- a/apps/coordinator/package.json +++ b/apps/coordinator/package.json @@ -1,7 +1,7 @@ { "name": "caravan-coordinator", "private": true, - "version": "1.2.0", + "version": "1.3.0", "description": "Unchained Capital's Bitcoin Multisig Coordinator Application", "main": "index.jsx", "type": "module", diff --git a/package-lock.json b/package-lock.json index 5433b7ef..4504d8da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ }, "apps/coordinator": { "name": "caravan-coordinator", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@caravan/bip32": "*", From 8d79fc6cfbd63bee37f076c4396a94d30e412e6f Mon Sep 17 00:00:00 2001 From: Mrigesh Thakur <96632943+Legend101Zz@users.noreply.github.com> Date: Wed, 23 Oct 2024 20:52:10 +0530 Subject: [PATCH 04/19] Fee bumping Package (#114) * feat: Initialize @caravan/feebumping package Create a new package for fee bumping functionality in Caravan. This package provides modular and reusable utilities for implementing RBF (Replace-By-Fee) and CPFP (Child-Pays-For-Parent) fee bumping strategies. Key additions: - Basic package structure and configuration files - Core modules for RBF and CPFP implementations - Fee estimation utilities - Type definitions for fee bumping operations - Constants and utility functions This package aims to enhance Caravan's transaction management capabilities by providing a flexible and extensible fee bumping solution that can be easily integrated into the Caravan coordinator wallet and other Bitcoin projects. --- .changeset/real-ravens-roll.md | 5 + packages/caravan-fees/.eslintrc.cjs | 7 + packages/caravan-fees/.prettierrc | 4 + packages/caravan-fees/README.md | 336 +++++++++ packages/caravan-fees/jest.config.ts | 22 + packages/caravan-fees/jest.setup.ts | 12 + packages/caravan-fees/package.json | 51 ++ .../src/btcTransactionComponents.ts | 366 +++++++++ .../src/btcTransactionTemplate.ts | 691 +++++++++++++++++ packages/caravan-fees/src/constants.ts | 74 ++ packages/caravan-fees/src/cpfp.ts | 306 ++++++++ packages/caravan-fees/src/index.ts | 8 + packages/caravan-fees/src/rbf.ts | 465 ++++++++++++ .../btcTransactionComponents.fixtures.ts | 142 ++++ .../tests/btcTransactionComponents.test.ts | 238 ++++++ .../tests/btcTransactionTemplate.fixtures.ts | 166 ++++ .../src/tests/btcTransactionTemplate.test.ts | 422 +++++++++++ .../caravan-fees/src/tests/cpfp.fixtures.ts | 86 +++ packages/caravan-fees/src/tests/cpfp.test.ts | 93 +++ .../caravan-fees/src/tests/rbf.fixtures.ts | 336 +++++++++ packages/caravan-fees/src/tests/rbf.test.ts | 267 +++++++ .../src/tests/transactionAnalyzer.fixtures.ts | 186 +++++ .../src/tests/transactionAnalyzer.test.ts | 88 +++ .../caravan-fees/src/transactionAnalyzer.ts | 706 ++++++++++++++++++ packages/caravan-fees/src/types.ts | 532 +++++++++++++ packages/caravan-fees/src/utils.ts | 462 ++++++++++++ packages/caravan-fees/tsconfig.json | 6 + packages/caravan-fees/tsup.config.ts | 13 + .../caravan-psbt/src/psbtv2/psbtv2.test.ts | 72 ++ packages/caravan-psbt/src/psbtv2/psbtv2.ts | 83 ++ 30 files changed, 6245 insertions(+) create mode 100644 .changeset/real-ravens-roll.md create mode 100644 packages/caravan-fees/.eslintrc.cjs create mode 100644 packages/caravan-fees/.prettierrc create mode 100644 packages/caravan-fees/README.md create mode 100644 packages/caravan-fees/jest.config.ts create mode 100644 packages/caravan-fees/jest.setup.ts create mode 100644 packages/caravan-fees/package.json create mode 100644 packages/caravan-fees/src/btcTransactionComponents.ts create mode 100644 packages/caravan-fees/src/btcTransactionTemplate.ts create mode 100644 packages/caravan-fees/src/constants.ts create mode 100644 packages/caravan-fees/src/cpfp.ts create mode 100644 packages/caravan-fees/src/index.ts create mode 100644 packages/caravan-fees/src/rbf.ts create mode 100644 packages/caravan-fees/src/tests/btcTransactionComponents.fixtures.ts create mode 100644 packages/caravan-fees/src/tests/btcTransactionComponents.test.ts create mode 100644 packages/caravan-fees/src/tests/btcTransactionTemplate.fixtures.ts create mode 100644 packages/caravan-fees/src/tests/btcTransactionTemplate.test.ts create mode 100644 packages/caravan-fees/src/tests/cpfp.fixtures.ts create mode 100644 packages/caravan-fees/src/tests/cpfp.test.ts create mode 100644 packages/caravan-fees/src/tests/rbf.fixtures.ts create mode 100644 packages/caravan-fees/src/tests/rbf.test.ts create mode 100644 packages/caravan-fees/src/tests/transactionAnalyzer.fixtures.ts create mode 100644 packages/caravan-fees/src/tests/transactionAnalyzer.test.ts create mode 100644 packages/caravan-fees/src/transactionAnalyzer.ts create mode 100644 packages/caravan-fees/src/types.ts create mode 100644 packages/caravan-fees/src/utils.ts create mode 100644 packages/caravan-fees/tsconfig.json create mode 100644 packages/caravan-fees/tsup.config.ts diff --git a/.changeset/real-ravens-roll.md b/.changeset/real-ravens-roll.md new file mode 100644 index 00000000..c243aa61 --- /dev/null +++ b/.changeset/real-ravens-roll.md @@ -0,0 +1,5 @@ +--- +"@caravan/psbt": minor +--- + +Added methods to handle sequence numbers within PSBTs for better RBF (Replace-By-Fee) support: diff --git a/packages/caravan-fees/.eslintrc.cjs b/packages/caravan-fees/.eslintrc.cjs new file mode 100644 index 00000000..75c26d83 --- /dev/null +++ b/packages/caravan-fees/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + extends: ["@caravan/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + rules: { + "@typescript-eslint/no-duplicate-enum-values": "warn", + }, +}; diff --git a/packages/caravan-fees/.prettierrc b/packages/caravan-fees/.prettierrc new file mode 100644 index 00000000..222861c3 --- /dev/null +++ b/packages/caravan-fees/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/packages/caravan-fees/README.md b/packages/caravan-fees/README.md new file mode 100644 index 00000000..bb8f8cf3 --- /dev/null +++ b/packages/caravan-fees/README.md @@ -0,0 +1,336 @@ +# Caravan Fees Package + +## Table of Contents +1. [Introduction](#introduction) +2. [Key Components](#key-components) +3. [Transaction Analyzer](#transaction-analyzer) +4. [BTC Transaction Template](#btc-transaction-template) +5. [RBF (Replace-By-Fee)](#rbf-replace-by-fee) +6. [CPFP (Child-Pays-For-Parent)](#cpfp-child-pays-for-parent) +7. [Usage Examples](#usage-examples) +8. [Advanced Customization](#advanced-customization) +9. [Best Practices](#best-practices) +10. [References](#references) + +## Introduction + +The Caravan Fees Package is a comprehensive toolkit for Bitcoin transaction fee management, focusing on Replace-By-Fee (RBF) and Child-Pays-For-Parent (CPFP) strategies. This package provides developers with powerful tools to analyze existing transactions, estimate appropriate fees, and create new transactions for fee bumping purposes. + +### Why Use This Package? + +- **Simplified Fee Management**: Automates complex calculations for RBF and CPFP. +- **Flexible Transaction Building**: Utilizes a template-based approach for creating new transactions. +- **Comprehensive Transaction Analysis**: Provides detailed insights into transaction properties and fee structures. +- **Customizable**: Allows for fine-tuned control over fee strategies and transaction construction. + +## Key Components + +The package consists of several key components: + +1. **Transaction Analyzer**: Analyzes existing transactions and provides recommendations. +2. **BTC Transaction Template**: A flexible class for building new Bitcoin transactions. +3. **RBF Functions**: Utilities for creating Replace-By-Fee transactions. +4. **CPFP Functions**: Utilities for creating Child-Pays-For-Parent transactions. +5. **Utility Functions**: Helper functions for various Bitcoin-related calculations. + +## Transaction Analyzer + +The `TransactionAnalyzer` class is the cornerstone of the package, providing comprehensive analysis of Bitcoin transactions. + +### Features: + +- Analyzes transaction inputs, outputs, fees, and size. +- Determines RBF and CPFP eligibility. +- Recommends optimal fee bumping strategy. +- Estimates fees for RBF and CPFP operations. + +### Usage: + +```javascript +const analyzer = new TransactionAnalyzer({ + txHex: "raw_transaction_hex", + network: Network.MAINNET, + targetFeeRate: 5, // sats/vbyte + absoluteFee: "1000", // in satoshis + availableUtxos: [...], // array of available UTXOs + requiredSigners: 2, + totalSigners: 3, + addressType: "P2WSH" +}); + +const analysis = analyzer.analyze(); +console.log(analysis); + +``` +## Example Output + +```json +{ + "txid": "1a2b3c4d5e6f...", + "vsize": 140, + "weight": 560, + "fee": "1000", + "feeRate": 7.14, + "inputs": [...], + "outputs": [...], + "isRBFSignaled": true, + "canRBF": true, + "canCPFP": true, + "recommendedStrategy": "RBF", + "estimatedRBFFee": "1200", + "estimatedCPFPFee": "1500" +} +``` + +# BTC Transaction Template + +The `BtcTransactionTemplate` class provides a flexible way to construct new Bitcoin transactions, particularly useful for RBF and CPFP operations. + +## Why Use a Template? + +The template approach offers several advantages: + +- **Flexibility**: Easily add, remove, or modify inputs and outputs. +- **Incremental Building**: Build transactions step-by-step, adjusting as needed and validating as needed. +- **Fee Management**: Automatically calculate and adjust fees based on target rates. +- **Change Handling**: Dynamically manage change outputs. + +## Key Methods + +- **`addInput(input: BtcTxInputTemplate)`**: Add a new input to the transaction. +- **`addOutput(output: BtcTxOutputTemplate)`**: Add a new output to the transaction. +- **`adjustChangeOutput()`**: Automatically adjust the change output based on fees. +- **`validate()`**: Ensure the transaction meets all necessary criteria. +- **`toPsbt()`**: Convert the transaction to a Partially Signed Bitcoin Transaction (PSBT). + +## Usage Example + +```javascript +const txTemplate = new BtcTransactionTemplate({ + targetFeeRate: 5, + dustThreshold: "546", + network: Network.MAINNET, + scriptType: "P2WSH", + requiredSigners: 2, + totalSigners: 3 +}); + +txTemplate.addInput(new BtcTxInputTemplate({ + txid: "previous_txid", + vout: 0, + amountSats: "100000" +})); + +txTemplate.addOutput(new BtcTxOutputTemplate({ + address: "recipient_address", + amountSats: "90000", + type: TxOutputType.EXTERNAL +})); + +txTemplate.adjustChangeOutput(); + +if (txTemplate.validate()) { + const psbt = txTemplate.toPsbt(); + console.log("PSBT:", psbt); +} +``` + +## RBF (Replace-By-Fee) + +The RBF functionality allows for the creation of transactions that replace existing unconfirmed transactions with higher fee versions. + +### Key Functions + +- **`createCancelRbfTransaction`**: Creates a transaction that cancels an existing unconfirmed transaction. +- **`createAcceleratedRbfTransaction`**: Creates a transaction that accelerates an existing unconfirmed transaction. + +### RBF Calculations + +The package provides flexibility in defining constraints for fee bumping while ensuring compliance with BIP125 rules. +It performs the following key actions: + +- Allows users to specify custom fee rate and absolute fee targets. +- Ensures the new transaction meets BIP125 requirements, including: + - At least one input from the original transaction. + - New fee must be higher than the old fee. + - New absolute fee must meet the minimum relay fee requirement. +- Performs sanity checks to prevent overpayment and ensure transaction validity. + + +## Usage Example + +```javascript +const cancelRbfTx = createCancelRbfTransaction( + { + originalTx: "020000000001...", // original transaction hex + availableInputs: [ + { txid: "abc123...", vout: 0, value: "10000" }, + // ... more UTXOs + ], + cancelAddress: "bc1q...", + network: Network.MAINNET, + dustThreshold: "546", + scriptType: "P2WSH", + requiredSigners: 2, + totalSigners: 3, + targetFeeRate: 5, + absoluteFee: "1000", + fullRBF: false, + strict: true + } +); + +console.log("Cancel RBF PSBT:", cancelRbfTx); +// Example output: +// Cancel RBF PSBT: cHNidP8BAH0CAAAAAbhbgd8Rm7xkjyGgIPz/tQm8YUH4xXcK... +``` + +## CPFP (Child-Pays-For-Parent) + +The **CPFP** functionality allows for the creation of child transactions that increase the effective fee rate of unconfirmed parent transactions. + +### Key Function + +**`createCPFPTransaction`**: Creates a child transaction that spends an output from an unconfirmed parent transaction, including a higher fee to incentivize confirmation of both transactions. + +### CPFP Calculations + +The package calculates the necessary fee for the child transaction to bring the overall package (parent + child) fee rate up to the desired level using the following formula: + +```plaintext +child_fee = (target_fee_rate * (parent_size + child_size)) - parent_fee +``` +## Usage Example +```javascript +const cpfpTx = createCPFPTransaction( + { + originalTx: "020000000001...", // original transaction hex + availableInputs: [ + { txid: "def456...", vout: 1, value: "20000" }, + // ... more UTXOs + ], + spendableOutputIndex: 1, + changeAddress: "bc1q...", + network: Network.MAINNET, + dustThreshold: "546", + scriptType: "P2WSH", + targetFeeRate: 10, + absoluteFee: "1000", + requiredSigners: 2, + totalSigners: 3, + strict: true + } +); + +console.log("CPFP PSBT:", cpfpTx); +// Example output: +// CPFP PSBT: cHNidP8BAH0CAAAAAT+X8zhpWKt0cK8nYEslhQLwCxFR5Zk3wl... + +``` + + +### Manual RBF Implementation: + +```javascript +// Analyze the original transaction +const analyzer = new TransactionAnalyzer({...}); +const analysis = analyzer.analyze(); + +// Create a new transaction template +const rbfTemplate = new BtcTransactionTemplate({...}); + +// Add at least one input from the original transaction +const originalInput = analysis.inputs[0]; +rbfTemplate.addInput(new BtcTxInputTemplate({ + txid: originalInput.txid, + vout: originalInput.vout, + amountSats: originalInput.amountSats +})); + +// Add more inputs if necessary +while (!rbfTemplate.areFeesPaid()) { + // Add additional input... +} + +// Add outputs (keeping original outputs for acceleration, or new output for cancellation) +analysis.outputs.forEach(output => { + rbfTemplate.addOutput(new BtcTxOutputTemplate({ + address: output.address, + amountSats: output.value.toString(), + type: TxOutputType.EXTERNAL + })); +}); + +// Adjust change output +rbfTemplate.adjustChangeOutput(); + +// Validate and create PSBT +if (rbfTemplate.validate()) { + const psbt = rbfTemplate.toPsbt(); + console.log("RBF PSBT:", psbt); +} +``` + +### Manual CPFP Implementation: + +```javascript +// Analyze the parent transaction +const analyzer = new TransactionAnalyzer({...}); +const analysis = analyzer.analyze(); + +// Create a new transaction template for the child +const cpfpTemplate = new BtcTransactionTemplate({...}); + +// Add the spendable output from the parent as an input +const parentOutput = analysis.outputs[spendableOutputIndex]; +cpfpTemplate.addInput(new BtcTxInputTemplate({ + txid: analysis.txid, + vout: spendableOutputIndex, + amountSats: parentOutput.value.toString() +})); + +// Add a change output +cpfpTemplate.addOutput(new BtcTxOutputTemplate({ + address: changeAddress, + amountSats: "0", // Will be adjusted later + type: TxOutputType.CHANGE +})); + +// Add additional inputs if necessary +while (!cpfpTemplate.areFeesPaid()) { + // Add additional input... +} + +// Adjust change output +cpfpTemplate.adjustChangeOutput(); + +// Validate and create PSBT +if (cpfpTemplate.validate()) { + const psbt = cpfpTemplate.toPsbt(); + console.log("CPFP PSBT:", psbt); +} +``` +## Advanced Customization + +The package allows for advanced customization through various options: + +- **Custom Fee Calculations**: Implement custom fee estimation logic by extending the `TransactionAnalyzer` class. +- **Transaction Templates**: Create custom transaction templates for specific use cases by extending `BtcTransactionTemplate`. + + +## Best Practices + +- **Validate Transactions**: Always validate transactions before broadcasting. +- **Consider Mempool State**: Consider the mempool state and current network conditions when setting fee rates. +- **Use Strict Mode**: Use the strict mode in RBF/CPFP transactions for mission-critical operations. +- **Update Regularly**: Regularly update the package to ensure compatibility with the latest Bitcoin network rules. + +## References + +- **[BIP 125: Opt-in Full Replace-by-Fee Signaling](https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki)** +- **[Bitcoin Core RBF Implementation](https://github.com/bitcoin/bitcoin/pull/6871)** +- **[Bitcoin Optech: Replace-by-Fee](https://bitcoinops.org/en/topics/replace-by-fee/)** +- **[Bitcoin Optech: Child Pays for Parent](https://bitcoinops.org/en/topics/cpfp/)** +- **[BIP 174: Partially Signed Bitcoin Transaction Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki)** + +This package provides a comprehensive solution for managing Bitcoin transaction fees, particularly focusing on RBF and CPFP strategies. By leveraging the `TransactionAnalyzer` and `BtcTransactionTemplate` classes, developers can easily implement complex fee bumping strategies in their applications, ensuring efficient and timely transaction processing on the Bitcoin network. diff --git a/packages/caravan-fees/jest.config.ts b/packages/caravan-fees/jest.config.ts new file mode 100644 index 00000000..24ad6c77 --- /dev/null +++ b/packages/caravan-fees/jest.config.ts @@ -0,0 +1,22 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +const config: JestConfigWithTsJest = { + extensionsToTreatAsEsm: [".ts"], + verbose: true, + + testEnvironment: "node", + testPathIgnorePatterns: ["./lib"], + transformIgnorePatterns: ["/node_modules/(?!uint8array-tools)"], + transform: { + "^.+\\.js$": "babel-jest", + "^.+\\.ts?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + setupFiles: ["/jest.setup.ts"], +}; + +export default config; diff --git a/packages/caravan-fees/jest.setup.ts b/packages/caravan-fees/jest.setup.ts new file mode 100644 index 00000000..6e1e3fc6 --- /dev/null +++ b/packages/caravan-fees/jest.setup.ts @@ -0,0 +1,12 @@ +import { TextEncoder, TextDecoder } from "util"; +import "@inrupt/jest-jsdom-polyfills"; + +// Polyfill TextEncoder and TextDecoder +global.TextEncoder = TextEncoder as any; +global.TextDecoder = TextDecoder as any; + +// Define `self` for environments where it's not available (like Node.js) +(global as any).self = global; + +// Polyfill missing properties for `Window` type compatibility +(global as any).name = ""; diff --git a/packages/caravan-fees/package.json b/packages/caravan-fees/package.json new file mode 100644 index 00000000..0c0633f7 --- /dev/null +++ b/packages/caravan-fees/package.json @@ -0,0 +1,51 @@ +{ + "name": "@caravan/fees", + "version": "1.0.0-beta", + "description": "Utility library for fee bumping bitcoin transactions", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.mjs", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "ci": "npm run lint && npm run test", + "dev": "npm run build -- --watch", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "test": "jest src/tests", + "test:watch": "jest --watch src", + "lint": "eslint src" + }, + "keywords": [ + "bitcoin", + "cpfp", + "rbf", + "feebumping", + "blockchain" + ], + "author": "Mrigesh Thakur", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "devDependencies": { + "prettier": "^3.2.5", + "@inrupt/jest-jsdom-polyfills": "^3.2.1", + "ts-jest": "^29.0.5", + "tsup": "^7.2.0", + "typescript": "^4.9.5", + "@caravan/typescript-config": "*", + "esbuild-plugin-polyfill-node": "^0.3.0" + }, + "dependencies": { + "@caravan/bitcoin": "*", + "@caravan/psbt": "*", + "bignumber.js": "^9.1.2", + "bitcoinjs-lib-v6": "npm:bitcoinjs-lib@^6.1.5" + } +} diff --git a/packages/caravan-fees/src/btcTransactionComponents.ts b/packages/caravan-fees/src/btcTransactionComponents.ts new file mode 100644 index 00000000..2af82cac --- /dev/null +++ b/packages/caravan-fees/src/btcTransactionComponents.ts @@ -0,0 +1,366 @@ +import { Satoshis, BTC, UTXO } from "./types"; +import { validateNonWitnessUtxo, validateSequence } from "./utils"; +import { satoshisToBitcoins } from "@caravan/bitcoin"; +import BigNumber from "bignumber.js"; + +/** + * Abstract base class for Bitcoin transaction components (inputs and outputs). + * Provides common functionality for inputs and outputs. + */ +export abstract class BtcTxComponent { + protected _amountSats: BigNumber; + + /** + * @param amountSats - The amount in satoshis (as a string) + */ + constructor(amountSats: Satoshis) { + this._amountSats = new BigNumber(amountSats); + } + + /** + * Get the amount in satoshis + * @returns The amount in satoshis (as a string) + */ + get amountSats(): Satoshis { + return this._amountSats.toString(); + } + + /** + * Set amount in satoshis + * @param amountSats - New amount in satoshis (as a string) + */ + set amountSats(value: Satoshis) { + this._amountSats = new BigNumber(value); + } + + /** + * Get the amount in BTC + * @returns The amount in BTC (as a string) + */ + get amountBTC(): BTC { + return satoshisToBitcoins(this._amountSats.toString()); + } + + hasAmount(): boolean { + return this._amountSats.isGreaterThanOrEqualTo(0); + } + + /** + * Check if the component is valid + * @returns True if valid, false otherwise + */ + abstract isValid(): boolean; +} + +/** + * Represents a Bitcoin transaction input template for PSBT creation. + * This class contains the minimal required fields and optional fields + * necessary for creating a valid PSBT input. + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki + */ +export class BtcTxInputTemplate extends BtcTxComponent { + private readonly _txid: string; + private readonly _vout: number; + private _nonWitnessUtxo?: Buffer; + private _witnessUtxo?: { + script: Buffer; + value: number; + }; + private _sequence?: number; + + /** + * @param {Object} params - The parameters for creating a BtcTxInputTemplate + * @param {string} params.txid - The transaction ID of the UTXO (reversed, big-endian) + * @param {number} params.vout - The output index in the transaction + * @param {Satoshis} params.amountSats - The amount in satoshis + */ + constructor(params: { txid: string; vout: number; amountSats?: Satoshis }) { + super(params.amountSats || "0"); + this._txid = params.txid; + this._vout = params.vout; + } + /** + * Creates a BtcTxInputTemplate from a UTXO object. + */ + static fromUTXO(utxo: UTXO): BtcTxInputTemplate { + const template = new BtcTxInputTemplate({ + txid: utxo.txid, + vout: utxo.vout, + amountSats: utxo.value, + }); + + if (utxo.prevTxHex) { + template.setNonWitnessUtxo(Buffer.from(utxo.prevTxHex, "hex")); + } + + if (utxo.witnessUtxo) { + template.setWitnessUtxo(utxo.witnessUtxo); + } + + return template; + } + /** + * The transaction ID of the UTXO (reversed, big-endian). + * Required for all PSBT inputs. + */ + get txid(): string { + return this._txid; + } + + /** + * The output index in the transaction. + * Required for all PSBT inputs. + */ + get vout(): number { + return this._vout; + } + + /** Get the sequence number */ + get sequence(): number | undefined { + return this._sequence; + } + + /** + * Sets the sequence number for the input. + * Optional, but useful for RBF signaling. + * @param {number} sequence - The sequence number + */ + setSequence(sequence: number): void { + if (!validateSequence(sequence)) { + throw new Error("Invalid sequence number"); + } + this._sequence = sequence; + } + + /** + * Enables Replace-By-Fee (RBF) signaling for this input. + * Sets the sequence number to 0xfffffffd . + */ + enableRBF(): void { + this.setSequence(0xfffffffd); + } + + /** + * Disables Replace-By-Fee (RBF) signaling for this input. + * Sets the sequence number to 0xffffffff. + */ + disableRBF(): void { + this.setSequence(0xffffffff); + } + + /** + * Checks if RBF is enabled for this input. + * @returns {boolean} True if RBF is enabled, false otherwise. + */ + isRBFEnabled(): boolean { + return this._sequence !== undefined && this._sequence < 0xfffffffe; + } + + /** + * Gets the non-witness UTXO. + */ + get nonWitnessUtxo(): Buffer | undefined { + return this._nonWitnessUtxo; + } + + /** + * Sets the non-witness UTXO. + * Required for non-segwit inputs in PSBTs. + * @param {Buffer} value - The full transaction containing the UTXO being spent + */ + setNonWitnessUtxo(value: Buffer): void { + if (!validateNonWitnessUtxo(value, this._txid, this._vout)) { + throw new Error("Invalid non-witness UTXO"); + } + this._nonWitnessUtxo = value; + } + + /** + * Gets the witness UTXO. + */ + get witnessUtxo(): { script: Buffer; value: number } | undefined { + return this._witnessUtxo; + } + + /** + * Sets the witness UTXO. + * Required for segwit inputs in PSBTs. + * @param {Object} value - The witness UTXO + * @param {Buffer} value.script - The scriptPubKey of the output + * @param {number} value.value - The value of the output in satoshis + */ + setWitnessUtxo(value: { script: Buffer; value: number }): void { + this._witnessUtxo = value; + } + + /** + * Check if the input is valid + * @returns True if the amount is positive and exists, and txid and vout are valid + */ + isValid(): boolean { + return this.hasAmount() && this._txid !== "" && this._vout >= 0; + } + + /** + * Checks if the input has the required fields for PSBT creation. + */ + hasRequiredFieldsforPSBT(): boolean { + return Boolean( + this._txid && + this._vout >= 0 && + (this._nonWitnessUtxo || this._witnessUtxo), + ); + } + + /** + * Converts the input template to a UTXO object. + */ + toUTXO(): UTXO { + return { + txid: this._txid, + vout: this._vout, + value: this._amountSats.toString(), + prevTxHex: this._nonWitnessUtxo?.toString("hex"), + witnessUtxo: this._witnessUtxo, + }; + } +} +/** + * Represents a Bitcoin transaction output + */ +export class BtcTxOutputTemplate extends BtcTxComponent { + private readonly _address: string; + private _malleable: boolean = true; + + /** + * @param params - Output parameters + * @param params.address - Recipient address + * @param params.amountSats - Amount in satoshis (as a string) + * @param params.locked - Whether the output is locked (immutable), defaults to false + * @throws Error if trying to create a locked output with zero amount + */ + constructor(params: { + address: string; + amountSats?: Satoshis | undefined; + locked?: boolean; + }) { + super(params.amountSats || "0"); + this._address = params.address; + this._malleable = !params.locked; + + if (!this._malleable && this._amountSats.isEqualTo(0)) { + throw new Error("Locked outputs must have an amount specified."); + } + } + + /** Get the recipient address */ + get address(): string { + return this._address; + } + + /** Check if the output is malleable (can be modified) */ + get isMalleable(): boolean { + return this._malleable; + } + + /** + * Set a new amount for the output + * @param amountSats - New amount in satoshis(as a string) + * @throws Error if trying to modify a non-malleable output + */ + setAmount(amountSats: Satoshis): void { + if (!this._malleable) { + throw new Error("Cannot modify non-malleable output"); + } + this._amountSats = new BigNumber(amountSats); + } + + /** + * Add amount to the output + * @param amountSats - Amount to add in satoshis (as a string) + * @throws Error if trying to modify a non-malleable output + */ + addAmount(amountSats: Satoshis): void { + if (!this._malleable) { + throw new Error("Cannot modify non-malleable output"); + } + this._amountSats = this._amountSats.plus(new BigNumber(amountSats)); + } + + /** + * Subtract amount from the output + * @param amountSats - Amount to subtract in satoshis (as a string) + * @throws Error if trying to modify a non-malleable output or if subtracting more than the current amount + */ + subtractAmount(amountSats: Satoshis): void { + if (!this._malleable) { + throw new Error("Cannot modify non-malleable output"); + } + const subtractAmount = new BigNumber(amountSats); + if (subtractAmount.isGreaterThan(this._amountSats)) { + throw new Error("Cannot subtract more than the current amount"); + } + this.setAmount(this._amountSats.minus(subtractAmount).toString()); + } + + /** + * Locks the output, preventing further modifications to its amount. + * + * This method sets the malleability of the output to false. Once called, + * the output amount cannot be changed. If the output is already locked, + * this method has no effect. + * + * Typical use cases include: + * - Finalizing a transaction before signing + * - Ensuring that certain outputs (like recipient amounts) are not accidentally modified + * + * An amount must be specified before locking. This is to prevent + * locking an output with a zero amount, which could lead to invalid transactions. + * + * @throws {Error} If trying to lock an output with a zero amount + */ + lock(): void { + if (!this.isMalleable) { + // Output is already locked, so we just return without doing anything + return; + } + + if (this._amountSats.isEqualTo(0)) { + throw new Error("Cannot lock an output with a zero amount."); + } + + this._malleable = false; + } + + /** + * Checks if the output is valid according to basic Bitcoin transaction rules. + * + * This method performs several checks to ensure the output is properly formed: + * + * 1. For locked outputs: + * - Ensures that a non-zero amount is specified. + * - Throws an error if the amount is zero, as locked outputs must always have a valid amount. + * + * 2. For all output types: + * - Checks if the output has a non-zero amount (via hasAmount() method). + * - Verifies that the address is not an empty string. + * + * Note: This method does not perform exhaustive validation. For more thorough checks, + * consider implementing a separate, comprehensive validation method. + * + * Special considerations: + * - OP_RETURN outputs might require different validation logic (not implemented here). + * - Zero-amount outputs for certain special cases (like Ephemeral Anchors) are not + * considered valid by this basic check. Implement custom logic if needed for such cases. + * + * @returns {boolean} True if the output is valid, false otherwise. + * @throws {Error} If a locked output has a zero amount. + */ + isValid(): boolean { + if (!this.isMalleable && this._amountSats.isEqualTo(0)) { + throw new Error("Locked outputs must have a non-zero amount specified"); + } + return this.hasAmount() && this._address !== ""; + } +} diff --git a/packages/caravan-fees/src/btcTransactionTemplate.ts b/packages/caravan-fees/src/btcTransactionTemplate.ts new file mode 100644 index 00000000..4087d065 --- /dev/null +++ b/packages/caravan-fees/src/btcTransactionTemplate.ts @@ -0,0 +1,691 @@ +import { + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "./btcTransactionComponents"; +import { Network } from "@caravan/bitcoin"; +import { PsbtV2 } from "@caravan/psbt"; +import { Satoshis, TransactionTemplateOptions, ScriptType } from "./types"; +import BigNumber from "bignumber.js"; +import { + DEFAULT_DUST_THRESHOLD_IN_SATS, + ABSURDLY_HIGH_ABS_FEE, + ABSURDLY_HIGH_FEE_RATE, +} from "./constants"; +import { + createOutputScript, + estimateTransactionVsize, + initializePsbt, + getOutputAddress, + parseWitnessUtxoValue, + parseNonWitnessUtxoValue, +} from "./utils"; + +/** + * Represents a Bitcoin transaction template. + * This class is used to construct and manipulate Bitcoin transactions. + */ +export class BtcTransactionTemplate { + private readonly _inputs: BtcTxInputTemplate[]; + private readonly _outputs: BtcTxOutputTemplate[]; + private readonly _targetFeeRate: BigNumber; + private readonly _dustThreshold: BigNumber; + private readonly _network: Network; + private readonly _scriptType: ScriptType; + private readonly _requiredSigners: number; + private readonly _totalSigners: number; + + /** + * Creates a new BtcTransactionTemplate instance. + * @param options - Configuration options for the transaction template + */ + constructor(options: TransactionTemplateOptions) { + this._inputs = options.inputs || []; + this._outputs = options.outputs || []; + this._targetFeeRate = new BigNumber(options.targetFeeRate); + this._dustThreshold = new BigNumber( + options.dustThreshold || DEFAULT_DUST_THRESHOLD_IN_SATS, + ); + this._network = options.network; + this._scriptType = options.scriptType; + this._requiredSigners = options.requiredSigners; + this._totalSigners = options.totalSigners; + } + + /** + * Creates a BtcTransactionTemplate from a raw PSBT hex string. + * This method parses the PSBT, extracts input and output information, + * and creates a new BtcTransactionTemplate instance. + * + * @param rawPsbt - The raw PSBT {PsbtV2 | string | Buffer} + * @param options - Additional options for creating the template + * @returns A new BtcTransactionTemplate instance + * @throws Error if PSBT parsing fails or required information is missing + */ + static fromPsbt( + rawPsbt: string, + options: Omit, + ): BtcTransactionTemplate { + const psbt = initializePsbt(rawPsbt); + const inputs = BtcTransactionTemplate.processInputs(psbt); + const outputs = BtcTransactionTemplate.processOutputs( + psbt, + options.network, + ); + + return new BtcTransactionTemplate({ + ...options, + inputs, + outputs, + }); + } + + /** + * Gets the inputs of the transaction. + * @returns A read-only array of inputs + */ + get inputs(): readonly BtcTxInputTemplate[] { + return this._inputs; + } + + /** + * Gets the outputs of the transaction. + * @returns A read-only array of outputs + */ + get outputs(): readonly BtcTxOutputTemplate[] { + return this._outputs; + } + + /** + * Gets the malleable outputs of the transaction. + * Malleable outputs are all those that can have their amount changed, e.g. change outputs. + * @returns An array of malleable outputs + */ + get malleableOutputs(): BtcTxOutputTemplate[] { + return this._outputs.filter((output) => output.isMalleable); + } + + /** + * Calculates the target fees to pay based on the estimated size and target fee rate. + * @returns {Satoshis} The target fees in satoshis (as a string) + */ + get targetFeesToPay(): Satoshis { + return this.targetFees().toString(); + } + + /** + * Calculates the current fee of the transaction. + * @returns {Satoshis} The current fee in satoshis (as a string) + */ + get currentFee(): Satoshis { + return this.calculateCurrentFee().toString(); + } + + /** + * Checks if the transaction needs a change output. + * @returns {boolean} True if there's enough leftover funds for a change output above the dust threshold. + */ + get needsChange(): boolean { + const totalInput = this.calculateTotalInputAmount(); + const totalOutput = this.calculateTotalOutputAmount(); + const fee = new BigNumber(this.targetFeesToPay); + const leftover = totalInput.minus(totalOutput).minus(fee); + return leftover.isGreaterThan(this._dustThreshold); + } + + /** + * Checks if the current fees are sufficient to meet the target fee rate. + * @returns True if the fees are paid, false otherwise + */ + areFeesPaid(): boolean { + return this.calculateCurrentFee().gte(this.targetFees()); + } + + /** + * Checks if the current fee rate meets or exceeds the target fee rate. + * @returns True if the fee rate is satisfied, false otherwise + */ + get feeRateSatisfied(): boolean { + return new BigNumber(this.estimatedFeeRate).gte(this._targetFeeRate); + } + + /** + * Determines if a change output is needed. + * @returns True if a change output is needed, false otherwise + */ + get needsChangeOutput(): boolean { + const MAX_OUTPUT_SIZE = 43; // Large enough to cover P2SH and P2WSH multisig outputs + const changeOutputFee = new BigNumber(this._targetFeeRate).multipliedBy( + MAX_OUTPUT_SIZE, + ); + // Calculate the buffer: target fees + change output fee + dust threshold + const changeOutputCost = this.targetFees() + .plus(changeOutputFee) + .plus(this._dustThreshold); + + return ( + !this.malleableOutputs.length && + this.calculateCurrentFee().gt(changeOutputCost) + ); + } + + /** + * Calculates the total input amount. + * @returns {Satoshis} The total input amount in satoshis (as a string) + */ + get totalInputAmount(): Satoshis { + return this.calculateTotalInputAmount().toString(); + } + + /** + * Calculates the total change amount. (Total Inputs Amount - Total Non-change (non-malleable) Outputs Amount ) + * @returns {Satoshis} The total change amount in satoshis (as a string) + */ + get changeAmount(): Satoshis { + return this.calculateChangeAmount().toString(); + } + + /** + * Calculates the total output amount. + * @returns {Satoshis} The total output amount in satoshis (as a string) + */ + get totalOutputAmount(): Satoshis { + return this.calculateTotalOutputAmount().toString(); + } + + /** + * Estimates the virtual size of the transaction. + * @returns The estimated virtual size in vbytes + */ + get estimatedVsize(): number { + return estimateTransactionVsize({ + addressType: this._scriptType, + numInputs: this._inputs.length, + numOutputs: this._outputs.length, + m: this._requiredSigners, + n: this._totalSigners, + }); + } + + /** + * Calculates the estimated fee rate of the transaction. + * @returns {string} The estimated fee rate in satoshis per vbyte + */ + get estimatedFeeRate(): string { + return this.calculateCurrentFee() + .dividedBy(this.calculateEstimatedVsize()) + .toString(); + } + + /** + * Adds an input to the transaction. + * @param input - The input to add + */ + addInput(input: BtcTxInputTemplate): void { + this._inputs.push(input); + } + + /** + * Adds an output to the transaction. + * @param output - The output to add + */ + addOutput(output: BtcTxOutputTemplate): void { + this._outputs.push(output); + } + + /** + * Removes an output from the transaction. + * @param index - The index of the output to remove + */ + removeOutput(index: number): void { + this._outputs.splice(index, 1); + } + + /** + * Adjusts the change output of the transaction. + * This method calculates a new change amount based on the current inputs, + * non-change outputs, and the target fee. It then updates the change output + * or removes it if the new amount is below the dust threshold. + * + * Key behaviors: + * 1. If there are multiple outputs and the change becomes dust, it removes the change output. + * 2. If there's only one output (which must be the change output) and it becomes dust, + * it keeps the output to maintain a valid transaction structure. + * 3. It calculates the difference between the new and current change amount. + * 4. It ensures the transaction remains balanced after adjustment. + * + * @returns {string | null} The new change amount in satoshis as a string, or null if no adjustment was made or the change output was removed. + * @throws {Error} If there's not enough input to satisfy non-change outputs and fees, or if the transaction doesn't balance after adjustment. + */ + adjustChangeOutput(): Satoshis | null { + if (this.malleableOutputs.length === 0) return null; + + // TO DO (MRIGESH): + // To handle for multiple change outputs + + const changeOutput = this.malleableOutputs[0]; + const totalOutWithoutChange = this.calculateTotalOutputAmount().minus( + this.calculateChangeAmount(), + ); + const currentChangeAmount = new BigNumber(changeOutput.amountSats); + + const newChangeAmount = this.calculateTotalInputAmount() + .minus(totalOutWithoutChange) + .minus(this.targetFees()); + + if (newChangeAmount.lt(0)) { + throw new Error( + `Input amount ${newChangeAmount.toString()} not enough to satisfy non-change output amounts.`, + ); + } + + const changeAmountDiff = newChangeAmount.minus(currentChangeAmount); + + // add change amount + changeOutput.addAmount(changeAmountDiff.toString()); + + // Check if the new change amount is below the dust threshold + if (new BigNumber(changeOutput.amountSats).lt(this._dustThreshold)) { + if (this.outputs.length > 1) { + // If there are multiple outputs, we can remove the dust change output + const changeOutputIndex = this.outputs.findIndex( + (output) => output === changeOutput, + ); + if (changeOutputIndex !== -1) { + this.removeOutput(changeOutputIndex); + } + // The fee will automatically adjust as it's calculated based on inputs minus outputs + return null; + } else { + // If this is the only output, we must keep it to have a valid transaction + console.warn( + "Change output is below dust threshold but cannot be removed as it's the only output.", + ); + } + } + + // get current out amount after adjustment + const balanceCheck = this.calculateTotalInputAmount() + .minus(this.calculateTotalOutputAmount()) + .minus(this.targetFees()); + + if (!balanceCheck.isZero()) { + throw new Error( + `Transaction does not balance. Discrepancy: ${balanceCheck.toString()} satoshis`, + ); + } + + return newChangeAmount.toString(); + } + + /** + * Validates the entire transaction template. + * + * This method performs a comprehensive check of the transaction, including: + * 1. Validation of all inputs: + * - Checks if each input has the required fields for PSBT creation. + * - Validates each input's general structure and data. + * 2. Validation of all outputs: + * - Ensures each output has a valid address and amount. + * 3. Verification that the current fee meets or exceeds the target fee + * 4. Check that the fee rate is not absurdly high + * 5. Check that the absolute fee is not absurdly high + * + * @returns {boolean} True if the transaction is valid according to all checks, false otherwise. + * + * @throws {Error} If any validation check encounters an unexpected error. + * + * @example + * const txTemplate = new BtcTransactionTemplate(options); + * if (txTemplate.validate()) { + * console.log("Transaction is valid"); + * } else { + * console.log("Transaction is invalid"); + * } + */ + validate(): boolean { + // 1. Validate all inputs + if (!this.validateInputs()) { + return false; + } + + // 2. Validate each output + if (!this._outputs.every((output) => output.isValid())) { + return false; + } + + // 3. Check if fees hit the target + if (this.calculateCurrentFee().lt(this.targetFees())) { + return false; + } + + // 4. Check if the fee rate isn't absurd + const feeRate = new BigNumber(this.estimatedFeeRate); + if (feeRate.gte(new BigNumber(ABSURDLY_HIGH_FEE_RATE))) { + return false; + } + + // 5. Check if the absolute fee isn't absurd + if (this.calculateCurrentFee().gte(new BigNumber(ABSURDLY_HIGH_ABS_FEE))) { + return false; + } + + return true; + } + + /** + * Converts the transaction template to a base64-encoded PSBT (Partially Signed Bitcoin Transaction) string. + * This method creates a new PSBT, adds all valid inputs and outputs from the template, + * and then serializes the PSBT to a base64 string. + * + * By default, it validates the entire transaction before creating the PSBT. This validation + * can be optionally skipped for partial or in-progress transactions. + * + * The method performs the following steps: + * 1. If validation is enabled (default), it calls the `validate()` method to ensure + * the transaction is valid. + * 2. Creates a new PsbtV2 instance. + * 3. Adds all inputs from the template to the PSBT, including UTXO information. + * 4. Adds all outputs from the template to the PSBT. + * 5. Serializes the PSBT to a base64-encoded string. + * + * @param {boolean} [validated=true] - Whether to validate the transaction before creating the PSBT. + * Set to false to skip validation for partial transactions. + * + * @returns A base64-encoded string representation of the PSBT. + * + * @throws {Error} If validation is enabled and the transaction fails validation checks. + * @throws {Error} If an invalid address is encountered when creating an output script. + * @throws {Error} If there's an issue with input or output data that prevents PSBT creation. + * @throws {Error} If serialization of the PSBT fails. + * + * @remarks + * - Only inputs and outputs that pass the `isInputValid` and `isOutputValid` checks are included. + * - Input amounts are not included in the PSBT. If needed, they should be added separately. + * - Output amounts are converted from string to integer (satoshis) when added to the PSBT. + * - The resulting PSBT is not signed and may require further processing (e.g., signing) before it can be broadcast. + */ + toPsbt(validated: boolean = true): string { + if (validated && !this.validate()) { + throw new Error("Invalid transaction: failed validation checks"); + } + + const psbt = new PsbtV2(); + + // Add Inputs to PSBT + this._inputs.forEach((input) => this.addInputToPsbt(psbt, input)); // already checks for validity + + // Add Outputs to PSBT + this._outputs.forEach((output) => this.addOutputToPsbt(psbt, output)); + + return psbt.serialize("base64"); + } + + /** + * Validates all inputs in the transaction. + * + * This method checks each input to ensure it has the necessary previous + * transaction data (`witness utxo, non-witness utxo`). The previous transaction data is + * crucial for validating the input, as it allows verification of the + * UTXO being spent, ensuring the input references a legitimate and + * unspent output. + * + * @param input - The input to check. + * @returns {boolean} - Returns true if all inputs are valid, meaning they have + * the required previous transaction data and meet other + * validation criteria; returns false otherwise. + */ + private validateInputs(): boolean { + return this._inputs.every( + (input) => input.hasRequiredFieldsforPSBT() && input.isValid(), + ); + } + + /** + * Calculates the total input amount. + * @returns {BigNumber} The total input amount in satoshis + * @private + */ + private calculateTotalInputAmount(): BigNumber { + return this.inputs.reduce((sum, input) => { + if (!input.isValid()) { + throw new Error(`Invalid input: ${JSON.stringify(input)}`); + } + return sum.plus(new BigNumber(input.amountSats)); + }, new BigNumber(0)); + } + + /** + * Calculates the total output amount. + * @returns {BigNumber} The total output amount in satoshis + * @private + */ + private calculateTotalOutputAmount(): BigNumber { + return this.outputs.reduce((sum, output) => { + if (!output.isValid()) { + throw new Error(`Invalid output: ${JSON.stringify(output)}`); + } + return sum.plus(new BigNumber(output.amountSats)); + }, new BigNumber(0)); + } + + /** + * Calculates the total change amount. + * @returns {BigNumber} The total change amount in satoshis + * @private + */ + private calculateChangeAmount(): BigNumber { + return this.outputs.reduce( + (acc, output) => + output.isMalleable ? acc.plus(new BigNumber(output.amountSats)) : acc, + new BigNumber(0), + ); + } + + /** + * Calculates the estimated virtual size of the transaction. + * @returns {number} The estimated virtual size in vbytes + * @private + */ + private calculateEstimatedVsize(): number { + return estimateTransactionVsize({ + addressType: this._scriptType, + numInputs: this.inputs.length, + numOutputs: this.outputs.length, + m: this._requiredSigners, + n: this._totalSigners, + }); + } + + /** + * Calculates the current fee of the transaction. + * @returns {BigNumber} The current fee in satoshis (as a BN) + * @private + */ + private calculateCurrentFee(): BigNumber { + return this.calculateTotalInputAmount().minus( + this.calculateTotalOutputAmount(), + ); + } + + /** + * Calculates the target fees to pay based on the estimated size and target fee rate. + * @returns {Satoshis} The target fees in satoshis (as a BN) + * @private + */ + private targetFees(): BigNumber { + return this._targetFeeRate + .times(this.calculateEstimatedVsize()) + .integerValue(BigNumber.ROUND_CEIL); + } + + /** + * Processes the inputs from a PSBT and creates BtcTxInputTemplate instances. + * + * @param psbt - The initialized PSBT + * @returns An array of BtcTxInputTemplate instances + * @throws Error if required input information is missing + */ + private static processInputs(psbt: PsbtV2): BtcTxInputTemplate[] { + const inputs: BtcTxInputTemplate[] = []; + + for (let i = 0; i < psbt.PSBT_GLOBAL_INPUT_COUNT; i++) { + const txid = psbt.PSBT_IN_PREVIOUS_TXID[i]; + const vout = psbt.PSBT_IN_OUTPUT_INDEX[i]; + + if (!txid || vout === undefined) { + throw new Error(`Missing txid or vout for input ${i}`); + } + + const input = new BtcTxInputTemplate({ + txid: Buffer.from(txid, "hex").reverse().toString("hex"), + vout, + }); + + BtcTransactionTemplate.setInputUtxo(input, psbt, i); + + inputs.push(input); + } + + return inputs; + } + + /** + * Sets the UTXO information for a given input. + * + * @param input - The BtcTxInputTemplate to update + * @param psbt - The PSBT containing the input information + * @param index - The index of the input in the PSBT + * @throws Error if UTXO information is missing + */ + private static setInputUtxo( + input: BtcTxInputTemplate, + psbt: PsbtV2, + index: number, + ): void { + const witnessUtxo = psbt.PSBT_IN_WITNESS_UTXO[index]; + const nonWitnessUtxo = psbt.PSBT_IN_NON_WITNESS_UTXO[index]; + + if (witnessUtxo) { + const amountSats = parseWitnessUtxoValue(witnessUtxo, index).toString(); + const witnessUtxoBuffer = Buffer.from(witnessUtxo, "hex"); + const value = witnessUtxoBuffer.readUInt32LE(0); + const script = witnessUtxoBuffer.slice(4); + + input.setWitnessUtxo({ script, value }); + input.amountSats = amountSats; + } else if (nonWitnessUtxo) { + const amountSats = parseNonWitnessUtxoValue( + nonWitnessUtxo, + index, + ).toString(); + input.setNonWitnessUtxo(Buffer.from(nonWitnessUtxo, "hex")); + input.amountSats = amountSats; + + input.setNonWitnessUtxo(Buffer.from(nonWitnessUtxo, "hex")); + input.amountSats = amountSats; + } else { + throw new Error(`Missing UTXO information for input ${index}`); + } + } + + /** + * Processes the outputs from a PSBT and creates BtcTxOutputTemplate instances. + * + * @param psbt - The initialized PSBT + * @param network - The Bitcoin network + * @returns An array of BtcTxOutputTemplate instances + * @throws Error if required output information is missing + */ + private static processOutputs( + psbt: PsbtV2, + network: Network, + ): BtcTxOutputTemplate[] { + const outputs: BtcTxOutputTemplate[] = []; + + for (let i = 0; i < psbt.PSBT_GLOBAL_OUTPUT_COUNT; i++) { + const script = Buffer.from(psbt.PSBT_OUT_SCRIPT[i], "hex"); + const amount = psbt.PSBT_OUT_AMOUNT[i]; + + if (!script || amount === undefined) { + throw new Error(`Missing script or amount for output ${i}`); + } + + const address = getOutputAddress(script, network); + if (!address) { + throw new Error(`Unable to derive address for output ${i}`); + } + + outputs.push( + new BtcTxOutputTemplate({ + address, + amountSats: amount.toString(), + locked: true, // We don't want to change these outputs + }), + ); + } + + return outputs; + } + + /** + * Adds a single input to the provided PSBT based on the given input template (used in BtcTransactionTemplate) + * @param {PsbtV2} psbt - The PsbtV2 object. + * @param input - The input template to be processed and added. + * @throws {Error} - Throws an error if script extraction or PSBT input addition fails. + */ + private addInputToPsbt(psbt: PsbtV2, input: BtcTxInputTemplate): void { + if (!input.hasRequiredFieldsforPSBT()) { + throw new Error( + `Input ${input.txid}:${input.vout} lacks required UTXO information`, + ); + } + + const inputData: any = { + previousTxId: input.txid, + outputIndex: input.vout, + }; + // Add non-witness UTXO if available + if (input.nonWitnessUtxo) { + inputData.nonWitnessUtxo = input.nonWitnessUtxo; + } + + // Add witness UTXO if available + if (input.witnessUtxo) { + inputData.witnessUtxo = { + amount: input.witnessUtxo.value, + script: input.witnessUtxo.script, + }; + } + + // Add sequence if set + if (input.sequence !== undefined) { + inputData.sequence = input.sequence; + } + try { + psbt.addInput(inputData); + } catch (error) { + throw new Error( + `Failed to add input to PSBT: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + /** + * Adds an output to the PSBT(used in BtcTransactionTemplate) + * + * @param {PsbtV2} psbt - The PsbtV2 object. + * @param {BtcTxOutputTemplate} output - The output template to be processed and added. + * @throws {Error} - Throws an error if output script creation fails. + */ + private addOutputToPsbt(psbt: PsbtV2, output: BtcTxOutputTemplate): void { + const script = createOutputScript(output.address, this._network); + if (!script) { + throw new Error( + `Unable to create output script for address: ${output.address}`, + ); + } + psbt.addOutput({ + script, + amount: parseInt(output.amountSats), + }); + } +} diff --git a/packages/caravan-fees/src/constants.ts b/packages/caravan-fees/src/constants.ts new file mode 100644 index 00000000..8b5ef924 --- /dev/null +++ b/packages/caravan-fees/src/constants.ts @@ -0,0 +1,74 @@ +/** + * The dust threshold in satoshis. Outputs below this value are considered "dust" + * and generally not relayed by the network. + * + * This value is derived from the Bitcoin Core implementation, where it's + * calculated as 3 * 182 satoshis for a standard P2PKH output, assuming + * a minimum relay fee of 1000 satoshis/kB. + * + * @see https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp + */ +export const DEFAULT_DUST_THRESHOLD_IN_SATS = "546"; // in satoshis + +/** + * The sequence number used to signal Replace-by-Fee (RBF) for a transaction input. + * + * As per BIP125, a transaction signals RBF if at least one of its inputs has + * a sequence number less than (0xffffffff - 1). This constant uses + * (0xffffffff - 2) to clearly signal RBF while leaving room for other + * potential sequence number use cases. + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + */ +export const RBF_SEQUENCE = 0xffffffff - 2; + +/** + * Constants for Bitcoin transaction fee safeguards + * + * These constants are used to prevent accidental or malicious creation of + * transactions with excessively high fees. They serve as upper bounds for + * fee rates and absolute fees in the context of RBF and CPFP operations. + */ + +/** + * Maximum allowable fee rate in satoshis per virtual byte (sat/vB). + * + * @constant + * @type {string} + * @default "1000" + * + * This constant represents an absurdly high fee rate of 1000 sat/vB. + * It's used as a safety check to prevent transactions with unreasonably + * high fee rates, which could result in significant financial loss. + * + * Context: + * - Normal fee rates typically range from 1-100 sat/vB, depending on network congestion. + * - A fee rate of 1000 sat/vB is considered extremely high and likely unintentional. + * - This safeguard helps protect users from inputting errors or potential fee-sniping attacks. + * + * Usage: + * - In fee estimation functions for RBF and CPFP. + * - As a validation check before broadcasting transactions. + */ +export const ABSURDLY_HIGH_FEE_RATE = "1000"; + +/** + * Maximum allowable absolute fee in satoshis. + * + * @constant + * @type {string} + * @default "2500000" + * + * This constant represents an absurdly high absolute fee of 2,500,000 satoshis (0.025 BTC). + * It serves as a cap on the total transaction fee, regardless of the transaction's size. + * + * Context: + * - 1,000,000 satoshis = 0.025 BTC, which is a significant amount for a transaction fee. + * - This limit helps prevent accidental loss of large amounts of Bitcoin due to fee miscalculations. + * - It's particularly important for larger transactions where a high fee rate could lead to substantial fees. + * + * Usage: + * - In fee calculation functions for both regular transactions and fee-bumping operations (RBF, CPFP). + * - As a final safety check before transaction signing and broadcasting. + */ +export const ABSURDLY_HIGH_ABS_FEE = "2500000"; diff --git a/packages/caravan-fees/src/cpfp.ts b/packages/caravan-fees/src/cpfp.ts new file mode 100644 index 00000000..c5b80052 --- /dev/null +++ b/packages/caravan-fees/src/cpfp.ts @@ -0,0 +1,306 @@ +import { TransactionAnalyzer } from "./transactionAnalyzer"; +import { BtcTransactionTemplate } from "./btcTransactionTemplate"; +import { + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "./btcTransactionComponents"; +import { FeeBumpStrategy, CPFPOptions, UTXO } from "./types"; +import { createOutputScript } from "./utils"; +import BigNumber from "bignumber.js"; + +/** + * Creates a Child-Pays-for-Parent (CPFP) transaction to accelerate the confirmation + * of an unconfirmed parent transaction. + * + * This function implements a simplified version of the CPFP strategy used in Bitcoin Core. + * It creates a new transaction (child) that spends an output from the original unconfirmed + * transaction (parent), including a higher fee to incentivize miners to confirm both + * transactions together. + * + * The CPFP calculation process: + * 1. Analyze the parent transaction to determine its fee, size, and available outputs. + * 2. Create a child transaction template with the target fee rate for the combined package. + * 3. Add the spendable output from the parent as an input to the child transaction. + * 4. Iteratively add additional inputs to the child transaction until the combined + * fee rate of the parent and child meets or exceeds the target fee rate. + * 5. Calculate and set the change output amount for the child transaction. + * 6. Validate the child transaction and ensure the combined fee rate is sufficient. + * + * The combined fee rate is calculated as: + * (parentFee + childFee) / (parentVsize + childVsize) + * + * @param {CPFPOptions} options - Configuration options for creating the CPFP transaction. + * @returns {string} The base64-encoded PSBT of the CPFP (child) transaction. + * @throws {Error} If CPFP is not possible, if the transaction creation fails, or if + * the combined fee rate doesn't meet the target (in strict mode). + * + * @example + * const cpfpTx = createCPFPTransaction({ + * originalTx: originalTxHex, + * availableInputs: availableUTXOs, + * spendableOutputIndex: 1, + * changeAddress: 'bc1q...', + * network: Network.MAINNET, + * dustThreshold: '546', + * scriptType: 'P2WSH', + * targetFeeRate: '15', + * absoluteFee: '1000', + * requiredSigners: 2, + * totalSigners: 3, + * strict: true + * }); + * + * @see https://bitcoinops.org/en/topics/cpfp/ Bitcoin Optech on Child Pays for Parent + * @see https://github.com/bitcoin/bitcoin/pull/7600 Bitcoin Core CPFP implementation + */ +export const createCPFPTransaction = (options: CPFPOptions): string => { + const { + strict = false, + originalTx, + network, + targetFeeRate, + absoluteFee, + availableInputs, + requiredSigners, + totalSigners, + scriptType, + dustThreshold, + spendableOutputIndex, + changeAddress, + } = options; + + // CPFP Calculation Process: + // Step 1: Analyze the original transaction + const txAnalyzer = new TransactionAnalyzer({ + txHex: originalTx, + network, + changeOutputIndex: spendableOutputIndex, + targetFeeRate, // We need this param. It is required for the analyzer to make decisions and so cannot be 0. + absoluteFee, + availableUtxos: availableInputs, + requiredSigners, + totalSigners, + addressType: scriptType, + }); + + const analysis = txAnalyzer.analyze(); + + // Step 2: Verify CPFP suitability + if (!analysis.canCPFP) { + throw new Error( + "CPFP is not possible for this transaction. Ensure the specified output is available for spending.", + ); + } + + if (analysis.recommendedStrategy !== FeeBumpStrategy.CPFP) { + if (strict) { + throw new Error( + `CPFP is not the recommended strategy for this transaction. The recommended strategy is: ${analysis.recommendedStrategy}`, + ); + } + console.warn( + "CPFP is not the recommended strategy for this transaction. Consider using the recommended strategy: " + + analysis.recommendedStrategy, + ); + } + + // Step 3: Create a new transaction template for the child transaction + const childTxTemplate = new BtcTransactionTemplate({ + inputs: [], + outputs: [], + targetFeeRate: Number(txAnalyzer.cpfpFeeRate), // Use CPFP-specific fee rate from analyzer + dustThreshold, + network, + scriptType, + requiredSigners, + totalSigners, + }); + + // Step 4: Add the spendable output from the parent transaction as an input + const parentOutput = txAnalyzer.outputs[spendableOutputIndex]; + if (!parentOutput) { + throw new Error( + `Spendable output at index ${spendableOutputIndex} not found in the parent transaction.`, + ); + } + + // Create a UTXO from the parent transaction's output + const parentOutputUTXO: UTXO = { + txid: analysis.txid, + vout: spendableOutputIndex, + value: parentOutput.amountSats, + witnessUtxo: { + script: createOutputScript(parentOutput.address, network), + value: parseInt(parentOutput.amountSats), + }, + + prevTxHex: originalTx, + }; + + childTxTemplate.addInput(BtcTxInputTemplate.fromUTXO(parentOutputUTXO)); + + // Step 5: Add a change output (at least 1 output required for a valid transaction) + childTxTemplate.addOutput( + new BtcTxOutputTemplate({ + address: changeAddress, + amountSats: "0", // Initial amount, will be adjusted later + }), + ); + + /** + * Step 6: Add additional inputs to cover the CPFP fee requirements + * + * This step implements a simplified version of the "Child-pays-for-parent" (CPFP) + * transaction selection logic, inspired by Bitcoin Core's implementation + * (https://github.com/bitcoin/bitcoin/pull/7600). + * + * In Bitcoin Core, transactions are sorted in the mempool using a modified + * feerate that considers ancestors. During block creation, transactions are + * considered in this order. As transactions are selected, a separate map tracks + * the new feerate-with-ancestors for in-mempool descendants. + * + * Our simplified approach: + * 1. We start with the parent transaction's fee and vsize. + * 2. We iteratively add inputs to the child transaction. + * 3. After each input addition, we recalculate the combined fee rate of the + * parent and child transactions. + * 4. We continue adding inputs until the combined fee rate meets or exceeds + * the target fee rate. + * + * This method ensures that the child transaction provides enough fee to incentivize + * miners to include both the parent and child transactions in a block, effectively + * "bumping" the fee of the parent transaction. + * + * Note: While this approach is simpler than Bitcoin Core's full implementation, + * it achieves the core goal of CPFP: ensuring the combined package (parent + child) + * is attractive for miners to include in a block. + */ + + for (const utxo of availableInputs) { + if ( + isCPFPFeeSatisfied(txAnalyzer, childTxTemplate) && + childTxTemplate.needsChange + ) { + break; // Stop adding inputs once CPFP fee requirements and change requirements (to ensure we don't end up with dust Output) are met + } + // Skip if this UTXO is already added + if ( + childTxTemplate.inputs.some( + (input) => input.txid === utxo.txid && input.vout === utxo.vout, + ) + ) { + continue; + } + childTxTemplate.addInput(BtcTxInputTemplate.fromUTXO(utxo)); + } + + // needsChange returns true if there is enough left over greater than dust. + // Since we already added a zero amount output for change, it takes into account the full size of the tx. + if (!childTxTemplate.needsChange && strict) { + throw new Error( + "Not enough inputs to create a non-dusty change output in the child transaction", + ); + } + + // Step 7: Calculate and set the change output amount + childTxTemplate.adjustChangeOutput(); + + // Step 8: Validate the child transaction + if (!childTxTemplate.validate()) { + throw new Error( + "Failed to create a valid CPFP transaction. Ensure all inputs and outputs are valid and fee requirements are met.", + ); + } + + // Step 9: Validate the combined (parent + child) fee rate + validateCPFPPackage(txAnalyzer, childTxTemplate, strict); + + // Step 10: Convert to PSBT and return as base64 + return childTxTemplate.toPsbt(true); +}; + +/** + * Determines if the combined fee rate of a parent and child transaction meets or exceeds + * the target fee rate for a Child-Pays-for-Parent (CPFP) transaction. + * + * This function calculates the combined fee rate of a parent transaction and its child + * (CPFP) transaction, then compares it to the target fee rate. It's used to ensure that + * the CPFP transaction provides sufficient fee incentive for miners to include both + * transactions in a block. + * + * The combined fee rate is calculated as: + * (parentFee + childFee) / (parentVsize + childVsize) + * + * @param {TransactionAnalyzer} txAnalyzer - The analyzer containing parent transaction information and CPFP fee rate. + * @param {BtcTransactionTemplate} childTxTemplate - The child transaction template. + * @returns {boolean} True if the combined fee rate meets or exceeds the target fee rate, false otherwise. + * + * @example + * const txAnalyzer = new TransactionAnalyzer({...}); + * const childTxTemplate = new BtcTransactionTemplate({...}); + * const isSatisfied = isCPFPFeeSatisfied(txAnalyzer, childTxTemplate); + * console.log(isSatisfied); // true or false + * + * @throws {Error} If any of the input parameters are negative. + */ +export function isCPFPFeeSatisfied( + txAnalyzer: TransactionAnalyzer, + childTxTemplate: BtcTransactionTemplate, +): boolean { + // Input validation + if (!txAnalyzer || !childTxTemplate) { + throw new Error("Invalid analyzer or child transaction template."); + } + const parentFee = new BigNumber(txAnalyzer.fee); + const parentVsize = txAnalyzer.vsize; + const childFee = new BigNumber(childTxTemplate.currentFee); + const childVsize = childTxTemplate.estimatedVsize; + const targetFeeRate = parseFloat(txAnalyzer.cpfpFeeRate); + + const combinedFee = parentFee.plus(childFee); + const combinedVsize = parentVsize + childVsize; + const combinedFeeRate = combinedFee.dividedBy(combinedVsize); + + return combinedFeeRate.gte(targetFeeRate); +} + +/** + * Validates that the combined fee rate of a parent and child transaction + * meets or exceeds the target fee rate for a Child-Pays-for-Parent (CPFP) transaction. + * + * This function calculates the combined fee rate of the parent transaction (from the analyzer) + * and its child transaction, then compares it to the target CPFP fee rate. It ensures + * that the CPFP transaction provides sufficient fee incentive for miners to include both + * transactions in a block. + * + * @param {TransactionAnalyzer} txAnalyzer - The analyzer containing parent transaction information and CPFP fee rate. + * @param {BtcTransactionTemplate} childTxTemplate - The child transaction template. + * @param {boolean} strict - If true, throws an error when the fee rate is not satisfied. If false, only logs a warning. + * @returns {void} + * + * @throws {Error} If the combined fee rate is below the target fee rate in strict mode. + */ +export function validateCPFPPackage( + txAnalyzer: TransactionAnalyzer, + childTxTemplate: BtcTransactionTemplate, + strict: boolean, +): void { + const parentFee = new BigNumber(txAnalyzer.fee); + const parentSize = new BigNumber(txAnalyzer.vsize); + const childFee = new BigNumber(childTxTemplate.currentFee); + const childSize = new BigNumber(childTxTemplate.estimatedVsize); + + const combinedFeeRate = parentFee + .plus(childFee) + .dividedBy(parentSize.plus(childSize)); + + if (combinedFeeRate.isLessThan(txAnalyzer.targetFeeRate)) { + const message = `Combined fee rate (${combinedFeeRate.toFixed(2)} sat/vB) is below the target CPFP fee rate (${txAnalyzer.targetFeeRate.toFixed(2)} sat/vB). ${strict ? "Increase inputs or reduce fee rate." : "Transaction may confirm slower than expected."}`; + + if (strict) { + throw new Error(message); + } else { + console.warn(message); + } + } +} diff --git a/packages/caravan-fees/src/index.ts b/packages/caravan-fees/src/index.ts new file mode 100644 index 00000000..ebc728c6 --- /dev/null +++ b/packages/caravan-fees/src/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./utils"; +export * from "./transactionAnalyzer"; +export * from "./rbf"; +export * from "./cpfp"; +export * from "./constants"; +export * from "./btcTransactionTemplate"; +export * from "./btcTransactionComponents"; diff --git a/packages/caravan-fees/src/rbf.ts b/packages/caravan-fees/src/rbf.ts new file mode 100644 index 00000000..4d337c99 --- /dev/null +++ b/packages/caravan-fees/src/rbf.ts @@ -0,0 +1,465 @@ +import { TransactionAnalyzer } from "./transactionAnalyzer"; +import { BtcTransactionTemplate } from "./btcTransactionTemplate"; +import { + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "./btcTransactionComponents"; +import { + FeeBumpStrategy, + CancelRbfOptions, + AcceleratedRbfOptions, + UTXO, +} from "./types"; +import BigNumber from "bignumber.js"; + +/** + * RBF (Replace-By-Fee) Transaction Creation + * + * SECURITY NOTE: By default, these functions reuse all inputs from the original + * transaction in the replacement transaction for acceleration. This is to prevent the "replacement + * cycle attack" where multiple versions of a transaction could potentially be + * confirmed if they don't conflict with each other. + * + * If you choose to set `reuseAllInputs` to false, be aware of the risks outlined here: + * https://bitcoinops.org/en/newsletters/2023/10/25/#fn:rbf-warning + * + * Only set `reuseAllInputs` to false if you fully understand these risks and have + * implemented appropriate safeguards in your wallet software. + */ + +/** + * Validates RBF possibility and suitability. + * @param analysis - The result of transaction analysis. + * @param targetFeeRate - The target fee rate for the new transaction. + * @param fullRBF - Whether to allow full RBF even if not signaled. + * @param strict - Whether to throw errors for non-recommended strategies. + */ +const validateRbfPossibility = ( + txAnalyzer: TransactionAnalyzer, + fullRBF: boolean, + strict: boolean, +): void => { + if (!txAnalyzer.canRBF) { + if (fullRBF) { + console.warn( + "Transaction does not signal RBF. Proceeding with full RBF, which may not be accepted by all nodes.", + ); + } else { + throw new Error( + "RBF is not possible for this transaction. Ensure at least one input has a sequence number < 0xfffffffe.", + ); + } + } + + if (txAnalyzer.recommendedStrategy !== FeeBumpStrategy.RBF) { + if (strict) { + throw new Error( + `RBF is not the recommended strategy for this transaction. The recommended strategy is: ${txAnalyzer.recommendedStrategy}`, + ); + } + console.warn( + `RBF is not the recommended strategy for this transaction. Consider using the recommended strategy: ${txAnalyzer.recommendedStrategy}`, + ); + } + + if ( + new BigNumber(txAnalyzer.targetFeeRate).isLessThanOrEqualTo( + txAnalyzer.feeRate, + ) + ) { + throw new Error( + `Target fee rate (${txAnalyzer.targetFeeRate} sat/vB) must be higher than the original transaction's fee rate (${txAnalyzer.feeRate} sat/vB).`, + ); + } +}; + +/** + * Adds inputs from the original transaction to the new transaction template. + * @param txAnalyzer - The transaction analyzer instance. + * @param newTxTemplate - The transaction template to add inputs to. + * @param availableInputs - Available UTXOs. + * @param reuseAllInputs - Whether we add all originals inputs or not. + * @param isAccelerated - Whether this is for an accelerated RBF transaction. + * @returns A set of added input identifiers. + */ +const addOriginalInputs = ( + txAnalyzer: TransactionAnalyzer, + newTxTemplate: BtcTransactionTemplate, + availableInputs: UTXO[], + reuseAllInputs: boolean, + isAccelerated: boolean, +): Set => { + const originalInputTemplates = txAnalyzer.getInputTemplates(); + const addedInputs = new Set(); + + if (reuseAllInputs) { + // Add all original inputs + originalInputTemplates.forEach((template) => { + const input = availableInputs.find( + (utxo) => utxo.txid === template.txid && utxo.vout === template.vout, + ); + if (input) { + newTxTemplate.addInput(BtcTxInputTemplate.fromUTXO(input)); + addedInputs.add(`${input.txid}:${input.vout}`); + } + }); + } else if (isAccelerated) { + console.warn( + "Not reusing all inputs can lead to a replacement cycle attack. " + + "See: https://bitcoinops.org/en/newsletters/2023/10/25/#fn:rbf-warning", + ); + + // Add at least one input from the original transaction + + // TO DO (MRIGESH) + // Add coin selection algorithm to add best suited input to start with ... + const originalInput = availableInputs.find((utxo) => + originalInputTemplates.some( + (template) => + template.txid === utxo.txid && template.vout === template.vout, + ), + ); + if (!originalInput) { + throw new Error( + "None of the original transaction inputs found in available UTXOs.", + ); + } + newTxTemplate.addInput(BtcTxInputTemplate.fromUTXO(originalInput)); + addedInputs.add(`${originalInput.txid}:${originalInput.vout}`); + } + + return addedInputs; +}; + +/** + * Adds additional inputs to meet fee requirements. + * @param newTxTemplate - The transaction template to add inputs to. + * @param availableInputs - Available UTXOs. + * @param addedInputs - Set of already added input identifiers. + * @param txAnalyzer - The transaction analyzer instance. + */ +const addAdditionalInputs = ( + newTxTemplate: BtcTransactionTemplate, + availableInputs: UTXO[], + addedInputs: Set, + txAnalyzer: TransactionAnalyzer, +): void => { + // We continue adding inputs until both the fee rate is satisfied and + // the absolute fee meets the minimum RBF requirement + for (const utxo of availableInputs) { + if ( + newTxTemplate.feeRateSatisfied && + new BigNumber(newTxTemplate.currentFee).gte(txAnalyzer.minimumRBFFee) && + (newTxTemplate.needsChange || newTxTemplate.outputs.length > 0) + ) { + /* + Stop adding inputs when the following conditions are met: + 1. The fee rate satisfies the target rate (which must be gte original tx fee rate) + 2. The current fee is gte the minimum required absolute RBF fee amount + 3. The tx already has an output or will have enough excess funds to have a change output added + */ + break; + } + // Skip if this UTXO is already added + const utxoKey = `${utxo.txid}:${utxo.vout}`; + if (addedInputs.has(utxoKey)) { + continue; + } + newTxTemplate.addInput(BtcTxInputTemplate.fromUTXO(utxo)); + addedInputs.add(utxoKey); + } +}; + +/** + * Ensures the new transaction meets RBF requirements. + * @param newTxTemplate - The new transaction template. + * @param txAnalyzer - The transaction analyzer instance. + */ +const validateRbfRequirements = ( + newTxTemplate: BtcTransactionTemplate, + txAnalyzer: TransactionAnalyzer, +): void => { + const currentFee = new BigNumber(newTxTemplate.currentFee); + const minRequiredFee = new BigNumber(txAnalyzer.minimumRBFFee); + const targetFee = new BigNumber(newTxTemplate.targetFeesToPay); + + // Check 1: New fee must be at least the minimum RBF fee + if (currentFee.isLessThan(minRequiredFee)) { + throw new Error( + `New transaction fee (${currentFee.toString()} sats) must be at least the minimum RBF fee (${minRequiredFee.toString()} sats).`, + ); + } + + // Check 2: Target fees must be paid + if (currentFee.isLessThan(targetFee)) { + throw new Error( + `New transaction fee (${currentFee.toString()} sats) is less than the target fee (${targetFee.toString()} sats).`, + ); + } +}; + +/** + * Creates a cancel Replace-By-Fee (RBF) transaction. + * + * This function creates a new transaction that double-spends at least one input + * from the original transaction, effectively cancelling it by sending all funds + * to a new address minus the required fees. + * + * @param {CancelRbfOptions} options - Configuration options for creating the cancel RBF transaction. + * @returns {string} The base64-encoded PSBT of the cancel RBF transaction. + * @throws {Error} If RBF is not possible or if the transaction creation fails. + * + * @example + * const cancelTx = createCancelRbfTransaction({ + * originalTx: originalTxHex, + * availableInputs: availableUTXOs, + * cancelAddress: 'bc1q...', // cancel address + * network: Network.MAINNET, + * scriptType: 'P2WSH', + * requiredSigners: 2, + * totalSigners: 3, + * targetFeeRate: '10', + * absoluteFee: '1000', + * fullRBF: false, + * strict: true + * reuseAllInputs: false, // Default behavior, more efficient fee wise + * }); + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki BIP 125: Opt-in Full Replace-by-Fee Signaling + * @see https://developer.bitcoin.org/devguide/transactions.html#locktime-and-sequence-number Bitcoin Core's guide on locktime and sequence numbers + * @see https://bitcoinops.org/en/newsletters/2023/10/25/#fn:rbf-warning Bitcoin Optech on replacement cycle attacks + */ +export const createCancelRbfTransaction = ( + options: CancelRbfOptions, +): string => { + const { + fullRBF = false, + strict = false, + originalTx, + network, + targetFeeRate, + absoluteFee, + availableInputs, + requiredSigners, + totalSigners, + scriptType, + cancelAddress, + reuseAllInputs = false, + } = options; + // Step 1: Analyze the original transaction + const txAnalyzer = new TransactionAnalyzer({ + txHex: originalTx, + network, + targetFeeRate, + absoluteFee, + availableUtxos: availableInputs, + requiredSigners, + totalSigners, + addressType: scriptType, + }); + + txAnalyzer.assumeRBF = fullRBF; + + // Validate that the target fee rate is higher than the original transaction's fee rate + if (new BigNumber(targetFeeRate).isLessThanOrEqualTo(txAnalyzer.feeRate)) { + throw new Error( + `Target fee rate (${targetFeeRate} sat/vB) must be higher than the original transaction's fee rate (${txAnalyzer.feeRate} sat/vB).`, + ); + } + + // Step 2: Verify RBF possibility and suitability + validateRbfPossibility(txAnalyzer, fullRBF, strict); + + // Step 3: Create a new transaction template + const newTxTemplate = new BtcTransactionTemplate({ + inputs: [], + outputs: [], + targetFeeRate: options.targetFeeRate, + dustThreshold: options.dustThreshold, + network: options.network, + scriptType: options.scriptType, + requiredSigners: options.requiredSigners, + totalSigners: options.totalSigners, + }); + + // Step 4: Add inputs from the original transaction + const addedInputs = addOriginalInputs( + txAnalyzer, + newTxTemplate, + availableInputs, + reuseAllInputs, + false, // Not an accelerated transaction + ); + + // Step 5: Add the cancellation output + newTxTemplate.addOutput( + new BtcTxOutputTemplate({ + address: cancelAddress, + amountSats: "0", // Temporary amount, will be adjusted later + locked: false, + }), + ); + + // Step 6: Add more inputs if necessary to meet fee requirements + addAdditionalInputs(newTxTemplate, availableInputs, addedInputs, txAnalyzer); + + // Step 7: Calculate and set the cancellation output amount + // Note : We cannot use `newTxTemplate.adjustChangeOutput()` here as we need to set fees first (as per RBF rules) and then set change amount + + const totalInputAmount = new BigNumber(newTxTemplate.totalInputAmount); + const totalOutputAmount = new BigNumber(newTxTemplate.totalOutputAmount); + const fee = BigNumber.max( + newTxTemplate.targetFeesToPay, + txAnalyzer.minimumRBFFee, + ); + const cancelOutputAmount = totalInputAmount + .minus(totalOutputAmount) + .minus(fee); + newTxTemplate.outputs[0].setAmount(cancelOutputAmount.toString()); + + // Step 8: Ensure the new transaction meets RBF requirements + validateRbfRequirements(newTxTemplate, txAnalyzer); + + // Step 9: Convert to PSBT and return as base64 + return newTxTemplate.toPsbt(true); +}; + +/** + * Creates an accelerated Replace-By-Fee (RBF) transaction. + * + * This function creates a new transaction that replaces the original one + * with a higher fee, aiming to accelerate its confirmation. It preserves + * the original outputs except for the change output, which is adjusted to + * accommodate the higher fee. + * + * @param {AcceleratedRbfOptions} options - Configuration options for creating the accelerated RBF transaction. + * @returns {string} The base64-encoded PSBT of the accelerated RBF transaction. + * @throws {Error} If RBF is not possible or if the transaction creation fails. + * + * @example + * const acceleratedTx = createAcceleratedRbfTransaction({ + * originalTx: originalTxHex, + * availableInputs: availableUTXOs, + * network: Network.MAINNET, + * scriptType: 'P2WSH', + * requiredSigners: 2, + * totalSigners: 3, + * targetFeeRate: '20', + * absoluteFee: '1000', + * changeIndex: 1, + * changeAddress: 'bc1q...', + * fullRBF: false, + * strict: true + * reuseAllInputs: true, // Default behavior, safer option + * }); + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki BIP 125: Opt-in Full Replace-by-Fee Signaling + * @see https://developer.bitcoin.org/devguide/transactions.html#locktime-and-sequence-number Bitcoin Core's guide on locktime and sequence numbers + * @see https://bitcoinops.org/en/newsletters/2023/10/25/#fn:rbf-warning Bitcoin Optech on replacement cycle attacks + */ +export const createAcceleratedRbfTransaction = ( + options: AcceleratedRbfOptions, +): string => { + const { + fullRBF = false, + strict = false, + originalTx, + network, + targetFeeRate, + absoluteFee, + availableInputs, + requiredSigners, + totalSigners, + scriptType, + changeIndex, + changeAddress, + reuseAllInputs = true, + } = options; + + // Step 1: Validate change output options + if (changeIndex !== undefined && changeAddress !== undefined) { + throw new Error( + "Provide either changeIndex or changeAddress, not both. This ensures unambiguous handling of the change output.", + ); + } + if (changeIndex === undefined && changeAddress === undefined) { + throw new Error( + "Either changeIndex or changeAddress must be provided for handling the change output.", + ); + } + + // Step 2: Analyze the original transaction + const txAnalyzer = new TransactionAnalyzer({ + txHex: originalTx, + network, + targetFeeRate, + absoluteFee, + availableUtxos: availableInputs, + requiredSigners, + totalSigners, + addressType: scriptType, + changeOutputIndex: changeIndex, + }); + + txAnalyzer.assumeRBF = fullRBF; + + // Step 3: Validate RBF possibility and suitability + validateRbfPossibility(txAnalyzer, fullRBF, strict); + + // Step 4: Create a new transaction template + const newTxTemplate = new BtcTransactionTemplate({ + inputs: [], + outputs: [], + targetFeeRate: BigNumber.max( + options.targetFeeRate, + new BigNumber(txAnalyzer.minimumRBFFee).dividedBy(txAnalyzer.vsize), + ).toNumber(), + dustThreshold: options.dustThreshold, + network: options.network, + scriptType: options.scriptType, + requiredSigners: options.requiredSigners, + totalSigners: options.totalSigners, + }); + + // Step 5: Add inputs from the original transaction + const addedInputs = addOriginalInputs( + txAnalyzer, + newTxTemplate, + availableInputs, + reuseAllInputs, + true, // This is an accelerated transaction + ); + + // Step 6: Add all non-change outputs from the original transaction + txAnalyzer.getOutputTemplates().forEach((outputTemplate) => { + if (!outputTemplate.isMalleable) { + newTxTemplate.addOutput( + new BtcTxOutputTemplate({ + address: outputTemplate.address, + amountSats: outputTemplate.amountSats, + locked: true, + }), + ); + } + }); + + // Step 7: Add additional inputs if necessary + addAdditionalInputs(newTxTemplate, availableInputs, addedInputs, txAnalyzer); + + // Step 8: Add or adjust change output + if (newTxTemplate.needsChangeOutput) { + const changeOutput = new BtcTxOutputTemplate({ + address: changeAddress || txAnalyzer.outputs[changeIndex!].address, + amountSats: "0", // adjusted with adjustChangeOutput call below + locked: false, + }); + newTxTemplate.addOutput(changeOutput); + newTxTemplate.adjustChangeOutput(); + } + + // Step 9: Validate RBF requirements + validateRbfRequirements(newTxTemplate, txAnalyzer); + + // Step 10: Convert to PSBT and return as base64 + return newTxTemplate.toPsbt(true); +}; diff --git a/packages/caravan-fees/src/tests/btcTransactionComponents.fixtures.ts b/packages/caravan-fees/src/tests/btcTransactionComponents.fixtures.ts new file mode 100644 index 00000000..f881973e --- /dev/null +++ b/packages/caravan-fees/src/tests/btcTransactionComponents.fixtures.ts @@ -0,0 +1,142 @@ +import { UTXO } from "../types"; + +export const validInputTemplateFixtures = [ + { + case: "Valid input with positive amount", + data: { + txid: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + vout: 0, + amountSats: "100000", + }, + expected: { + txid: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + vout: 0, + amountSats: "100000", + amountBTC: "0.001", + isValid: true, + }, + }, + { + case: "Valid input with RBF signaling", + data: { + txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + vout: 1, + amountSats: "200000", + sequence: 0xfffffffd, + }, + expected: { + txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + vout: 1, + amountSats: "200000", + amountBTC: "0.002", + isValid: true, + isRBFEnabled: undefined, + }, + }, +]; + +export const invalidInputTemplateFixtures = [ + { + case: "Invalid input with negative amount", + data: { + txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + vout: 1, + amountSats: "-100000", + }, + expected: { + txid: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + vout: 1, + amountSats: "-100000", + amountBTC: "-0.001", + isValid: false, + }, + }, + { + case: "Invalid input with invalid sequence number", + data: { + txid: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + vout: 0, + amountSats: "100000", + sequence: 0x100000000, // Greater than 32-bit unsigned integer + }, + expected: { + error: "Invalid sequence number", + }, + }, +]; + +export const validOutputTemplateFixtures = [ + { + case: "Valid output with positive amount", + data: { + address: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + amountSats: "50000", + }, + expected: { + address: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + amountSats: "50000", + amountBTC: "0.0005", + isMalleable: true, + isValid: true, + }, + }, + { + case: "Valid locked output", + data: { + address: "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + amountSats: "100000", + locked: true, + }, + expected: { + address: "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + amountSats: "100000", + amountBTC: "0.001", + isMalleable: false, + isValid: true, + }, + }, +]; + +export const invalidOutputTemplateFixtures = [ + { + case: "Invalid output with zero amount", + data: { + address: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + amountSats: "0", + }, + expected: { + address: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + amountSats: "0", + amountBTC: "0", + isMalleable: true, + isValid: false, + }, + }, + { + case: "Invalid locked output with zero amount", + data: { + address: "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + amountSats: "0", + locked: true, + }, + expected: { + error: "Locked outputs must have an amount specified.", + }, + }, +]; + +export const utxoFixture: UTXO = { + // https://mempool.space/tx/cb79eea71fc4818d28c8ef6a9cad382ed9355304c23da1ae4759aeec0c66cbd4 + txid: "cb79eea71fc4818d28c8ef6a9cad382ed9355304c23da1ae4759aeec0c66cbd4", + vout: 2, + value: "55526782", + prevTxHex: + "010000000001011bbfa7552a378e64e4b91a662f5ba6d77a5554a8bca4ffbd98a5e15e51fc4adc1100000000fdffffff035e1d6c00000000001600143e6cf1cff5518b17cae550d7d9c987c6b727501d36648400000000001600147253a62e6e83e9f7483278cf971119987a466f477e454f0300000000220020806e446bb267eb1eaf1eaede0f88b5c7fe4b237783c1c580437e8efc89bfc9630400483045022100964708f47b59b19f5356e32bed0463299d18b71c29e77f7a0e5a28ad5b55d9bf02201696b00ec63dd6961e75ac18745631d07307163d514110c167a29dc4994eb78d01483045022100d94e052dd94c56acfb598484d9a7e4c8686f1933f5442216598e4c7a08d281a202205bf78ae3dbd302572645416b406c1a399b7998e28dc1cb5e6ff4b9264b8bab1b0169522103b66728c8bd8175973e4004860b9cc57fa882be226e5693b498e421948eb2471f2103b802448e6ac06d47a4767f16898bae89b8ac52318b7bb9d8d0113c6f5f70b8c12103295030f31a4f98b3bd6d8cba6cab11b3a188611098115d32c9115687aa8799ae53aee1210d00", + witnessUtxo: { + script: Buffer.from( + "0020806e446bb267eb1eaf1eaede0f88b5c7fe4b237783c1c580437e8efc89bfc963", + "hex", + ), + value: 55526782, + }, +}; diff --git a/packages/caravan-fees/src/tests/btcTransactionComponents.test.ts b/packages/caravan-fees/src/tests/btcTransactionComponents.test.ts new file mode 100644 index 00000000..37f0f941 --- /dev/null +++ b/packages/caravan-fees/src/tests/btcTransactionComponents.test.ts @@ -0,0 +1,238 @@ +import { + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "../btcTransactionComponents"; +import { + validInputTemplateFixtures, + invalidInputTemplateFixtures, + validOutputTemplateFixtures, + invalidOutputTemplateFixtures, + utxoFixture, +} from "./btcTransactionComponents.fixtures"; + +describe("BtcTxInputTemplate", () => { + describe("Valid Inputs", () => { + test.each(validInputTemplateFixtures)("$case", ({ data, expected }) => { + const input = new BtcTxInputTemplate(data); + expect(input.txid).toBe(expected.txid); + expect(input.vout).toBe(expected.vout); + expect(input.amountSats).toBe(expected.amountSats); + expect(input.amountBTC).toBe(expected.amountBTC); + expect(input.isValid()).toBe(expected.isValid); + if (expected.isRBFEnabled !== undefined) { + expect(input.isRBFEnabled()).toBe(expected.isRBFEnabled); + } + }); + }); + + describe("Invalid Inputs", () => { + test.each(invalidInputTemplateFixtures)("$case", ({ data, expected }) => { + if (expected.error) { + const temp = new BtcTxInputTemplate(data); + expect(() => { + temp.setSequence(data.sequence!); + }).toThrow(expected.error); + } else { + const input = new BtcTxInputTemplate(data); + expect(input.isValid()).toBe(expected.isValid); + } + }); + }); + + describe("fromUTXO", () => { + it("should create an input template from a UTXO", () => { + const input = BtcTxInputTemplate.fromUTXO(utxoFixture); + expect(input.txid).toBe(utxoFixture.txid); + expect(input.vout).toBe(utxoFixture.vout); + expect(input.amountSats).toBe(utxoFixture.value); + expect(input.nonWitnessUtxo).toEqual( + Buffer.from(utxoFixture.prevTxHex!, "hex"), + ); + expect(input.witnessUtxo).toEqual(utxoFixture.witnessUtxo); + }); + }); + + describe("setSequence", () => { + it("should set sequence number", () => { + const input = new BtcTxInputTemplate(validInputTemplateFixtures[0].data); + input.setSequence(0xfffffffd); + expect(input.sequence).toBe(0xfffffffd); + expect(input.isRBFEnabled()).toBe(true); + }); + + it("should throw error when setting invalid sequence number", () => { + const input = new BtcTxInputTemplate(validInputTemplateFixtures[0].data); + expect(() => { + input.setSequence(0x100000000); // Greater than 32-bit unsigned integer + }).toThrow("Invalid sequence number"); + }); + }); + + describe("setWitnessUtxo", () => { + it("should set witness UTXO", () => { + const input = new BtcTxInputTemplate(validInputTemplateFixtures[0].data); + const witnessUtxo = { + script: Buffer.from("dummy_script"), + value: 123456, + }; + input.setWitnessUtxo(witnessUtxo); + expect(input.witnessUtxo).toEqual(witnessUtxo); + }); + }); + + describe("setNonWitnessUtxo", () => { + it("should set non-witness UTXO", () => { + const input = new BtcTxInputTemplate(validInputTemplateFixtures[0].data); + const nonWitnessUtxo = Buffer.from(utxoFixture.prevTxHex!, "hex"); + input.setNonWitnessUtxo(nonWitnessUtxo); + expect(input.nonWitnessUtxo).toEqual(nonWitnessUtxo); + }); + + it("should throw error when setting invalid non-witness UTXO", () => { + const input = new BtcTxInputTemplate(validInputTemplateFixtures[0].data); + const invalidNonWitnessUtxo = Buffer.from("invalid_utxo"); + expect(() => { + input.setNonWitnessUtxo(invalidNonWitnessUtxo); + }).toThrow("Invalid non-witness UTXO"); + }); + }); + + describe("hasRequiredFieldsforPSBT", () => { + it("should return true when all required fields are present", () => { + const input = BtcTxInputTemplate.fromUTXO(utxoFixture); + expect(input.hasRequiredFieldsforPSBT()).toBe(true); + }); + + it("should return false when required fields are missing", () => { + const input = new BtcTxInputTemplate(validInputTemplateFixtures[0].data); + expect(input.hasRequiredFieldsforPSBT()).toBe(false); + }); + }); + + describe("toUTXO", () => { + it("should convert input template to UTXO", () => { + const input = BtcTxInputTemplate.fromUTXO(utxoFixture); + const convertedUTXO = input.toUTXO(); + expect(convertedUTXO).toEqual(utxoFixture); + }); + }); +}); + +describe("BtcTxOutputTemplate", () => { + describe("Valid Outputs", () => { + test.each(validOutputTemplateFixtures)("$case", ({ data, expected }) => { + const output = new BtcTxOutputTemplate(data); + expect(output.address).toBe(expected.address); + expect(output.amountSats).toBe(expected.amountSats); + expect(output.amountBTC).toBe(expected.amountBTC); + expect(output.isMalleable).toBe(expected.isMalleable); + expect(output.isValid()).toBe(expected.isValid); + }); + }); + + describe("Invalid Outputs", () => { + test.each(invalidOutputTemplateFixtures)("$case", ({ data, expected }) => { + if (expected.error) { + expect(() => new BtcTxOutputTemplate(data)).toThrow(expected.error); + } else { + const output = new BtcTxOutputTemplate(data); + expect(() => { + output.lock(); + }).toThrow(); + } + }); + }); + + describe("Amount Manipulation", () => { + let output: BtcTxOutputTemplate; + + beforeEach(() => { + output = new BtcTxOutputTemplate(validOutputTemplateFixtures[0].data); + }); + + it("should set amount", () => { + output.setAmount("200000"); + expect(output.amountSats).toBe("200000"); + }); + + it("should add amount", () => { + output.addAmount("50000"); + expect(output.amountSats).toBe("100000"); + }); + + it("should subtract amount", () => { + output.subtractAmount("25000"); + expect(output.amountSats).toBe("25000"); + }); + + it("should throw error when subtracting more than available", () => { + expect(() => { + output.subtractAmount("75000"); + }).toThrow("Cannot subtract more than the current amount"); + }); + }); + + describe("Locked Output", () => { + it("should not allow modifications to locked output", () => { + const lockedOutput = new BtcTxOutputTemplate( + validOutputTemplateFixtures[1].data, + ); + expect(() => { + lockedOutput.setAmount("200000"); + }).toThrow("Cannot modify non-malleable output"); + expect(() => { + lockedOutput.addAmount("50000"); + }).toThrow("Cannot modify non-malleable output"); + expect(() => { + lockedOutput.subtractAmount("50000"); + }).toThrow("Cannot modify non-malleable output"); + }); + }); + + describe("Lock", () => { + it("should lock an output", () => { + const output = new BtcTxOutputTemplate( + validOutputTemplateFixtures[0].data, + ); + output.lock(); + expect(output.isMalleable).toBe(false); + }); + + it("should not throw error when locking an already locked output", () => { + const output = new BtcTxOutputTemplate( + validOutputTemplateFixtures[1].data, + ); + expect(() => { + output.lock(); + }).not.toThrow(); + expect(output.isMalleable).toBe(false); + }); + }); + + describe("isValid", () => { + it("should return true for valid output", () => { + const output = new BtcTxOutputTemplate( + validOutputTemplateFixtures[0].data, + ); + expect(output.isValid()).toBe(true); + }); + + it("should return false for output with empty address", () => { + const output = new BtcTxOutputTemplate({ + address: "", + amountSats: "100000", + }); + expect(output.isValid()).toBe(false); + }); + + it("should throw error for locked output with zero amount", () => { + expect(() => { + new BtcTxOutputTemplate({ + address: "dummy", + amountSats: "0", + locked: true, + }); + }).toThrow("Locked outputs must have an amount specified."); + }); + }); +}); diff --git a/packages/caravan-fees/src/tests/btcTransactionTemplate.fixtures.ts b/packages/caravan-fees/src/tests/btcTransactionTemplate.fixtures.ts new file mode 100644 index 00000000..aa373679 --- /dev/null +++ b/packages/caravan-fees/src/tests/btcTransactionTemplate.fixtures.ts @@ -0,0 +1,166 @@ +import { Network } from "@caravan/bitcoin"; + +export const fixtures: TestFixture[] = [ + { + case: "Single input, multiple outputs transaction", + input: { + inputs: [ + { + txid: "781e5527d1af148125f6f1c29177cd2168246d84210dd223019811286b2f4718", + vout: 5, + value: "22181635", + sequence: 4294967295, + prevTxHex: + "0100000000010117ba2213d4849fded58c68eb58da6a0a7d310bba86e4eff2dd3a4da88a7044f20500000000ffffffff06297d080000000000160014fe3f10c6682ca520d7d4bb9ce6aac24a3e8887bdbaa2030000000000160014a91587db15572a94389fd4ae1dcbb21b14c61bd631660400000000001600144c43740c628dd1e8a67fa0e8cef7ce6dfc5aba71787e0400000000001976a9142bb0012ff941501d1207f2fba0223d3f4411162288acd01213000000000017a914d3817a569b661bb420560132777c3c4536d925a08703775201000000001600149a5cf45acfa00df89f70fe345be34cc6abbb408e02483045022100b785095310cbf768071fba7da86f57316547dae1c7cceb3b6d2803108cae88660220041fb4dff95fea2247d2582738233e1fb8e8f06abc844de261b6141214ffd59b012103782c80595775eb1b564ee6b136075f90824c5f102c15884546ea590f3636584f00000000", + witnessUtxo: { + script: Buffer.from( + "00149a5cf45acfa00df89f70fe345be34cc6abbb408e", + "hex", + ), + value: 24810346, + }, + }, + ], + outputs: [ + { + address: "bc1qvnek97cc7g28z966k6z7cxnkqzry0e8qpcldr0", + amountSats: "147872", + locked: true, + }, + { + address: "1P8Ka29bmHMq3eX8o16SxvN5KSzEuq7Mwr", + amountSats: "2803883", + locked: true, + }, + { + address: "bc1q5cs5gzfrg75hfmalwvuc7suw3ectqf505famgy", + amountSats: "151680", + locked: true, + }, + { + address: "bc1qlrwrfe7wt9nla7p4usevxwjfw38qlzs4mn6kn2", + amountSats: "389515", + locked: false, + }, + { + address: "3DbSS6ybKUdnqrVnMMwJqMc2x2BvuT7YpU", + amountSats: "699910", + locked: true, + }, + { + address: "bc1q4t348nq4r50l7uqzja868prca62n49ca37z4z5", + amountSats: "780487", + locked: true, + }, + { + address: "bc1qtyee6c5k4dtqtk6sf8p4hd9hqq6ajkhzzmy8vy", + amountSats: "38734", + locked: true, + }, + { + address: "bc1qnfw0gkk05qxl38mslc69hc6vc64mksyw6zzxhg", + amountSats: "17168362", + locked: true, + }, + ], + network: Network.MAINNET, + targetFeeRate: 1, + scriptType: "P2SH-P2WSH", + requiredSigners: 1, + totalSigners: 1, + }, + expected: { + vsize: 406, + fee: "1192", + feeRate: "2.93", + }, + }, + { + case: "Single input, multisig outputs transaction", + input: { + inputs: [ + { + txid: "15a435a2614ee85776e26908fab442e51a57f4e83f5f4a798ca190ef5fd7defe", + vout: 1, + value: "36444", + sequence: 4294967295, + prevTxHex: + "0100000000010117ba2213d4849fded58c68eb58da6a0a7d310bba86e4eff2dd3a4da88a7044f20500000000ffffffff06297d080000000000160014fe3f10c6682ca520d7d4bb9ce6aac24a3e8887bdbaa2030000000000160014a91587db15572a94389fd4ae1dcbb21b14c61bd631660400000000001600144c43740c628dd1e8a67fa0e8cef7ce6dfc5aba71787e0400000000001976a9142bb0012ff941501d1207f2fba0223d3f4411162288acd01213000000000017a914d3817a569b661bb420560132777c3c4536d925a08703775201000000001600149a5cf45acfa00df89f70fe345be34cc6abbb408e02483045022100b785095310cbf768071fba7da86f57316547dae1c7cceb3b6d2803108cae88660220041fb4dff95fea2247d2582738233e1fb8e8f06abc844de261b6141214ffd59b012103782c80595775eb1b564ee6b136075f90824c5f102c15884546ea590f3636584f00000000", + witnessUtxo: { + script: Buffer.from( + "00149a5cf45acfa00df89f70fe345be34cc6abbb408e", + "hex", + ), + value: 24810346, + }, + }, + ], + outputs: [ + { + address: "bc1q6kz5j2ppfjgwq3g9anvsg7vwnjl8s6vree7v0r", + amountSats: "547", + locked: true, + }, + { + address: "bc1qfyxv2ndmp7uy3vzaqpwf8uf9l9a2rxphr8pftj", + amountSats: "31020", + locked: false, + }, + { + address: + "bc1qs64cjuvgwyw0j8d3txxxmyc4ajal49u6a5f9feefy7cr64ndy85ssqswh8", + amountSats: "796", + locked: true, + }, + { + address: + "bc1quykxzkguhk02svrrcayu4ppdva2lfcsfz05wy7a8j5t5cgcfelyqp3xhkt", + amountSats: "796", + locked: true, + }, + ], + network: Network.MAINNET, + targetFeeRate: 1, + scriptType: "P2SH-P2WSH", + requiredSigners: 1, + totalSigners: 3, + }, + expected: { + vsize: 287, + fee: "3285", + feeRate: "11.4", + }, + }, +]; + +//Reduce method is being called on fixture.test.inputs, which TypeScript doesn't recognize as an array. To fix this, we need to ensure that fixture.test.inputs is properly typed as an array. +export interface TestInput { + txid: string; + vout: number; + value: string; + sequence?: number; + prevTxHex?: string; + witnessUtxo?: any; +} + +export interface TestFixture { + case: string; + input: { + inputs: TestInput[]; + outputs: { + address: string; + amountSats: string; + locked: boolean; + }[]; + network: Network; + targetFeeRate: number; + scriptType: string; + requiredSigners: number; + totalSigners: number; + }; + expected: { + vsize: number; + fee: string; + feeRate: string; + }; +} diff --git a/packages/caravan-fees/src/tests/btcTransactionTemplate.test.ts b/packages/caravan-fees/src/tests/btcTransactionTemplate.test.ts new file mode 100644 index 00000000..b314367a --- /dev/null +++ b/packages/caravan-fees/src/tests/btcTransactionTemplate.test.ts @@ -0,0 +1,422 @@ +import { BtcTransactionTemplate } from "../btcTransactionTemplate"; +import { + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "../btcTransactionComponents"; +import { Network } from "@caravan/bitcoin"; +import { UTXO } from "../types"; +import { fixtures, TestFixture } from "./btcTransactionTemplate.fixtures"; +import BigNumber from "bignumber.js"; + +describe("BtcTransactionTemplate", () => { + fixtures.forEach((fixture: TestFixture) => { + describe(fixture.case, () => { + let txTemplate: BtcTransactionTemplate; + + beforeEach(() => { + txTemplate = new BtcTransactionTemplate({ + inputs: fixture.input.inputs.map((input) => + BtcTxInputTemplate.fromUTXO(input as unknown as UTXO), + ), + outputs: fixture.input.outputs.map( + (output) => new BtcTxOutputTemplate(output), + ), + network: fixture.input.network, + targetFeeRate: fixture.input.targetFeeRate, + scriptType: fixture.input.scriptType, + requiredSigners: fixture.input.requiredSigners, + totalSigners: fixture.input.totalSigners, + }); + }); + + test("should correctly calculate total input amount", () => { + const expectedInputAmount = fixture.input.inputs.reduce( + (sum, input) => sum.plus(input.value), + new BigNumber(0), + ); + expect(txTemplate.totalInputAmount).toBe( + expectedInputAmount.toString(), + ); + }); + + test("should correctly calculate total output amount", () => { + const expectedOutputAmount = fixture.input.outputs.reduce( + (sum, output) => sum.plus(output.amountSats), + new BigNumber(0), + ); + expect(txTemplate.totalOutputAmount).toBe( + expectedOutputAmount.toString(), + ); + }); + + test("should correctly estimate transaction vsize", () => { + expect(txTemplate.estimatedVsize).toBe(fixture.expected.vsize); + }); + + test("should correctly calculate transaction fee", () => { + expect(txTemplate.currentFee).toBe(fixture.expected.fee); + }); + + test("should correctly calculate fee rate", () => { + expect(parseFloat(txTemplate.estimatedFeeRate)).toBeCloseTo( + parseFloat(fixture.expected.feeRate), + 1, + ); + }); + + test("should validate transaction correctly", () => { + expect(txTemplate.validate()).toBe(true); + }); + + test("should detect if fees are paid correctly", () => { + const paidFees = new BigNumber(txTemplate.currentFee); + const requiredFees = new BigNumber(txTemplate.targetFeesToPay); + expect(txTemplate.areFeesPaid()).toBe(paidFees.gte(requiredFees)); + }); + + test("should adjust change output correctly", () => { + const changeOutputs = txTemplate.outputs.filter( + (output) => output.isMalleable, + ); + const originalChangeOutput = changeOutputs[0]; + + txTemplate.adjustChangeOutput(); + + const changeOutputs2 = txTemplate.outputs.filter( + (output) => output.isMalleable, + ); + const newChangeOutput = changeOutputs2[0]; + + if (originalChangeOutput) { + expect(newChangeOutput).toBeDefined(); + expect( + new BigNumber(newChangeOutput.amountSats).gte( + originalChangeOutput.amountSats, + ), + ).toBe(true); + } else { + expect(newChangeOutput).toBeUndefined(); + } + }); + + test("should create valid PSBT", () => { + const psbt = txTemplate.toPsbt(); + expect(psbt).toBeTruthy(); + expect(() => txTemplate.toPsbt()).not.toThrow(); + }); + }); + }); +}); + +describe("BtcTxInputTemplate", () => { + test("should create valid input", () => { + const input = new BtcTxInputTemplate({ + txid: "781e5527d1af148125f6f1c29177cd2168246d84210dd223019811286b2f4718", + vout: 5, + amountSats: "22181635", + }); + expect(input.isValid()).toBe(true); + }); + + test("should detect invalid input", () => { + const input = new BtcTxInputTemplate({ + txid: "", + vout: -1, + amountSats: "0", + }); + expect(input.isValid()).toBe(false); + }); + + test("should correctly convert between satoshis and BTC", () => { + const input = new BtcTxInputTemplate({ + txid: "781e5527d1af148125f6f1c29177cd2168246d84210dd223019811286b2f4718", + vout: 5, + amountSats: "22181635", + }); + expect(input.amountBTC).toBe("0.22181635"); + }); +}); + +describe("BtcTxOutputTemplate", () => { + test("should create valid output", () => { + const output = new BtcTxOutputTemplate({ + address: "bc1q64f362fb18f21471175ab685ec1a76008647e4e0", + amountSats: "134560", + locked: true, + }); + expect(output.isValid()).toBe(true); + }); + + test("should detect invalid output", () => { + expect(() => { + new BtcTxOutputTemplate({ + address: "", + amountSats: "0", + locked: true, + }).isValid(); + }).toThrow(); + }); + + test("should correctly handle malleable outputs", () => { + const output = new BtcTxOutputTemplate({ + address: "bc1q64f362fb18f21471175ab685ec1a76008647e4e0", + amountSats: "134560", + locked: false, + }); + expect(output.isMalleable).toBe(true); + output.lock(); + expect(output.isMalleable).toBe(false); + expect(() => output.setAmount("200000")).toThrow(); + }); + + test("should correctly add and subtract amounts", () => { + const output = new BtcTxOutputTemplate({ + address: "bc1q64f362fb18f21471175ab685ec1a76008647e4e0", + amountSats: "134560", + locked: false, + }); + output.addAmount("10000"); + expect(output.amountSats).toBe("144560"); + output.subtractAmount("20000"); + expect(output.amountSats).toBe("124560"); + }); + + describe("needsChangeOutput", () => { + test("should correctly determine if change output is needed", () => { + const txTemplate = new BtcTransactionTemplate({ + inputs: [ + new BtcTxInputTemplate({ + txid: "1234", + vout: 0, + amountSats: "100000", + }), + ], + outputs: [ + new BtcTxOutputTemplate({ + address: "1234", + amountSats: "90000", + locked: true, + }), + ], + network: Network.MAINNET, + targetFeeRate: 1, + scriptType: "P2WSH", + requiredSigners: 1, + totalSigners: 1, + dustThreshold: "546", + }); + + expect(txTemplate.needsChangeOutput).toBe(true); + + txTemplate.addOutput( + new BtcTxOutputTemplate({ + address: "5678", + amountSats: "9000", + locked: false, + }), + ); + expect(txTemplate.needsChangeOutput).toBe(false); + }); + }); + + describe("addInput and addOutput", () => { + test("should correctly add input and output", () => { + const txTemplate = new BtcTransactionTemplate({ + inputs: [], + outputs: [], + network: Network.MAINNET, + targetFeeRate: 1, + scriptType: "p2pkh", + requiredSigners: 1, + totalSigners: 1, + }); + + const input = new BtcTxInputTemplate({ + txid: "1234", + vout: 0, + amountSats: "100000", + }); + txTemplate.addInput(input); + expect(txTemplate.inputs.length).toBe(1); + expect(txTemplate.inputs[0]).toBe(input); + + const output = new BtcTxOutputTemplate({ + address: "1234", + amountSats: "100000", + locked: true, + }); + txTemplate.addOutput(output); + expect(txTemplate.outputs.length).toBe(1); + expect(txTemplate.outputs[0]).toBe(output); + }); + }); + + describe("removeOutput", () => { + test("should correctly remove output", () => { + const txTemplate = new BtcTransactionTemplate({ + inputs: [ + new BtcTxInputTemplate({ + txid: "1234", + vout: 0, + amountSats: "100000", + }), + ], + outputs: [ + new BtcTxOutputTemplate({ + address: "1234", + amountSats: "50000", + locked: true, + }), + new BtcTxOutputTemplate({ + address: "5678", + amountSats: "50000", + locked: true, + }), + ], + network: Network.MAINNET, + targetFeeRate: 1, + scriptType: "p2pkh", + requiredSigners: 1, + totalSigners: 1, + }); + + expect(txTemplate.outputs.length).toBe(2); + txTemplate.removeOutput(0); + expect(txTemplate.outputs.length).toBe(1); + expect(txTemplate.outputs[0].address).toBe("5678"); + }); + }); + + describe("static from Psbt", () => { + // https://en.bitcoin.it/wiki/BIP_0174 + const fixture = { + test: { + psbtHex: + "cHNidP8BAJoCAAAAAljoeiG1ba8MI76OcHBFbDNvfLqlyHV5JPVFiHuyq911AAAAAAD/////g40EJ9DsZQpoqka7CwmK6kQiwHGyyng1Kgd5WdB86h0BAAAAAP////8CcKrwCAAAAAAWABTYXCtx0AYLCcmIauuBXlCZHdoSTQDh9QUAAAAAFgAUAK6pouXw+HaliN9VRuh0LR2HAI8AAAAAAAEAuwIAAAABqtc5MQGL0l+ErkALaISL4J23BurCrBgpi6vucatlb4sAAAAASEcwRAIgWPb8fGoz4bMVSNSByCbAFb0wE1qtQs1neQ2rZtKtJDsCIEoc7SYExnNbY5PltBaR3XiwDwxZQvufdRhW+qk4FX26Af7///8CgPD6AgAAAAAXqRQPuUY0IWlrgsgzryQceMF9295JNIfQ8gonAQAAABepFCnKdPigj4GZlCgYXJe12FLkBj9hh2UAAAAiAgLath/0mhTban0CsM0fu3j8SxgxK1tOVNrk26L7/vU210gwRQIhAPYQOLMI3B2oZaNIUnRvAVdyk0IIxtJEVDk82ZvfIhd3AiAFbmdaZ1ptCgK4WxTl4pB02KJam1dgvqKBb2YZEKAG6gEBAwQBAAAAAQRHUiEClYO/Oa4KYJdHrRma3dY0+mEIVZ1sXNObTCGD8auW4H8hAtq2H/SaFNtqfQKwzR+7ePxLGDErW05U2uTbovv+9TbXUq4iBgKVg785rgpgl0etGZrd1jT6YQhVnWxc05tMIYPxq5bgfxDZDGpPAAAAgAAAAIAAAACAIgYC2rYf9JoU22p9ArDNH7t4/EsYMStbTlTa5Nui+/71NtcQ2QxqTwAAAIAAAACAAQAAgAABASAAwusLAAAAABepFLf1+vQOPUClpFmx2zU18rcvqSHohyICAjrdkE89bc9Z3bkGsN7iNSm3/7ntUOXoYVGSaGAiHw5zRzBEAiBl9FulmYtZon/+GnvtAWrx8fkNVLOqj3RQql9WolEDvQIgf3JHA60e25ZoCyhLVtT/y4j3+3Weq74IqjDym4UTg9IBAQMEAQAAAAEEIgAgjCNTFzdDtZXftKB7crqOQuN5fadOh/59nXSX47ICiQMBBUdSIQMIncEMesbbVPkTKa9hczPbOIzq0MIx9yM3nRuZAwsC3CECOt2QTz1tz1nduQaw3uI1Kbf/ue1Q5ehhUZJoYCIfDnNSriIGAjrdkE89bc9Z3bkGsN7iNSm3/7ntUOXoYVGSaGAiHw5zENkMak8AAACAAAAAgAMAAIAiBgMIncEMesbbVPkTKa9hczPbOIzq0MIx9yM3nRuZAwsC3BDZDGpPAAAAgAAAAIACAACAACICA6mkw39ZltOqJdusa1cK8GUDlEkpQkYLNUdT7Z7spYdxENkMak8AAACAAAAAgAQAAIAAIgICf2OZdX0u/1WhNq0CxoSxg4tlVuXxtrNCgqlLa1AFEJYQ2QxqTwAAAIAAAACABQAAgAA=", + options: { + network: Network.MAINNET, + targetFeeRate: 10, + dustThreshold: "546", + scriptType: "P2WSH", + requiredSigners: 1, + totalSigners: 1, + }, + }, + expected: { + inputCount: 2, + outputCount: 2, + inputs: [ + { + txid: "75ddabb27b8845f5247975c8a5ba7c6f336c4570708ebe230caf6db5217ae858", + vout: 0, + }, + { + txid: "1dea7cd05979072a3578cab271c02244ea8a090bbb46aa680a65ecd027048d83", + vout: 1, + }, + ], + outputs: [ + { + address: "bc1qmpwzkuwsqc9snjvgdt4czhjsnywa5yjdgwyw6k", + amountSats: "149990000", + }, + { + address: "bc1qqzh2ngh97ru8dfvgma25d6r595wcwqy0skmt5z", + amountSats: "100000000", + }, + ], + totalInputAmount: "250000000", + totalOutputAmount: "249990000", + }, + }; + + it("should correctly parse the PSBT and create a BtcTransactionTemplate", () => { + const txTemplate = BtcTransactionTemplate.fromPsbt( + fixture.test.psbtHex, + fixture.test.options, + ); + + // Check input parsing + expect(txTemplate.inputs.length).toBe(fixture.expected.inputCount); + txTemplate.inputs.forEach((input, index) => { + expect(input.txid).toBe(fixture.expected.inputs[index].txid); + expect(input.vout).toBe(fixture.expected.inputs[index].vout); + }); + + // Check output parsing + expect(txTemplate.outputs.length).toBe(fixture.expected.outputCount); + txTemplate.outputs.forEach((output, index) => { + expect(output.address).toBe(fixture.expected.outputs[index].address); + expect(output.amountSats).toBe( + fixture.expected.outputs[index].amountSats, + ); + }); + + // Check total amounts + expect(txTemplate.totalInputAmount).toBe( + fixture.expected.totalInputAmount, + ); + expect(txTemplate.totalOutputAmount).toBe( + fixture.expected.totalOutputAmount, + ); + + // Check if options are correctly set + expect(txTemplate["_network"]).toBe(fixture.test.options.network); + expect(txTemplate["_targetFeeRate"].toNumber()).toBe( + fixture.test.options.targetFeeRate, + ); + expect(txTemplate["_dustThreshold"].toString()).toBe( + fixture.test.options.dustThreshold, + ); + expect(txTemplate["_scriptType"]).toBe(fixture.test.options.scriptType); + expect(txTemplate["_requiredSigners"]).toBe( + fixture.test.options.requiredSigners, + ); + expect(txTemplate["_totalSigners"]).toBe( + fixture.test.options.totalSigners, + ); + }); + + it("should correctly calculate fee-related properties", () => { + const txTemplate = BtcTransactionTemplate.fromPsbt( + fixture.test.psbtHex, + fixture.test.options, + ); + + expect(txTemplate.currentFee).toBe("10000"); + expect(txTemplate.targetFeesToPay).toBe("2360"); // 236 vbytes * 10 sats/vbyte + expect(txTemplate.areFeesPaid()).toBe(true); + expect(txTemplate.feeRateSatisfied).toBe(true); + }); + + it("should handle malleable outputs correctly", () => { + const txTemplate = BtcTransactionTemplate.fromPsbt( + fixture.test.psbtHex, + fixture.test.options, + ); + + expect(txTemplate.malleableOutputs.length).toBe(0); // as we dont want to affect the already exising output amounts + expect(txTemplate.needsChangeOutput).toBe(true); + }); + + it("should throw an error for invalid PSBT", () => { + const invalidPsbtHex = "invalidPsbtHex"; + expect(() => { + BtcTransactionTemplate.fromPsbt(invalidPsbtHex, fixture.test.options); + }).toThrow(); + }); + + it("should handle PSBTs with missing input information", () => { + // Create a PSBT with missing input information + const psbtWithMissingInputInfo = + "cHNidP8BAEwCAAAAAALT3/UFAAAAABl2qRTQxZkDxbrChodg6Q/VIaRmWqdlIIisAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4ezLhMAAAAA"; + + const templateIn = BtcTransactionTemplate.fromPsbt( + psbtWithMissingInputInfo, + fixture.test.options, + ); + + expect(templateIn.inputs.length).toBe(0); + }); + }); +}); diff --git a/packages/caravan-fees/src/tests/cpfp.fixtures.ts b/packages/caravan-fees/src/tests/cpfp.fixtures.ts new file mode 100644 index 00000000..49b8840b --- /dev/null +++ b/packages/caravan-fees/src/tests/cpfp.fixtures.ts @@ -0,0 +1,86 @@ +import { Network } from "@caravan/bitcoin"; +import { UTXO, SCRIPT_TYPES } from "../types"; + +const parentTxHex = + "020000000001019ef21963fbf5261d3b62f7f0467ab4b6d006b7d25a27d6744c95d9c11f577b210300000000ffffffff02713d0000000000001600147938bb5013f400246165f507ed015853430e28d2007c500200000000160014f2aecd6ab28d970ee8eea34665c181393b8754c60247304402201aaa53e645c14148171c3ea39841ee4ad7451d3a30f651e8a38ca20cec2cab9402206eab21ae37a5e0eaa0fe39d26821133e2c97297897de75b854865b5884a3523b012102b38786de2766d97e9d0341f9c2435b71242f0e41e887aebf8af5943afa7fa9b800000000"; + +const availableUTXOs: UTXO[] = [ + { + txid: "9805c05eebf91913601ed9024330b8a3d4fcc4d2503abf4dce5067cb011673c5", + vout: 0, + value: "529781", + witnessUtxo: { + script: Buffer.from( + "001497a754f6dade2dba25d58058b1a283597f4236aa", + "hex", + ), + value: 529781, + }, + prevTxHex: + "02000000000101427edede923448733dc125a975931bc62ecb5366bdf1289a0cbd445bf85d26620000000000fdffffff02751508000000000016001497a754f6dade2dba25d58058b1a283597f4236aaa1e3290000000000160014dc9abb9f0536f8ce517a248da673476a48a384f30247304402200d79b523aa388327ef663649bb2fe70fb405353de86cee0cbc30e74d131767140220437699b88488c56cab545c31a2fd0d139f6624fb1bed334f5138075dcee2d622012102e6ccf653b0c47a6b7a3d5c8b4bac43d51b2bbc7310d92cefbfbbdaf0588950d3421b0d00", + }, + { + txid: "77f437ae7f796896f1d69e2c9329202d6ac4b4a03fbc0f18e06dfab87f4b0702", + vout: 1, + value: "38829056", + witnessUtxo: { + script: Buffer.from( + "0014f2aecd6ab28d970ee8eea34665c181393b8754c6", + "hex", + ), + value: 38829056, + }, + prevTxHex: parentTxHex, + }, +]; + +export const cpfpValidFixtures = [ + { + case: "Valid CPFP transaction creation", + options: { + originalTx: parentTxHex, + availableInputs: availableUTXOs, + spendableOutputIndex: 1, + changeAddress: "bc1q72hv664j3ktsa68w5drxtsvp8yacw4xxt7rvxm", + network: Network.MAINNET, + dustThreshold: "546", + scriptType: SCRIPT_TYPES.P2SH_P2WSH, + targetFeeRate: 6.33, + absoluteFee: "871", + requiredSigners: 1, + totalSigners: 1, + strict: true, + }, + expected: { + parentTxid: + "77f437ae7f796896f1d69e2c9329202d6ac4b4a03fbc0f18e06dfab87f4b0702", + parentFee: "871", + parentVsize: 140.25, + childVsize: 168, + childFee: "1085", + combinedFeeRate: 6.35, + changeOutput: { + address: "bc1q72hv664j3ktsa68w5drxtsvp8yacw4xxt7rvxm", + value: "38827971", + }, + }, + }, +]; + +export const cpfpInvalidFixtures = [ + { + case: "CPFP not possible due to invalid spendable output index", + options: { + ...cpfpValidFixtures[0].options, + spendableOutputIndex: 10, // This output doesn't exist in the parent transaction + }, + }, + { + case: "Combined fee rate too low", + options: { + ...cpfpValidFixtures[0].options, + targetFeeRate: 2000, // Much higher than what can be achieved + }, + }, + // Removed the Dust output creation case, as now we use the parent tx to get the spendable output, as child tx's input so cannot override it's amount to create this invalid case . +]; diff --git a/packages/caravan-fees/src/tests/cpfp.test.ts b/packages/caravan-fees/src/tests/cpfp.test.ts new file mode 100644 index 00000000..3de8984e --- /dev/null +++ b/packages/caravan-fees/src/tests/cpfp.test.ts @@ -0,0 +1,93 @@ +import { createCPFPTransaction } from "../cpfp"; +import { TransactionAnalyzer } from "../transactionAnalyzer"; +import { cpfpValidFixtures, cpfpInvalidFixtures } from "./cpfp.fixtures"; +import { PsbtV2 } from "@caravan/psbt"; +import BigNumber from "bignumber.js"; +import { + calculateTotalInputValue, + calculateTotalOutputValue, + estimateTransactionVsize, +} from "../utils"; + +describe("CPFP Transaction Creation", () => { + describe("Valid CPFP Transactions", () => { + cpfpValidFixtures.forEach((fixture) => { + it(fixture.case, () => { + const cpfpPsbtBase64 = createCPFPTransaction(fixture.options); + const psbt = new PsbtV2(cpfpPsbtBase64); + + // Step 1: Verify transaction analysis + const txAnalyzer = new TransactionAnalyzer({ + txHex: fixture.options.originalTx, + network: fixture.options.network, + targetFeeRate: fixture.options.targetFeeRate, + absoluteFee: fixture.options.absoluteFee, + availableUtxos: fixture.options.availableInputs, + requiredSigners: fixture.options.requiredSigners, + totalSigners: fixture.options.totalSigners, + addressType: fixture.options.scriptType, + changeOutputIndex: fixture.options.spendableOutputIndex, + }); + + const analysis = txAnalyzer.analyze(); + // Step 2: Verify CPFP possibility + expect(analysis.canCPFP).toBe(true); + + // Step 3: Verify new transaction template + expect(psbt.PSBT_GLOBAL_INPUT_COUNT).toBe(1); + expect(psbt.PSBT_GLOBAL_OUTPUT_COUNT).toBe(1); + + // Step 4: Verify spendable output is used as input + expect(psbt.PSBT_IN_PREVIOUS_TXID[0]).toBe(fixture.expected.parentTxid); + expect(psbt.PSBT_IN_OUTPUT_INDEX[0]).toBe( + fixture.options.spendableOutputIndex, + ); + + // Step 5: Verify change output + expect(psbt.PSBT_OUT_SCRIPT[0]).toContain( + fixture.options.availableInputs[ + fixture.options.spendableOutputIndex + ].witnessUtxo?.script.toString("hex"), + ); + + // Step 6 & 7: Verify fee calculation and change amount + const totalInputAmount = calculateTotalInputValue(psbt); + const totalOutputAmount = calculateTotalOutputValue(psbt); + const fee = totalInputAmount.minus(totalOutputAmount); + expect(fee.toString()).toBe(fixture.expected.childFee); + expect(totalOutputAmount.toString()).toBe( + fixture.expected.changeOutput.value, + ); + + // Step 8: Verify child transaction validity + const childVsize = estimateTransactionVsize({ + addressType: fixture.options.scriptType, + numInputs: psbt.PSBT_GLOBAL_INPUT_COUNT, + numOutputs: psbt.PSBT_GLOBAL_OUTPUT_COUNT, + m: fixture.options.requiredSigners, + n: fixture.options.totalSigners, + }); + expect(childVsize).toBeCloseTo(fixture.expected.childVsize, 0); + + // Step 9: Verify combined fee rate + const parentFee = new BigNumber(fixture.expected.parentFee); + const combinedFee = parentFee.plus(fee); + const combinedSize = new BigNumber(fixture.expected.parentVsize).plus( + childVsize, + ); + const combinedFeeRate = combinedFee.dividedBy(combinedSize); + expect(combinedFeeRate.toFixed(2)).toBe( + fixture.expected.combinedFeeRate.toFixed(2), + ); + }); + }); + }); + + describe("Invalid CPFP Transactions", () => { + cpfpInvalidFixtures.forEach((fixture) => { + it(fixture.case, () => { + expect(() => createCPFPTransaction(fixture.options)).toThrow(); + }); + }); + }); +}); diff --git a/packages/caravan-fees/src/tests/rbf.fixtures.ts b/packages/caravan-fees/src/tests/rbf.fixtures.ts new file mode 100644 index 00000000..b45f0145 --- /dev/null +++ b/packages/caravan-fees/src/tests/rbf.fixtures.ts @@ -0,0 +1,336 @@ +import { Network } from "@caravan/bitcoin"; +import { UTXO, SCRIPT_TYPES } from "../types"; + +export const rbfFixtures = { + cancelRbf: [ + // https://mempool.space/tx/be81b81620702718957d445611066bd596fe1840e219b84f6ea60e0114a7a305 + { + /* + ====================================== IMPORTANT ==================================== + This multisig transaction example demonstrates a key aspect of RBF (Replace-By-Fee) cancellation: + + Original Transaction: + - Type: P2WSH (Pay-to-Witness-Script-Hash) + - Inputs: 2 + - Outputs: 3 + - Fee: 20,880 satoshis + - Size: 348 vBytes + - Fee Rate: 60 sat/vB (20,880 / 348) + + RBF Cancellation Scenario: + 1. Target Fee Rate: 80 sat/vB (increased from original 60 sat/vB) + 2. Cancellation tx typically has fewer outputs (usually just one to the cancellation address) + 3. In this case, the new tx size decreases to 159 vBytes due to having only one output + + RBF Fee Calculation: + - Minimum required fee: 20,880 satoshis (must be >= original fee to satisfy RBF rule #3) + - Actual fee rate achieved: 131.32 sat/vB (20,880 / 159) + + Key Observation: + Even though a fee of 12,720 satoshis (159 * 80) would achieve the desired 80 sat/vB rate, + we must pay the original fee of 20,880 satoshis to satisfy RBF rules. This results in a + higher effective fee rate than initially targeted. + Note here our RBF function pays 21,228 = original(20,880) + incremental_fees * original_tx_size(1 * 348) + as we use txAnalyzer to calculate minimum RBF fee which cannot account for changed tx size as in this case so assumes the above minimum fees to be paid + + This example illustrates how RBF cancellations can sometimes lead to paying more in fees + than strictly necessary for the desired fee rate, due to the requirement to pay at least + the original transaction's fee. + */ + case: "Create a valid cancel RBF transaction", + originalTx: + "010000000001022b2be3ac975c385defbda23a1eec30946b708c209981a5c07830fd726b3e25530200000000fdffffffe635a5d14ea5eb63d455948c0894d61ac35f7ba00f260f128b725f25855bd6550400000000fdffffff03400caa3b00000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d886c5e1b000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df980960d04000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e048040047304402202c0be2d56da6f551b92e657a1f29bc410f244d68063bbc14297bae318d06a32d0220276ddf6da48d0dfdc717974aee32b94a45867393a663df8bc1e8382e1f7d9989014730440220649a73751ad934c9e517022bc8b510d53474772286e5678227ae71837ec0307d022031ec829069c7ab474759741c6acd1efb7e0918d5d63e4fbd1071ce3ea1bfc6ec01695221022dfa322241a4946b9ead36ab9c8c55bd4c4340a1290b5bf71d23a695aeb1240a21034d82610a17c332852205e063c64fee21a77fabc7ac0e6d7ada2a820922c9a5dc2103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f88053ae040047304402207361009ec2357c8d9f178a79772715d29308f1c265bf3c31f083d9253dcedd3c02203fbe90a73f2ca763233cacd9f6be1ebc24abdeff928d5ad454c6fd50b9bc869d014730440220416d0e1acfd95024dc31bc534fee0662ffd906dba98040ac4ed039bf4451176d02203b9416a93b4a116f4edcbfd256e5f232bc9b9f12f6270e84e23597ce68f24ca201695221022dfa322241a4946b9ead36ab9c8c55bd4c4340a1290b5bf71d23a695aeb1240a21034d82610a17c332852205e063c64fee21a77fabc7ac0e6d7ada2a820922c9a5dc2103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f88053ae00000000", + network: Network.MAINNET, + inputs: [ + { + // https://mempool.space/tx/53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b#flow=&vout=2 + txid: "53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b", + vout: 2, + value: "932049200", + sequence: 4294967293, + }, + { + // https://mempool.space/tx/55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6#flow=&vout=4 + txid: "55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6", + vout: 4, + value: "596144040", + sequence: 4294967293, + }, + ], + availableUtxos: [ + // (Original Input) https://mempool.space/tx/53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b#flow=&vout=2 + { + txid: "53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b", + vout: 2, + value: "932049200", + witnessUtxo: { + script: Buffer.from( + "00202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e048", + "hex", + ), + value: 932049200, + }, + prevTxHex: + "010000000145a8a366f1e073e4e1a03172b95e7db2f006d710d3585e5d14a137c218bf90ed03000000fc00473044022056da571c657d063d18217cb3d2cdb827d11fdd0fdef9ffc0ef69345628c0da260220092f7fe2b2d2cba7d1f4d53f0fb7db54dd15c6b8aac2f8e66102778aac0a62250147304402200bf2e4c82a2337168b7370deae483c88521367da8415c65ffa24b21ba4b13a7f02207a946b5c0f9f9213be130022d83f2ee91363c08de8cd2460765e020f59a3399a014c695221026fcec918a19aad4c92527f4bad924c7cc8dfdba935418f3ce217c1c839a58b952103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f8802103d1fc482d299248b97e38d9042a068eb102818a3556d5247d080849a0880a9e2653aefdffffff03400caa3b000000002200203eb5062a0b0850b23a599425289a091c374ca934101d03144f060c5b46a979be181d1a1a000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df930f18d37000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e04800000000", + }, + { + // (Original Input 2) https://mempool.space/tx/55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6#flow=&vout=4 + txid: "55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6", + vout: 4, + value: "596144040", + witnessUtxo: { + script: Buffer.from( + "00202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e048", + "hex", + ), + value: 596144040, + }, + prevTxHex: + "010000000145a8a366f1e073e4e1a03172b95e7db2f006d710d3585e5d14a137c218bf90ed05000000fc004730440220414f5352d874fad4171326bdc563f2724eadbbe8e3199fbc83f9d6d04dc02176022072ec117a06d340209b44cef533d1a2f21c0ace64f653f454e745add290938e53014730440220367ff913573c50706003423b47648553730d0377c7707e505d1a4f9a08313ab6022073fad7c23abeb4ca1d48075bd276c7a5aa7b0d942ad741c9a8a4233a264f2df1014c695221026fcec918a19aad4c92527f4bad924c7cc8dfdba935418f3ce217c1c839a58b952103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f8802103d1fc482d299248b97e38d9042a068eb102818a3556d5247d080849a0880a9e2653aefdffffff05400caa3b000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df90084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d0084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d3819b1110000000022002014b288dca5d59caa8868d1668c97c971e58ab3ccf10534ac567ea51aa8aba299a86f8823000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e04800000000", + }, + { + // extra UTXO , NOT IN ORIGINAL TX + // https://mempool.space/tx/55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6#flow=&vout=3 + txid: "55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6", + vout: 3, + value: "‎296819000", + witnessUtxo: { + script: Buffer.from( + "002014b288dca5d59caa8868d1668c97c971e58ab3ccf10534ac567ea51aa8aba299", + "hex", + ), + value: 296819000, + }, + prevTxHex: + "010000000145a8a366f1e073e4e1a03172b95e7db2f006d710d3585e5d14a137c218bf90ed05000000fc004730440220414f5352d874fad4171326bdc563f2724eadbbe8e3199fbc83f9d6d04dc02176022072ec117a06d340209b44cef533d1a2f21c0ace64f653f454e745add290938e53014730440220367ff913573c50706003423b47648553730d0377c7707e505d1a4f9a08313ab6022073fad7c23abeb4ca1d48075bd276c7a5aa7b0d942ad741c9a8a4233a264f2df1014c695221026fcec918a19aad4c92527f4bad924c7cc8dfdba935418f3ce217c1c839a58b952103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f8802103d1fc482d299248b97e38d9042a068eb102818a3556d5247d080849a0880a9e2653aefdffffff05400caa3b000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df90084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d0084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d3819b1110000000022002014b288dca5d59caa8868d1668c97c971e58ab3ccf10534ac567ea51aa8aba299a86f8823000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e04800000000", + }, + ], + dustThreshold: "546", + targetFeeRate: 80, // original was 60 sat/vB + scriptType: SCRIPT_TYPES.P2WSH, + requiredSigners: 2, + totalSigners: 3, + cancelAddress: + "bc1qyy30guv6m5ez7ntj0ayr08u23w3k5s8vg3elmxdzlh8a3xskupyqn2lp5w", // https://mempool.space/address/bc1qyy30guv6m5ez7ntj0ayr08u23w3k5s8vg3elmxdzlh8a3xskupyqn2lp5w + expected: { + inputCount: 1, + outputCount: 1, + fee: "20880", // absolute fees of tx + expectedfee: "21228", // rationalized above + feeRate: 133.509, // rationalized above + }, + }, + ], + + acceleratedRbf: [ + { + /* + ====================================== IMPORTANT ==================================== + We consider the same tx we considered above but here we instead of cancellation we consider the case where we accelerate the tx using the existing input and any additional + input that we may add.... + Original Transaction: + - Type: P2WSH (Pay-to-Witness-Script-Hash) + - Inputs: 2 + - Outputs: 3 + - Fee: 20,880 satoshis + - Size: 348 vBytes + - Fee Rate: 60 sat/vB (20,880 / 348) + + Accelerated RBF Scenario: + 1. Target Fee Rate: 80 sat/vB (increased from original 60 sat/vB) + 2. The accelerated tx maintains the original output structure but increases the fee + 3. Estimated new tx size: 498 vBytes (may vary slightly based on input/output changes) + + RBF Fee Calculation: + - Minimum required fee: 20,880 satoshis (must be >= original fee to satisfy RBF rule #3) + - Target fee for new size: 39,840 satoshis (498 vBytes * 80 sat/vB) + - Actual fee to pay: 39,840 satoshis (higher of the two above values) + */ + + // https://mempool.space/tx/be81b81620702718957d445611066bd596fe1840e219b84f6ea60e0114a7a305 + case: "Create a valid accelerated RBF transaction", + originalTx: + "010000000001022b2be3ac975c385defbda23a1eec30946b708c209981a5c07830fd726b3e25530200000000fdffffffe635a5d14ea5eb63d455948c0894d61ac35f7ba00f260f128b725f25855bd6550400000000fdffffff03400caa3b00000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d886c5e1b000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df980960d04000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e048040047304402202c0be2d56da6f551b92e657a1f29bc410f244d68063bbc14297bae318d06a32d0220276ddf6da48d0dfdc717974aee32b94a45867393a663df8bc1e8382e1f7d9989014730440220649a73751ad934c9e517022bc8b510d53474772286e5678227ae71837ec0307d022031ec829069c7ab474759741c6acd1efb7e0918d5d63e4fbd1071ce3ea1bfc6ec01695221022dfa322241a4946b9ead36ab9c8c55bd4c4340a1290b5bf71d23a695aeb1240a21034d82610a17c332852205e063c64fee21a77fabc7ac0e6d7ada2a820922c9a5dc2103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f88053ae040047304402207361009ec2357c8d9f178a79772715d29308f1c265bf3c31f083d9253dcedd3c02203fbe90a73f2ca763233cacd9f6be1ebc24abdeff928d5ad454c6fd50b9bc869d014730440220416d0e1acfd95024dc31bc534fee0662ffd906dba98040ac4ed039bf4451176d02203b9416a93b4a116f4edcbfd256e5f232bc9b9f12f6270e84e23597ce68f24ca201695221022dfa322241a4946b9ead36ab9c8c55bd4c4340a1290b5bf71d23a695aeb1240a21034d82610a17c332852205e063c64fee21a77fabc7ac0e6d7ada2a820922c9a5dc2103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f88053ae00000000", + network: Network.MAINNET, + inputs: [ + { + // https://mempool.space/tx/53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b#flow=&vout=2 + txid: "53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b", + vout: 2, + value: "932049200", + sequence: 4294967293, + }, + { + // https://mempool.space/tx/55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6#flow=&vout=4 + txid: "55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6", + vout: 4, + value: "596144040", + sequence: 4294967293, + }, + ], + availableUtxos: [ + // (Original Input 1) https://mempool.space/tx/53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b#flow=&vout=2 + { + txid: "53253e6b72fd3078c0a58199208c706b9430ec1e3aa2bdef5d385c97ace32b2b", + vout: 2, + value: "932049200", + witnessUtxo: { + script: Buffer.from( + "00202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e048", + "hex", + ), + value: 932049200, + }, + prevTxHex: + "010000000145a8a366f1e073e4e1a03172b95e7db2f006d710d3585e5d14a137c218bf90ed03000000fc00473044022056da571c657d063d18217cb3d2cdb827d11fdd0fdef9ffc0ef69345628c0da260220092f7fe2b2d2cba7d1f4d53f0fb7db54dd15c6b8aac2f8e66102778aac0a62250147304402200bf2e4c82a2337168b7370deae483c88521367da8415c65ffa24b21ba4b13a7f02207a946b5c0f9f9213be130022d83f2ee91363c08de8cd2460765e020f59a3399a014c695221026fcec918a19aad4c92527f4bad924c7cc8dfdba935418f3ce217c1c839a58b952103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f8802103d1fc482d299248b97e38d9042a068eb102818a3556d5247d080849a0880a9e2653aefdffffff03400caa3b000000002200203eb5062a0b0850b23a599425289a091c374ca934101d03144f060c5b46a979be181d1a1a000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df930f18d37000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e04800000000", + }, + { + // (Original Input 2) https://mempool.space/tx/55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6#flow=&vout=4 + txid: "55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6", + vout: 4, + value: "596144040", + witnessUtxo: { + script: Buffer.from( + "00202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e048", + "hex", + ), + value: 596144040, + }, + prevTxHex: + "010000000145a8a366f1e073e4e1a03172b95e7db2f006d710d3585e5d14a137c218bf90ed05000000fc004730440220414f5352d874fad4171326bdc563f2724eadbbe8e3199fbc83f9d6d04dc02176022072ec117a06d340209b44cef533d1a2f21c0ace64f653f454e745add290938e53014730440220367ff913573c50706003423b47648553730d0377c7707e505d1a4f9a08313ab6022073fad7c23abeb4ca1d48075bd276c7a5aa7b0d942ad741c9a8a4233a264f2df1014c695221026fcec918a19aad4c92527f4bad924c7cc8dfdba935418f3ce217c1c839a58b952103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f8802103d1fc482d299248b97e38d9042a068eb102818a3556d5247d080849a0880a9e2653aefdffffff05400caa3b000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df90084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d0084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d3819b1110000000022002014b288dca5d59caa8868d1668c97c971e58ab3ccf10534ac567ea51aa8aba299a86f8823000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e04800000000", + }, + { + // extra UTXO , NOT IN ORIGINAL TX + // https://mempool.space/tx/55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6#flow=&vout=3 + txid: "55d65b85255f728b120f260fa07b5fc31ad694088c9455d463eba54ed1a535e6", + vout: 3, + value: "29681900", + witnessUtxo: { + script: Buffer.from( + "002014b288dca5d59caa8868d1668c97c971e58ab3ccf10534ac567ea51aa8aba299", + "hex", + ), + value: 29681900, + }, + prevTxHex: + "010000000145a8a366f1e073e4e1a03172b95e7db2f006d710d3585e5d14a137c218bf90ed05000000fc004730440220414f5352d874fad4171326bdc563f2724eadbbe8e3199fbc83f9d6d04dc02176022072ec117a06d340209b44cef533d1a2f21c0ace64f653f454e745add290938e53014730440220367ff913573c50706003423b47648553730d0377c7707e505d1a4f9a08313ab6022073fad7c23abeb4ca1d48075bd276c7a5aa7b0d942ad741c9a8a4233a264f2df1014c695221026fcec918a19aad4c92527f4bad924c7cc8dfdba935418f3ce217c1c839a58b952103c96d495bfdd5ba4145e3e046fee45e84a8a48ad05bd8dbb395c011a32cf9f8802103d1fc482d299248b97e38d9042a068eb102818a3556d5247d080849a0880a9e2653aefdffffff05400caa3b000000002200200a618b712d918bb1ba59b737c2a37b40d557374754ef2575ce41d08d5f782df90084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d0084d71700000000220020701a8d401c84fb13e6baf169d59684e17abd9fa216c8cc5b9fc63d622ff8c58d3819b1110000000022002014b288dca5d59caa8868d1668c97c971e58ab3ccf10534ac567ea51aa8aba299a86f8823000000002200202122f4719add322f4d727f48379f8a8ba36a40ec4473fd99a2fdcfd89a16e04800000000", + }, + ], + dustThreshold: "546", + targetFeeRate: 80, // original was 60 sat/vB + scriptType: SCRIPT_TYPES.P2WSH, + requiredSigners: 2, + totalSigners: 3, + changeAddress: + "bc1qyy30guv6m5ez7ntj0ayr08u23w3k5s8vg3elmxdzlh8a3xskupyqn2lp5w", // https://mempool.space/address/bc1qyy30guv6m5ez7ntj0ayr08u23w3k5s8vg3elmxdzlh8a3xskupyqn2lp5w + expected: { + inputCount: 3, // 2 inputs + 1 extra UTXO added for fees + outputCount: 4, // 3 original + 1 change output + fee: "20880", // absolute fees of tx + expectedfee: "39840", // rationalized above + feeRate: 80, // rationalized above + }, + }, + ], + fullRbf: [ + // Mempool link: https://mempool.space/tx/544ddde5c6f3cbdbeb17bffe0d3da28025d220c5de959448c81abc2166296395 + { + case: "Create a valid full RBF transaction", + originalTx: + "02000000000102eafd0b87e19b30b6ff189c01df0593cbb86824db523cf7d59273a7095f0ce7830000000000ffffffffc99adaea9a81d557af38d10d716355b4a28cbe1a703aaefdb54e58bca8f21cb70000000000ffffffff01029c0000000000001976a914c9b40cb7a7b0efa94667365218e0f230c13f316288ac02473044022029bf4acabc50e54387f5f195bc1c33096acb3c268c6db43e0126e062e4ea5b3702200f40e116decb763f28f44904f1f29677a87e0daaaec5a6b05961d9e2fb23560e012102dc4e1a0c6ef83097f938b94af31d451ac08543e12e8da6debef598a390186dff024730440220500ec8cd1e79c977c8c2ec77b573ccd570e77adefd03402538ebad57a37663e802204db691201a4fb8fe2cbd553837875d31afad0102e34e349975034ed0fe23594b012102dc4e1a0c6ef83097f938b94af31d451ac08543e12e8da6debef598a390186dff00000000", + network: Network.MAINNET, + inputs: [ + { + txid: "83e70c5f09a77392d5f73c52db2468b8cb9305df019c18ffb6309be1870bfdea", + vout: 0, + value: "20688", + }, + { + txid: "b71cf2a8bc584eb5fdae3a701abe8ca2b45563710dd138af57d5819aeada9ac9", + vout: 0, + value: "20155", + }, + ], + availableUtxos: [ + { + // https://mempool.space/tx/83e70c5f09a77392d5f73c52db2468b8cb9305df019c18ffb6309be1870bfdea#flow=&vout=0 + txid: "83e70c5f09a77392d5f73c52db2468b8cb9305df019c18ffb6309be1870bfdea", + vout: 0, + value: "20688", + witnessUtxo: { + script: Buffer.from( + "001481f45ed53f6b1f8b7b3f95fb1a84c69a13292c8f", + "hex", + ), + value: 20688, + }, + prevTxHex: + "0200000000010147e243e98a006c0bdc369912c899819d15cd95f3c58156fe1095e42155fb6f770100000000fdffffff02c39902000000000016001443ae9d3e5ffac83b8df0512b61d0fda041be2b41f8ee1b000000000017a9148965f77e0e88df82a5ac8406dc8b98d102295411870247304402200ea276530bc577be36a20da72fb7eb5965cfdb13db6328bcc1f5c3a5f9a99ff3022059fe09156729d620b78a2ea13debec73461ab76260c9b9afe45b163b23f6aad901210335ec3d15bb90b22c11d2ef5010a84dfa8323c85d1c652fceba15d25e5ed63443431b0d00", + }, + + // Additional UTXO for potential fee increase + { + // extra UTXO (dummy) + // https://mempool.space/tx/6bec655fc52d4e0b6930321e016055e6cc87f170916d4c4ef5a030fad41a8845 + txid: "6bec655fc52d4e0b6930321e016055e6cc87f170916d4c4ef5a030fad41a8845", + vout: 0, + value: "316200", + witnessUtxo: { + script: Buffer.from( + "0014f98f41f4f2f231524a72a9994e9b996b381473f2", + "hex", + ), + value: 316200, + }, + prevTxHex: + "020000000001014b653447f713b3eed5db67f18288e4f6f7791f0a75e9ec303432a9f77a3fc5c30000000023220020ba9745bcea3477666c9b0f45612922fdc722bd17fcd1f4bbbf75bad2fda96d3afdffffff0228d3040000000000160014f98f41f4f2f231524a72a9994e9b996b381473f2e23599000000000017a9148e4bd93ba6fa094bdd98305c51fa9bd4d8878b1f87040047304402204d4cf3e606ab2533af37065c2827d878d05a92aa51c3bb9f6a8da0afa91a0a6d02200d02d6db146adf7bf253ab0325293cfb14469c7e49ba49678955550d0df16e2f01473044022015b29bb7e3cf3fc5feeef71494703b2446e144b8817101afc897cfe6d2c73ca6022042eb0bf6f384ddc1eeae394872f4d9659a3299bb8a37e52ea0b7b585868b3a80014752210303b89234c6487d64450b96e2df0e56c92e6ac0519f1efe1c965a175f5c112920210389ad75f5eb174ebd8b58ae87ea774bdd613131bdad23ccd4ee1e73cdf7cfb4b052aed6280d00", + }, + ] as UTXO[], + dustThreshold: "546", + targetFeeRate: 6, + scriptType: SCRIPT_TYPES.P2SH_P2WSH, + requiredSigners: 1, + totalSigners: 1, + changeAddress: "bc1q7y50e8culkenu3tnn66ly6gq9m43y8ymk70k8z", + fullRBF: true, + expected: { + inputCount: 2, + outputCount: 2, + fee: "905", + expectedFee: "1845", + feeRate: 6.029, + }, + }, + ], + invalidCases: [ + { + case: "Throw error when inputs are insufficient for fees", + originalTx: + "02000000000101d6e4be83f8eacd781ae5f757bb0e74a771875cf4f6c03a8c9c378c3e62bd7e7f0000000000fdffffff029c120000000000001600142dc1c9f7da43cc0205a2f2c94bd337799ac0a0c99c120000000000001600142dc1c9f7da43cc0205a2f2c94bd337799ac0a0c902483045022100aa46010e8dcaad8056964dad2b0d2fbfd9a19782d76a3f46b8bf8a5285efa79302204f340cd906a8119455b7432edfa9545cbe9b5b2178eef89f58c3da37f2b5e92e0121030b0a5094d100125de30f1526776b7e6cfc428a07711cc0985a145ae0718e2a0c00000000", + availableUtxos: [ + { + txid: "7f7ebd623e8c379c8c3ac0f6f45c8771a7740ebb57f7e51a78cdeaf883bee4d6", + vout: 0, + value: "100", + witnessUtxo: { + script: Buffer.from( + "0014dc9abb9f0536f8ce517a248da673476a48a384f3", + "hex", + ), + value: 100, + }, + }, + ], + network: Network.MAINNET, + dustThreshold: "546", + scriptType: SCRIPT_TYPES.P2SH, + requiredSigners: 1, + totalSigners: 1, + targetFeeRate: 10, + absoluteFee: "1229", + changeAddress: "bc1q7y50e8culkenu3tnn66ly6gq9m43y8ymk70k8z", + expectedError: + "Failed to create a valid accelerated RBF transaction. Ensure all inputs and outputs are valid and fee requirements are met.", + }, + ], +}; diff --git a/packages/caravan-fees/src/tests/rbf.test.ts b/packages/caravan-fees/src/tests/rbf.test.ts new file mode 100644 index 00000000..b810c9cf --- /dev/null +++ b/packages/caravan-fees/src/tests/rbf.test.ts @@ -0,0 +1,267 @@ +import { + createCancelRbfTransaction, + createAcceleratedRbfTransaction, +} from "../rbf"; +import { rbfFixtures } from "./rbf.fixtures"; +import { PsbtV2 } from "@caravan/psbt"; +import BigNumber from "bignumber.js"; +import { + calculateTotalInputValue, + calculateTotalOutputValue, + estimateTransactionVsize, +} from "../utils"; + +describe("RBF Transaction Functions", () => { + describe("createCancelRbfTransaction", () => { + rbfFixtures.cancelRbf.forEach((fixture) => { + it(fixture.case, () => { + const cancelPsbt = createCancelRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + cancelAddress: fixture.cancelAddress, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.expected.fee, + fullRBF: false, + strict: false, + }); + + const psbt = new PsbtV2(cancelPsbt); + + expect(psbt.PSBT_GLOBAL_INPUT_COUNT).toBe(fixture.expected.inputCount); + expect(psbt.PSBT_GLOBAL_OUTPUT_COUNT).toBe( + fixture.expected.outputCount, + ); + + const totalInputAmount = calculateTotalInputValue(psbt); + const totalOutputAmount = calculateTotalOutputValue(psbt); + const fee = totalInputAmount.minus(totalOutputAmount); + expect(fee.toString()).toBe(fixture.expected.expectedfee); + + const feeRate = fee.dividedBy( + estimateTransactionVsize({ + addressType: fixture.scriptType, + numInputs: psbt.PSBT_GLOBAL_INPUT_COUNT, + numOutputs: psbt.PSBT_GLOBAL_OUTPUT_COUNT, + m: fixture.requiredSigners, + n: fixture.totalSigners, + }), + ); + expect(feeRate.toNumber()).toBeCloseTo(fixture.expected.feeRate, 2); + }); + }); + }); + + describe("createAcceleratedRbfTransaction", () => { + rbfFixtures.acceleratedRbf.forEach((fixture) => { + it(fixture.case, () => { + const acceleratedPsbt = createAcceleratedRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.expected.fee, + changeAddress: fixture.changeAddress, + }); + + const psbt = new PsbtV2(acceleratedPsbt); + + // Add your assertions here based on the fixture's expected values + expect(psbt.PSBT_GLOBAL_INPUT_COUNT).toBe(fixture.expected.inputCount); + expect(psbt.PSBT_GLOBAL_OUTPUT_COUNT).toBe( + fixture.expected.outputCount, + ); + + const totalInputAmount = calculateTotalInputValue(psbt); + const totalOutputAmount = calculateTotalOutputValue(psbt); + const fee = totalInputAmount.minus(totalOutputAmount); + expect(fee.toString()).toBe(fixture.expected.expectedfee); + + const feeRate = fee.dividedBy( + estimateTransactionVsize({ + addressType: fixture.scriptType, + numInputs: psbt.PSBT_GLOBAL_INPUT_COUNT, + numOutputs: psbt.PSBT_GLOBAL_OUTPUT_COUNT, + m: fixture.requiredSigners, + n: fixture.totalSigners, + }), + ); + expect(feeRate.toNumber()).toBeCloseTo(fixture.expected.feeRate, 2); + }); + }); + }); + + describe("Full RBF Transaction", () => { + rbfFixtures.fullRbf.forEach((fixture) => { + it(fixture.case, () => { + const fullRbfPsbt = createAcceleratedRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.expected.fee, + changeAddress: fixture.changeAddress, + fullRBF: fixture.fullRBF, + }); + + const psbt = new PsbtV2(fullRbfPsbt); + + // Add your assertions here based on the fixture's expected values + expect(psbt.PSBT_GLOBAL_INPUT_COUNT).toBe(fixture.expected.inputCount); + expect(psbt.PSBT_GLOBAL_OUTPUT_COUNT).toBe( + fixture.expected.outputCount, + ); + + const totalInputAmount = calculateTotalInputValue(psbt); + const totalOutputAmount = calculateTotalOutputValue(psbt); + const fee = totalInputAmount.minus(totalOutputAmount); + expect(fee.toString()).toBe(fixture.expected.expectedFee); + + const feeRate = fee.dividedBy( + estimateTransactionVsize({ + addressType: fixture.scriptType, + numInputs: psbt.PSBT_GLOBAL_INPUT_COUNT, + numOutputs: psbt.PSBT_GLOBAL_OUTPUT_COUNT, + m: fixture.requiredSigners, + n: fixture.totalSigners, + }), + ); + expect(feeRate.toNumber()).toBeCloseTo(fixture.expected.feeRate, 2); + }); + }); + }); + describe("Invalid Cases", () => { + rbfFixtures.invalidCases.forEach((fixture) => { + it(fixture.case, () => { + expect(() => { + if (fixture.case.includes("cancel")) { + createCancelRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + cancelAddress: fixture.changeAddress, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.absoluteFee, + }); + } else { + createAcceleratedRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.absoluteFee, + changeAddress: fixture.changeAddress, + }); + } + }).toThrow(); + }); + }); + }); + + describe("Edge Cases", () => { + it("should handle dust threshold correctly", () => { + const fixture = rbfFixtures.cancelRbf[0]; // Use the first cancel RBF fixture + const highDustThreshold = "100000"; // Unrealistically high dust threshold + + const cancelPsbt = createCancelRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + cancelAddress: fixture.cancelAddress, + network: fixture.network, + dustThreshold: highDustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.expected.fee, + fullRBF: false, + }); + + const psbt = new PsbtV2(cancelPsbt); + + // Ensure that even with high dust threshold, we still have an output + expect(psbt.PSBT_GLOBAL_OUTPUT_COUNT).toBe(1); + + const outputAmount = new BigNumber(psbt.PSBT_OUT_AMOUNT[0].toString()); + expect(outputAmount.isGreaterThan(highDustThreshold)).toBe(true); + }); + + it("should handle very high fee rates", () => { + const fixture = rbfFixtures.acceleratedRbf[0]; // Use the first accelerated RBF fixture + const highFeeRate = 100; // 100 sat/vbyte + + const acceleratedPsbt = createAcceleratedRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: fixture.availableUtxos, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: highFeeRate, + absoluteFee: new BigNumber(fixture.expected.fee).toString(), + changeAddress: fixture.changeAddress, + }); + + const psbt = new PsbtV2(acceleratedPsbt); + + const totalInputAmount = calculateTotalInputValue(psbt); + const totalOutputAmount = calculateTotalOutputValue(psbt); + const fee = totalInputAmount.minus(totalOutputAmount); + const feeRate = fee.dividedBy( + estimateTransactionVsize({ + addressType: fixture.scriptType, + numInputs: psbt.PSBT_GLOBAL_INPUT_COUNT, + numOutputs: psbt.PSBT_GLOBAL_OUTPUT_COUNT, + m: fixture.requiredSigners, + n: fixture.totalSigners, + }), + ); + + expect(feeRate.isGreaterThanOrEqualTo(highFeeRate)).toBe(true); + }); + + it("should handle transactions with maximum number of inputs", () => { + const fixture = rbfFixtures.cancelRbf[0]; // Use the first cancel RBF fixture + const maxInputs = Array(10000).fill(fixture.availableUtxos[0]); + + const cancelPsbt = createCancelRbfTransaction({ + originalTx: fixture.originalTx, + availableInputs: maxInputs, + cancelAddress: fixture.cancelAddress, + network: fixture.network, + dustThreshold: fixture.dustThreshold, + scriptType: fixture.scriptType, + requiredSigners: fixture.requiredSigners, + totalSigners: fixture.totalSigners, + targetFeeRate: fixture.targetFeeRate, + absoluteFee: fixture.expected.fee, + }); + + const psbt = new PsbtV2(cancelPsbt); + + expect(psbt.PSBT_GLOBAL_INPUT_COUNT).toBeLessThanOrEqual(10000); + expect(psbt.PSBT_GLOBAL_OUTPUT_COUNT).toBe(1); + }); + }); +}); diff --git a/packages/caravan-fees/src/tests/transactionAnalyzer.fixtures.ts b/packages/caravan-fees/src/tests/transactionAnalyzer.fixtures.ts new file mode 100644 index 00000000..0a9e49af --- /dev/null +++ b/packages/caravan-fees/src/tests/transactionAnalyzer.fixtures.ts @@ -0,0 +1,186 @@ +import { Network } from "@caravan/bitcoin"; +import { FeeBumpStrategy } from "../types"; + +export const transactionAnalyzerFixtures = { + validTransactions: [ + { + // https://mempool.space/tx/6446c234ef6e2c77b82c97792940f961d478e4674f7408815618a9fda277c5fb + case: "RBF signaled transaction", + txHex: + "02000000000101f9df8cfa6cb54d8bd0f44d4d2b665a6ab11a0c61f08d5525bb9017eb3a83bf270000000000fdffffff02fa90000000000000160014c3f1f018c993180e667d6fcc51af0036a38a97290000000000000000076a5d04140114000247304402202c001eb4ddeabac07acf3ff62c3eb5c4eff39353eefdd5d410a17ed0105880c602207aec9c17866327ed8a37d3a089b70cfda040b95ee0218ffd240e200415b8d33b0121027e18b1eab7be5d378ab0c9a1ab1f738b75350dbba59f95ae91c8d3094cdf495500000000", + options: { + absoluteFee: "327", + targetFeeRate: 1, + network: Network.MAINNET, + dustThreshold: 546, + changeOutputIndex: 1, + availableUtxos: [ + { + txid: "27bf833aeb1790bb25558df0610c1ab16a5a662b4d4df4d08b4db56cfa8cdff9", + prevTxHex: + "02000000000101ffb300f1869eb6a374d02e489cb5d850bd06739955d3d08695489b8c7a3f905c0000000000fdffffff024192000000000000160014c3f1f018c993180e667d6fcc51af0036a38a97290000000000000000076a5d041401140002483045022100cc953354d643842c43571443a8ea766677296d96770e720e10c3a29ce0109379022019e4c746c0145cc575f40b78ee7fe1f1f725d960ccdc43940eea75101e84424f0121027e18b1eab7be5d378ab0c9a1ab1f738b75350dbba59f95ae91c8d3094cdf495500000000", + vout: 0, + value: "37441", // 0.00037441 BTC in satoshis + witnessUtxo: { + script: Buffer.from( + "0014c3f1f018c993180e667d6fcc51af0036a38a9729", + "hex", + ), + value: 37441, + }, + }, + ], + }, + expected: { + txid: "6446c234ef6e2c77b82c97792940f961d478e4674f7408815618a9fda277c5fb", + canRBF: true, + canCPFP: true, + inputCount: 1, + outputCount: 2, + vsize: 125.25, + weight: 501, + fee: "327", + feeRate: "2.61", + recommendedStrategy: FeeBumpStrategy.NONE, + estimatedRBFFee: 453, // 327 + 1*125.25 + estimatedCPFPFee: 73, + inputSequences: [4294967293], + outputValues: [37114, 0], + }, + }, + { + // https://mempool.space/tx/b47dd2f885a4583e9f47b7b65fb2df8feeb0254e101574ee0407e52ccc5b66ea + case: "Non-RBF signaled transaction", + txHex: + "020000000001010014cd0d501821b405ac62fea4148ed2233525e26cf297d5348b6f32af6f61bd0100000000ffffffff0265410000000000001600143c840220065e205fcab60328c86539235bfce3149504080000000000160014859d1dd47c63a308eff92006133c403ab3a760ff024830450221008895a7f75aa08dbc6fcf200be9addf688adb65934f4881799cea7b21a2f179b502207b7cd6fd931165057ce69421b25bf71f54db8202bc5a94ac100bcb3418d8d268012102b16f2b081b8ba89d5e30933da5f9a9bc8813d7612a14fba68be7b9cc725a4d5600000000", + options: { + absoluteFee: "482", + targetFeeRate: 5, + network: Network.MAINNET, + dustThreshold: 546, + changeOutputIndex: 1, + availableUtxos: [ + { + txid: "bd616faf326f8b34d597f26ce2253523d28e14a4fe62ac05b42118500dcd1400", + vout: 1, + value: "542684", // 0.00542684 BTC in satoshis + witnessUtxo: { + script: Buffer.from( + "0014859d1dd47c63a308eff92006133c403ab3a760ff", + "hex", + ), + value: 542684, + }, + }, + ], + }, + expected: { + txid: "b47dd2f885a4583e9f47b7b65fb2df8feeb0254e101574ee0407e52ccc5b66ea", + canRBF: false, + canCPFP: true, + inputCount: 1, + outputCount: 2, + vsize: 140.5, + weight: 562, + fee: "482", + feeRate: "3.43", + recommendedStrategy: FeeBumpStrategy.CPFP, + estimatedRBFFee: 623, // 482 + 1*140.5 = 622.5(minimum RBF for bumping , 1 sats/vbyte incremental fee) + estimatedCPFPFee: 2303, + inputSequences: [4294967295], + outputValues: [16741, 525461], + }, + }, + { + // https://mempool.space/tx/78d249155c3d1a70353192a86d4770a2fbaa6ca2c07a0a6c7b0c3951c649d462 + case: "Both RBF and CPFP possible", + txHex: + "020000000001013d8c8999e448c6593d7567534f36cd29d720931d2babc95fdd8074c7619f994d0200000000fdffffff0849a4000000000000160014e652c05df205783da3c52389297b735cbef809c7a3a8000000000000160014c8566e21fb0856d80619c5da034ac4feddc613d08abe000000000000160014539422c6b02c4f8ebd867ddefddc8fe1b826e80468bf000000000000160014dbe8fcf64788f7c0b3ccba91e164d5460648e7b190e20000000000001600148b90d42b9e07d2374325889126fa1ab0abd6e8a890e2000000000000160014f48f05c1c21cec9f26421bff7cb4e4ad0313fe19c54c020000000000160014ddd7bf2a9b1403fc7f6ab758057b3c6ad522af030d11b00000000000160014690b6f1113ddcad9eb2c931ad53b25cdfa756c24024730440220728d7f4df67f6f1fb0742bcd0a23b1604d561d8f9525ec85310a4f375a03100602204792910102db2001ed3bde8145e63dcae90e5aeef9bc16c21c4597baf3fba3ea012103f38818a20f1b81ee305b7e6dd9e0f76623a95c79ea17673a46dcfa558cddfe23a71a0d00", + options: { + absoluteFee: "2289", + targetFeeRate: 15, + network: Network.MAINNET, + dustThreshold: 546, + changeOutputIndex: 7, + availableUtxos: [ + { + txid: "4d999f61c77480dd5fc9ab2b1d9320d729cd364f5367753d59c648e499898c3d", + vout: 2, + value: "11990721", // 0.11990721 BTC in satoshis + prevTxHex: + "02000000000101a443287510d866aacc0638693f75e677020d35a9593d8713d8b8da56c8dbd1860400000000fdffffff03f1c00000000000001600141bf5e314ac13e0d8bb96182e8f5104e9ccff0ba87c98010000000000160014957e78098f25333734498c7bb15a93a982c909f3c1f6b60000000000160014e3bc70ab68cbb50ca0a351f98d80ce7723b7f1250247304402206d5055463225dc6b0c8d57dbf551ac77a364b836e24cdbef283d4a33a286a9810220379f14e13ef9660f3ce6f50bf53025f5244bbb1d8e9973cc689f66efd174f0ab012102a9fd420c6f4afa4b4385ba35606f47233f9f37a13d97084e1b59aff64884223b941a0d00", + witnessUtxo: { + script: Buffer.from( + "0014e3bc70ab68cbb50ca0a351f98d80ce7723b7f125", + "hex", + ), + value: 11990721, + }, + }, + ], + }, + expected: { + txid: "78d249155c3d1a70353192a86d4770a2fbaa6ca2c07a0a6c7b0c3951c649d462", + canRBF: true, + canCPFP: true, + inputCount: 1, + outputCount: 8, + vsize: 326.25, + weight: 1305, + fee: "2289", + feeRate: "7.02", + recommendedStrategy: FeeBumpStrategy.RBF, + estimatedRBFFee: 2616, // 2289 + 1 * 326.25 + estimatedCPFPFee: 14692, + inputSequences: [4294967293], + outputValues: [ + 42057, 43171, 48778, 49000, 58000, 58000, 150725, 11538701, + ], + }, + }, + // ...TO DO (MRIGESH): add other valid transaction cases , based on suggestions received ... + ], + invalidTransactions: [ + { + case: "Transaction with no inputs", + txHex: "0200000000010000000000000000096a07546573744f505400000000", + options: { + absoluteFee: "0", + targetFeeRate: 1, + network: Network.MAINNET, + dustThreshold: 546, + changeOutputIndex: 0, // random as it would fail anyways + availableUtxos: [], + }, + expectedError: "Transaction has no inputs", + }, + { + case: "Transaction with no outputs", + txHex: + "0200000001f9df8cfa6cb54d8bd0f44d4d2b665a6ab11a0c61f08d5525bb9017eb3a83bf270000000000fdffffff0000000000", + options: { + absoluteFee: "51", + targetFeeRate: 1, + network: Network.MAINNET, + dustThreshold: 546, + changeOutputIndex: 0, // random as it would fail anyways + availableUtxos: [], + }, + expectedError: "Transaction has no outputs", + }, + { + case: "Invalid transaction hex", + txHex: + "0100000002a4814fd0c260334875985613f95b012d9514a6f1d2979b29e0ada7f4f1c5987c010000006b483045022100af590e92332d1a28fd1635cfd86683843daafe875ece517061251844ba92788f022038510d3326532f9c525e298c550daddb2bfc52e34c735e541c96c0cf9e2e14200121021f097756ba020e8ba72f6bcde18dd757b9235b6f613fd4cc56fecd1caefc7a44ffffffff4a69a65d45163278be854789839e57bf2800e52a5a17f859a8236baace57695f000000006b48304502210094dfa2f4ebe267bc76e889ffac833f6e059781020a65e034dd174e74ef7d7ddb022009b245e4e20f44125859627ebc51f8d08e9f600b93a03d30ca8501e9e78f9d3801210290962152a37b473065ff2e8447733da18bfc13938d0cd5bd816154b5b52908d7ffffffff01010000000000000000000000", + options: { + absoluteFee: "100", + targetFeeRate: 1, + network: Network.MAINNET, + dustThreshold: 546, + changeOutputIndex: 0, // random as it would fail anyways + availableUtxos: [], + }, + expectedError: "Invalid transaction hex", + }, + ], +}; diff --git a/packages/caravan-fees/src/tests/transactionAnalyzer.test.ts b/packages/caravan-fees/src/tests/transactionAnalyzer.test.ts new file mode 100644 index 00000000..9fa4cea2 --- /dev/null +++ b/packages/caravan-fees/src/tests/transactionAnalyzer.test.ts @@ -0,0 +1,88 @@ +import { TransactionAnalyzer } from "../transactionAnalyzer"; +import { transactionAnalyzerFixtures } from "./transactionAnalyzer.fixtures"; +import BigNumber from "bignumber.js"; + +describe("TransactionAnalyzer", () => { + transactionAnalyzerFixtures.validTransactions.forEach((fixture) => { + test(`Valid Transaction: ${fixture.case}`, () => { + const analyzer = new TransactionAnalyzer({ + txHex: fixture.txHex, + ...fixture.options, + requiredSigners: 1, + totalSigners: 2, + addressType: "P2SH", + }); + + expect(analyzer.txid).toBe(fixture.expected.txid); + expect( + Math.abs(analyzer.vsize - fixture.expected.vsize), + ).toBeLessThanOrEqual(1); + expect(analyzer.weight).toBe(fixture.expected.weight); + expect(analyzer.inputs.length).toBe(fixture.expected.inputCount); + expect(analyzer.outputs.length).toBe(fixture.expected.outputCount); + expect(analyzer.fee).toBe(fixture.expected.fee); + const feeRate = new BigNumber(analyzer.feeRate); + const expectedFeeRate = new BigNumber(fixture.expected.feeRate); + const allowedFeeRateError = new BigNumber("0.1"); // 0.1 sat/vbyte + + expect( + feeRate + .minus(expectedFeeRate) + .abs() + .isLessThanOrEqualTo(allowedFeeRateError), + ).toBe(true); + + expect(analyzer.canRBF).toBe(fixture.expected.canRBF); + expect(analyzer.canCPFP).toBe(fixture.expected.canCPFP); + expect(analyzer.recommendedStrategy).toBe( + fixture.expected.recommendedStrategy, + ); + + const feeTolerance = 15; // Allowable tolerance of up to 5 sats + + const minimumRBFFee = Number(analyzer.minimumRBFFee); + const minimumCPFPFee = Number(analyzer.minimumCPFPFee); + + // Ensure the minimum RBF fee is greater than or equal to the estimated RBF fee + expect(minimumRBFFee).toBeGreaterThanOrEqual( + fixture.expected.estimatedRBFFee, + ); + + // Ensure the minimum RBF fee is not more than 5 sats above the estimated RBF fee + expect(minimumRBFFee).toBeLessThanOrEqual( + fixture.expected.estimatedRBFFee + feeTolerance, + ); + + // Ensure the minimum CPFP fee is greater than or equal to the estimated CPFP fee + expect(minimumCPFPFee).toBeGreaterThanOrEqual( + fixture.expected.estimatedCPFPFee, + ); + + // Ensure the minimum CPFP fee is not more than 5 sats above the estimated CPFP fee + expect(minimumCPFPFee).toBeLessThanOrEqual( + fixture.expected.estimatedCPFPFee + feeTolerance, + ); + + expect(analyzer.inputs.map((input) => input.sequence)).toEqual( + fixture.expected.inputSequences, + ); + expect(analyzer.outputs.map((output) => output.amountSats)).toEqual( + fixture.expected.outputValues.map(String), + ); + }); + }); + + transactionAnalyzerFixtures.invalidTransactions.forEach((fixture) => { + test(`Invalid Transaction: ${fixture.case}`, () => { + expect(() => { + new TransactionAnalyzer({ + txHex: fixture.txHex, + ...fixture.options, + requiredSigners: 1, + totalSigners: 2, + addressType: "P2SH", + }); + }).toThrow(); + }); + }); +}); diff --git a/packages/caravan-fees/src/transactionAnalyzer.ts b/packages/caravan-fees/src/transactionAnalyzer.ts new file mode 100644 index 00000000..72bdc8d2 --- /dev/null +++ b/packages/caravan-fees/src/transactionAnalyzer.ts @@ -0,0 +1,706 @@ +import { Transaction } from "bitcoinjs-lib-v6"; +import { Network } from "@caravan/bitcoin"; +import { + UTXO, + AnalyzerOptions, + FeeBumpStrategy, + Satoshis, + ScriptType, + SCRIPT_TYPES, + FeeRateSatsPerVByte, + TxAnalysis, +} from "./types"; +import { + BtcTxComponent, + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "./btcTransactionComponents"; +import { getOutputAddress, estimateTransactionVsize } from "./utils"; +import BigNumber from "bignumber.js"; + +// added type for validation of Analyzer Options +interface ValidatedAnalyzerOptions { + rawTx: Transaction; + network: Network; + targetFeeRate: FeeRateSatsPerVByte; + absoluteFee: BigNumber; + availableUtxos: UTXO[]; + changeOutputIndex: number | undefined; + incrementalRelayFeeRate: BigNumber; + requiredSigners: number; + totalSigners: number; + addressType: ScriptType; +} + +/** + * TransactionAnalyzer Class + * + * This class provides comprehensive analysis of Bitcoin transactions, including + * fee estimation, RBF (Replace-By-Fee) and CPFP (Child-Pays-For-Parent) capabilities. + * It's designed to help wallet developers make informed decisions about fee bumping + * strategies for unconfirmed transactions. + * + * Key Features: + * - Analyzes transaction inputs, outputs, fees, and size + * - Determines RBF and CPFP eligibility + * - Recommends optimal fee bumping strategy + * - Estimates fees for RBF and CPFP operations + * - Provides detailed transaction information for wallet integration + * + * Usage: + * const analyzer = new TransactionAnalyzer({txHex,...other-options}); + * const analysis = analyzer.analyze(); + * + * @class + */ +export class TransactionAnalyzer { + private readonly _rawTx: Transaction; + private readonly _network: Network; + private readonly _targetFeeRate: number; + private readonly _absoluteFee: BigNumber; + private readonly _availableUtxos: UTXO[]; + private readonly _changeOutputIndex: number | undefined; + private readonly _incrementalRelayFeeRate: BigNumber; + private readonly _requiredSigners: number; + private readonly _totalSigners: number; + private readonly _addressType: ScriptType; + + private _canRBF: boolean | null = null; + private _canCPFP: boolean | null = null; + private _assumeRBF: boolean = false; + + /** + * Creates an instance of TransactionAnalyzer. + * @param {AnalyzerOptions} options - Configuration options for the analyzer + * @throws {Error} If the transaction is invalid or lacks inputs/outputs + */ + constructor(options: AnalyzerOptions) { + const validatedOptions = TransactionAnalyzer.validateOptions(options); + + this._rawTx = validatedOptions.rawTx; + this._network = validatedOptions.network; + this._targetFeeRate = validatedOptions.targetFeeRate; + this._absoluteFee = validatedOptions.absoluteFee; + this._availableUtxos = validatedOptions.availableUtxos; + + // TO DO (MRIGESH) + // Make this and accelerate RBF fn work with an array of change indices + this._changeOutputIndex = validatedOptions.changeOutputIndex; + this._incrementalRelayFeeRate = validatedOptions.incrementalRelayFeeRate; + this._requiredSigners = validatedOptions.requiredSigners; + this._totalSigners = validatedOptions.totalSigners; + this._addressType = validatedOptions.addressType; + } + + /** + * Gets the transaction ID (txid) of the analyzed transaction. + * @returns {string} The transaction ID + */ + get txid(): string { + return this._rawTx.getId(); + } + + /** + * Gets the virtual size (vsize) of the transaction in virtual bytes. + * Note: This uses bitcoinjs-lib's implementation which applies Math.ceil() + * for segwit transactions, potentially slightly overestimating the vsize. + * This is generally acceptable, especially for fee bumping scenarios. + * @returns {number} The virtual size of the transaction + */ + get vsize(): number { + return this._rawTx.virtualSize(); + } + + /** + * Gets the weight of the transaction in weight units. + * @returns {number} The weight of the transaction + */ + get weight(): number { + return this._rawTx.weight(); + } + + /** + * Gets the deserialized inputs of the transaction. + * @returns {BtcTxInputTemplate[]} An array of transaction inputs + */ + get inputs(): BtcTxInputTemplate[] { + return this.deserializeInputs(); + } + + /** + * Gets the deserialized outputs of the transaction. + * @returns {BtcTxOutputTemplate[]} An array of transaction outputs + */ + get outputs(): BtcTxOutputTemplate[] { + return this.deserializeOutputs(); + } + + /** + * Calculates and returns the fee of the transaction in satoshis. + * @returns {Satoshis} The transaction fee in satoshis + */ + get fee(): Satoshis { + return this._absoluteFee.toString(); + } + + /** + * Calculates and returns the fee rate of the transaction in satoshis per vbyte. + * @returns {FeeRateSatsPerVByte} The transaction fee rate in satoshis per vbyte + */ + get feeRate(): FeeRateSatsPerVByte { + return this._absoluteFee.dividedBy(this.vsize).toNumber(); + } + + /** + * Gets whether RBF is assumed to be always possible, regardless of signaling. + * @returns {boolean} True if RBF is assumed to be always possible, false otherwise + */ + get assumeRBF(): boolean { + return this._assumeRBF; + } + + /** + * Sets whether to assume RBF is always possible, regardless of signaling. + * @param {boolean} value - Whether to assume RBF is always possible + */ + set assumeRBF(value: boolean) { + this._assumeRBF = value; + if (value && !this.isRBFSignaled()) { + console.warn( + "Assuming full RBF is possible, but transaction does not signal RBF. " + + "This may cause issues with some nodes or services and could lead to " + + "delayed or failed transaction replacement.", + ); + } + } + + /** + * Checks if RBF (Replace-By-Fee) can be performed on this transaction. + * + * RBF allows unconfirmed transactions to be replaced with a new version + * that pays a higher fee. There are two types of RBF: + * + * 1. Signaled RBF (BIP125): At least one input has a sequence number < 0xfffffffe. + * 2. Full RBF: Replacing any unconfirmed transaction, regardless of signaling. + * + * This method determines if RBF is possible based on three criteria: + * 1. The transaction signals RBF (or full RBF is assumed). + * 2. The wallet controls at least one input of the transaction. + * 3. The necessary input is available in the wallet's UTXO set. + * + * It uses the transaction's input templates and compares them against the available UTXOs + * to ensure that the wallet has control over at least one input, which is necessary for RBF. + * + * + * While BIP125 defines the standard for signaled RBF, some nodes and miners + * may accept full RBF, allowing replacement of any unconfirmed transaction. + * + * CAUTION: Assuming full RBF when a transaction doesn't signal it may lead to: + * - Rejected replacements by nodes not accepting full RBF + * - Delayed or failed transaction replacement + * - Potential double-spend risks if recipients accept unconfirmed transactions + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + * @see https://bitcoinops.org/en/topics/replace-by-fee/ + * + * @returns {boolean} True if RBF can be performed (signaled or assumed), false otherwise + * + */ + get canRBF(): boolean { + const signaled = this.isRBFSignaled(); + if (this._assumeRBF && !signaled) { + console.warn( + "Assuming RBF is possible, but transaction does not signal RBF. This may cause issues with some nodes or services.", + ); + } + + // Check if RBF is signaled or assumed + if (!(signaled || this._assumeRBF)) { + return false; + } + + // Get input templates from the transaction + const inputTemplates = this.getInputTemplates(); + + // Check if any of the transaction's inputs are in the available UTXOs + const hasControlledInput = this._availableUtxos.some((utxo) => + inputTemplates.some( + (template) => + template.txid === utxo.txid && template.vout === utxo.vout, + ), + ); + + // RBF is only possible if the wallet controls at least one input + return hasControlledInput; + } + + /** + * Check if Child-Pays-for-Parent (CPFP) is possible for the transaction. + * @returns {boolean} True if CPFP is possible, false otherwise. + */ + get canCPFP(): boolean { + return this.canPerformCPFP(); + } + + /** + * Recommends the optimal fee bumping strategy based on the current transaction state. + * @returns {FeeBumpStrategy} The recommended fee bumping strategy + */ + get recommendedStrategy(): FeeBumpStrategy { + return this.recommendStrategy(); + } + + /** + * Gets the list of available UTXOs for potential use in fee bumping. + * @returns {UTXO[]} An array of available UTXOs + */ + get availableUTXOs(): UTXO[] { + return this._availableUtxos; + } + + /** + * Gets the current target fee rate in satoshis per vbyte. + * @returns {FeeRateSatsPerVByte } The target fee rate in satoshis per vbyte. + */ + get targetFeeRate(): FeeRateSatsPerVByte { + return this._targetFeeRate; + } + + /** + * Calculates and returns the fee rate required for a successful CPFP. + * @returns {string} The CPFP fee rate in satoshis per vbyte + */ + get cpfpFeeRate(): string { + const desiredPackageFee = new BigNumber(this.targetFeeRate).multipliedBy( + this.CPFPPackageSize, + ); + const expectedFeeRate = BigNumber.max( + desiredPackageFee.minus(this.fee).dividedBy(this.estimatedCPFPChildSize), + new BigNumber(0), + ); + + return expectedFeeRate.toString(); + } + + /** + * Calculates the minimum total fee required for a valid RBF (Replace-By-Fee) replacement transaction. + * + * This method determines the minimum fee needed to replace the current transaction + * using the RBF protocol, as defined in BIP 125. It considers two key factors: + * 1. The current transaction fee + * 2. The minimum required fee increase (incremental relay fee * transaction size) + * + * Key considerations: + * - BIP 125 Rule 4: Replacement must pay for its own bandwidth at minimum relay fee + * This rule doesn't explicitly consider fee rates, focusing on anti-DDoS protection. + * - Modern mining preferences favor higher fee rates over absolute fees. + * + * The calculation ensures that the new transaction meets the minimum fee increase + * required by the RBF rules, which is: + * minimum_fee = original_fee + (incremental_relay_fee * transaction_size) + * + * + * + * References: + * - BIP 125 (RBF): https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + * - Bitcoin Core RBF implementation: https://github.com/bitcoin/bitcoin/blob/master/src/policy/rbf.cpp + * - RBF discussion (2018): https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2018-February/015724.html + * - One-Shot Replace-By-Fee-Rate proposal: https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2024-January/022298.html + * + * @returns {Satoshis} The minimum total fee required for the replacement transaction in satoshis. + * This is always at least the current fee plus the minimum required increase. + * @note This getter does not consider the user's target fee rate. It's the responsibility + * of the RBF function to ensure that the new transaction's fee is the maximum of + * this minimum fee and the fee calculated using the user's target fee rate. + */ + get minimumRBFFee(): Satoshis { + return new BigNumber(this.fee) + .plus(this._incrementalRelayFeeRate.multipliedBy(this.vsize)) + .integerValue(BigNumber.ROUND_CEIL) + .toString(); + } + + /** + * Calculates the minimum total fee required for a successful CPFP (Child-Pays-For-Parent) operation. + * + * This method calculates the fee needed for a child transaction to boost the + * fee rate of the current (parent) transaction using the CPFP technique. It considers: + * 1. The current transaction's size and fee + * 2. An estimated size for a simple child transaction (1 input, 1 output) + * 3. The target fee rate for the combined package (parent + child) + * + * The calculation aims to determine how much additional fee the child transaction + * needs to contribute to bring the overall package fee rate up to the target. + * + * Assumptions: + * - The child transaction will have 1 input (spending an output from this transaction) + * - The child transaction will have 1 output (change back to the user's wallet) + * - The multisig configuration (m-of-n) is the same as the parent transaction + * + * References: + * - Bitcoin Core CPFP implementation: + * https://github.com/bitcoin/bitcoin/blob/master/src/policy/fees.cpp + * - CPFP overview: https://bitcoinops.org/en/topics/cpfp/ + * - Package relay for CPFP: https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki#implementation-notes + * + * @returns {Satoshis} The estimated additional CPFP fee in satoshis. + * This value represents how much extra fee the child transaction + * should include above its own minimum required fee. + * A positive value indicates the amount of additional fee required. + * A zero or negative value (rare) could indicate that the current + * transaction's fee is already sufficient for the desired rate. + */ + get minimumCPFPFee(): Satoshis { + return new BigNumber(this.cpfpFeeRate) + .multipliedBy(this.CPFPPackageSize) + .toString(); + } + + /** + * Estimates the virtual size of a potential CPFP child transaction. + * @returns {number} The estimated vsize of the child transaction in vbytes + */ + get estimatedCPFPChildSize(): number { + const config = { + addressType: this._addressType, + numInputs: 1, // Assuming 1 input for the child transaction + numOutputs: 1, // Assuming 1 output for the child transaction + m: this._requiredSigners, + n: this._totalSigners, + }; + return estimateTransactionVsize(config); + } + + /** + * Calculates the total package size for a potential CPFP transaction. + * This includes the size of the current (parent) transaction and the estimated size of the child transaction. + * @returns {number} The total package size in vbytes + */ + get CPFPPackageSize(): number { + return this.vsize + this.estimatedCPFPChildSize; + } + + /** + * Performs a comprehensive analysis of the Bitcoin transaction. + * + * This method aggregates various metrics and properties of the transaction, + * including size, fees, RBF and CPFP capabilities, and the recommended + * fee bumping strategy. It utilizes internal calculations and checks + * performed by other methods of the TransactionAnalyzer class. + * + * @returns {TxAnalysis} A TxAnalysis object containing detailed information about the transaction. + * + * @property {string} txid - The transaction ID (hash) of the analyzed transaction. + * @property {number} vsize - The virtual size of the transaction in virtual bytes (vBytes). + * @property {number} weight - The weight of the transaction in weight units (WU). + * @property {Satoshis} fee - The total fee of the transaction in satoshis. + * @property {FeeRateSatsPerVByte} feeRate - The fee rate of the transaction in satoshis per virtual byte. + * @property {BtcTxInputTemplate[]} inputs - An array of the transaction's inputs. + * @property {BtcTxOutputTemplate[]} outputs - An array of the transaction's outputs. + * @property {boolean} isRBFSignaled - Indicates whether the transaction signals RBF (Replace-By-Fee). + * @property {boolean} canRBF - Indicates whether RBF is possible for this transaction. + * @property {boolean} canCPFP - Indicates whether CPFP (Child-Pays-For-Parent) is possible for this transaction. + * @property {FeeBumpStrategy} recommendedStrategy - The recommended fee bumping strategy for this transaction. + * @property {Satoshis} estimatedRBFFee - The estimated fee required for a successful RBF, in satoshis. + * @property {Satoshis} estimatedCPFPFee - The estimated fee required for a successful CPFP, in satoshis. + * + * @throws {Error} May throw an error if any of the internal calculations fail. + * + * @example + * const txAnalyzer = new TransactionAnalyzer(options); + * try { + * const analysis = txAnalyzer.analyze(); + * console.log(`Transaction ${analysis.txid} analysis:`); + * console.log(`Fee rate: ${analysis.feeRate} sat/vB`); + * console.log(`Can RBF: ${analysis.canRBF}`); + * console.log(`Can CPFP: ${analysis.canCPFP}`); + * console.log(`Recommended strategy: ${analysis.recommendedStrategy}`); + * } catch (error) { + * console.error('Analysis failed:', error); + * } + * + */ + public analyze(): TxAnalysis { + return { + txid: this.txid, + vsize: this.vsize, + weight: this.weight, + fee: this.fee, + feeRate: this.feeRate, + inputs: this.inputs, + outputs: this.outputs, + isRBFSignaled: this.isRBFSignaled(), + canRBF: this.canRBF, + canCPFP: this.canCPFP, + recommendedStrategy: this.recommendedStrategy, + estimatedRBFFee: this.minimumRBFFee, + estimatedCPFPFee: this.minimumCPFPFee, + }; + } + + /** + * Creates input templates from the transaction's inputs. + * + * This method maps each input of the analyzed transaction to a BtcTxInputTemplate. + * It extracts the transaction ID (txid) and output index (vout) from each input + * to create the templates. Note that the amount in satoshis is not included, as + * this information is not available in the raw transaction data. + * + * @returns {BtcTxInputTemplate[]} An array of BtcTxInputTemplate objects representing + * the inputs of the analyzed transaction. These templates will not have + * amounts set and will need to be populated later with data from an external + * source (e.g., bitcoind wallet, blockchain explorer, or local UTXO set). + */ + public getInputTemplates(): BtcTxInputTemplate[] { + return this.inputs.map((input) => { + return new BtcTxInputTemplate({ + txid: input.txid, + vout: input.vout, + }); + }); + } + + /** + * Creates output templates from the transaction's outputs. + * + * This method maps each output of the analyzed transaction to a BtcTxOutputTemplate. + * It extracts the recipient address, determines whether it's a change output or not, + * and includes the amount in satoshis. The output type is set to "change" if the + * output is spendable (typically indicating a change output), and "destination" otherwise. + * + * @returns {BtcTxOutputTemplate[]} An array of BtcTxOutputTemplate objects representing + * the outputs of the analyzed transaction. + */ + public getOutputTemplates(): BtcTxOutputTemplate[] { + return this.outputs.map((output, index) => { + return new BtcTxOutputTemplate({ + address: output.address, + locked: index !== this._changeOutputIndex, + amountSats: output.amountSats, + }); + }); + } + /** + * Retrieves the change output of the transaction, if it exists. + * @returns {BtcTxComponent | null} The change output or null if no change output exists + */ + public getChangeOutput(): BtcTxComponent | null { + if (this._changeOutputIndex !== undefined) { + return this.outputs[this._changeOutputIndex]; + } + return null; + } + + // Protected methods + + /** + * Deserializes and formats the transaction inputs. + * + * This method processes the raw input data from the original transaction + * and converts it into a more easily manageable format. It performs the + * following operations for each input: + * + * 1. Reverses the transaction ID (txid) from little-endian to big-endian format. + * 2. Extracts the output index (vout) being spent. + * 3. Captures the sequence number, which is used for RBF signaling. + * + * @returns {BtcTxInputTemplate[]} + * + * @protected + */ + protected deserializeInputs(): BtcTxInputTemplate[] { + return this._rawTx.ins.map((input) => { + const template = new BtcTxInputTemplate({ + txid: Buffer.from(input.hash).reverse().toString("hex"), // reversed (big-endian) format + vout: input.index, + amountSats: "0", // We don't have this information from the raw transaction + }); + + // Set sequence + template.setSequence(input.sequence); + return template; + }); + } + + /** + * Deserializes and formats the transaction outputs. + * + * This method processes the raw output data from the original transaction + * and converts it into a more easily manageable format. It performs the + * following operations for each output: + * + * 1. Extracts the output value in satoshis. + * 2. Derives the recipient address from the scriptPubKey. + * 3. Determines if the output is spendable (i.e., if it's a change output). + * + * @returns {BtcTxOutputTemplate[]} + * + * @protected + */ + protected deserializeOutputs(): BtcTxOutputTemplate[] { + return this._rawTx.outs.map((output, index) => { + return new BtcTxOutputTemplate({ + amountSats: output.value.toString(), + address: getOutputAddress(output.script, this._network), + locked: index !== this._changeOutputIndex, + }); + }); + } + + /** + * Checks if the transaction signals RBF (Replace-By-Fee). + * @returns {boolean} True if the transaction signals RBF, false otherwise + * @protected + */ + protected isRBFSignaled(): boolean { + if (!this._canRBF) { + this._canRBF = this._rawTx.ins.some( + (input) => input.sequence < 0xfffffffe, + ); + } + return this._canRBF; + } + + /** + * Determines if CPFP (Child-Pays-For-Parent) can be performed on this transaction. + * @returns {boolean} True if CPFP can be performed, false otherwise + * @protected + */ + protected canPerformCPFP(): boolean { + if (!this._canCPFP) { + this._canCPFP = this.outputs.some((output) => output.isMalleable); + } + return this._canCPFP; + } + + /** + * Recommends the optimal fee bumping strategy based on the current transaction state. + * @returns {FeeBumpStrategy} The recommended fee bumping strategy + * @protected + */ + protected recommendStrategy(): FeeBumpStrategy { + // TO DO (MRIGESH): + // Assuming a tx is non-RBF , but depends on Full-RBF and has lower fees than CPFP then we need to think of mechanism to distinguish a better strategy between the two or maybe warn the user + + if ( + new BigNumber(this.feeRate).isGreaterThanOrEqualTo(this.targetFeeRate) + ) { + return FeeBumpStrategy.NONE; + } + + const rbfFee = this.minimumRBFFee; + const cpfpFee = this.minimumCPFPFee; + + if ( + this.canRBF && + (!this.canCPFP || new BigNumber(rbfFee).isLessThan(cpfpFee)) + ) { + return FeeBumpStrategy.RBF; + } else if (this.canCPFP) { + return FeeBumpStrategy.CPFP; + } + + return FeeBumpStrategy.NONE; + } + + private static validateOptions( + options: AnalyzerOptions, + ): ValidatedAnalyzerOptions { + const validatedOptions: Partial = {}; + + // Raw transaction validation + try { + const tx = Transaction.fromHex(options.txHex); + if (tx.outs.length === 0) { + throw new Error("Transaction has no outputs"); + } + } catch (error) { + throw new Error( + `Invalid transaction: ${error instanceof Error ? error.message : String(error)}`, + ); + } + validatedOptions.rawTx = Transaction.fromHex(options.txHex); + // Network validation + if (!Object.values(Network).includes(options.network)) { + throw new Error(`Invalid network: ${options.network}`); + } + validatedOptions.network = options.network; + + // Target fee rate validation + if (options.targetFeeRate === undefined) { + throw new Error("Target fee rate is required"); + } + if ( + typeof options.targetFeeRate !== "number" || + options.targetFeeRate <= 0 + ) { + throw new Error( + `Invalid target fee rate: ${options.targetFeeRate}. Must be a positive number.`, + ); + } + validatedOptions.targetFeeRate = options.targetFeeRate; + + // Absolute fee validation + const absoluteFee = new BigNumber(options.absoluteFee); + if (absoluteFee.isLessThanOrEqualTo(0)) { + throw new Error(`Invalid absolute fee: ${options.absoluteFee}`); + } + validatedOptions.absoluteFee = absoluteFee; + + // Available UTXOs validation + if (!Array.isArray(options.availableUtxos)) { + throw new Error("Available UTXOs must be an array"); + } + validatedOptions.availableUtxos = options.availableUtxos; + + //If Change output is given then it's validation + if (options.changeOutputIndex) + if ( + options.changeOutputIndex !== undefined && + (typeof options.changeOutputIndex !== "number" || + options.changeOutputIndex < 0) + ) { + throw new Error( + `Invalid change output index: ${options.changeOutputIndex}`, + ); + } + validatedOptions.changeOutputIndex = options.changeOutputIndex; + + //If Incremental relay fee is given then it's validation + const incrementalRelayFee = new BigNumber( + options.incrementalRelayFeeRate || 1, + ); + if (incrementalRelayFee.isLessThanOrEqualTo(0)) { + throw new Error( + `Invalid incremental relay fee: ${options.incrementalRelayFeeRate}`, + ); + } + validatedOptions.incrementalRelayFeeRate = incrementalRelayFee; + + // Required and total signers validation + if ( + typeof options.requiredSigners !== "number" || + options.requiredSigners <= 0 + ) { + throw new Error(`Invalid required signers: ${options.requiredSigners}`); + } + if (typeof options.totalSigners !== "number" || options.totalSigners <= 0) { + throw new Error(`Invalid total signers: ${options.totalSigners}`); + } + if (options.requiredSigners > options.totalSigners) { + throw new Error( + `Required signers (${options.requiredSigners}) cannot be greater than total signers (${options.totalSigners})`, + ); + } + validatedOptions.requiredSigners = options.requiredSigners; + validatedOptions.totalSigners = options.totalSigners; + + // Address type validation + if (!Object.values(SCRIPT_TYPES).includes(options.addressType)) { + throw new Error(`Invalid address type: ${options.addressType}`); + } + validatedOptions.addressType = options.addressType; + + return validatedOptions as ValidatedAnalyzerOptions; + } +} diff --git a/packages/caravan-fees/src/types.ts b/packages/caravan-fees/src/types.ts new file mode 100644 index 00000000..8d652c4b --- /dev/null +++ b/packages/caravan-fees/src/types.ts @@ -0,0 +1,532 @@ +import { Network } from "@caravan/bitcoin"; +import { + BtcTxInputTemplate, + BtcTxOutputTemplate, +} from "./btcTransactionComponents"; +import { MULTISIG_ADDRESS_TYPES } from "@caravan/bitcoin"; + +/** + * Represents an Unspent Transaction Output (UTXO) with essential information for PSBT creation. + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki + */ +export interface UTXO { + /** The transaction ID of the UTXO in reversed hex format (big-endian). */ + txid: string; + + /** The output index of the UTXO in its parent transaction. */ + vout: number; + + /** The value of the UTXO in satoshis. */ + value: Satoshis; + + /** + * The full previous transaction in hexadecimal format. + * Required for non-segwit inputs in PSBTs. + */ + prevTxHex?: string; + + /** + * The witness UTXO information for segwit transactions. + * Required for segwit inputs in PSBTs. + */ + witnessUtxo?: { + script: Buffer; + value: number; + }; +} + +/** + * Configuration options for the TransactionAnalyzer. + */ +export interface AnalyzerOptions { + /** + * The Bitcoin network to use (mainnet, testnet, or regtest). + */ + network: Network; + + /** + * The target fee rate in satoshis per vbyte that the user wants to achieve. + * This is used to determine if fee bumping is necessary and to calculate + * the new fee for RBF or CPFP. + */ + targetFeeRate: number; + + /** + * The absolute fee of the original transaction in satoshis. + * This is used as the basis for fee calculations and comparisons. + */ + absoluteFee: Satoshis; + + /** + * An array of Unspent Transaction Outputs (UTXOs) that are available + * for fee bumping. These are potential inputs that can be added to + * a replacement transaction in RBF, or used to create a child transaction + * in CPFP. + */ + availableUtxos: UTXO[]; + + /** + * The index of the change output in the transaction, if known. + * This helps identify which output is the change and can be + * adjusted for fee bumping in RBF scenarios. + */ + changeOutputIndex?: number; + + /** + * The incremental relay fee-rate in satoshis per vbyte. This is the minimum + * fee rate increase required for nodes to accept a replacement transaction. + * It's used in RBF calculations to ensure the new transaction meets + * network requirements. + * Default value in Bitcoin Core is 1 sat/vbyte. + * @see https://github.com/bitcoin/bitcoin/blob/master/src/policy/fees.h + */ + incrementalRelayFeeRate?: FeeRateSatsPerVByte; + + /** + * The number of signatures required in a multisig setup. + * This is used to estimate transaction size more accurately + * for multisig transactions. + */ + requiredSigners: number; + + /** + * The total number of signers in a multisig setup. + * This is used along with requiredSigners to estimate + * transaction size more accurately for multisig transactions. + */ + totalSigners: number; + + /** + * The type of Bitcoin address used (e.g., P2PKH, P2SH, P2WPKH, P2WSH). + * This is used to determine the input size for different address types + * when estimating transaction size. + */ + addressType: ScriptType; + + /** + * The hexadecimal representation of the raw transaction. + * This is used for parsing the transaction details directly + * from the hex . + */ + txHex: string; +} + +/** + * Enum representing different fee bumping strategies. + * These strategies are used to increase the fee of a transaction to improve its chances of confirmation. + */ +export enum FeeBumpStrategy { + /** + * Replace-By-Fee (RBF) strategy. + * This involves creating a new transaction that replaces the original one with a higher fee. + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + */ + RBF = "RBF", + + /** + * Child-Pays-for-Parent (CPFP) strategy. + * This involves creating a new transaction that spends an output of the original transaction, + * with a high enough fee to incentivize miners to confirm both transactions together. + * @see https://bitcoinops.org/en/topics/cpfp/ + */ + CPFP = "CPFP", + + /** + * No fee bumping strategy. + * This indicates that fee bumping is not necessary or possible for the transaction. + */ + NONE = "NONE", +} + +/** + * Represents an input in a Bitcoin transaction. + * Transaction inputs are references to outputs of previous transactions that are being spent. + */ +export interface TransactionInput { + /** + * The transaction ID of the previous transaction containing the output being spent. + */ + txid: string; + + /** + * The index of the output in the previous transaction that is being spent. + */ + vout: number; + + /** + * The sequence number of the input. + * This is used for relative time locks and signaling RBF. + * @see https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + */ + sequence: number; + + /** + * The scriptSig of the input in hexadecimal format. + * For non-segwit inputs, this contains the unlocking script. + */ + scriptSig: string; + + /** + * The witness data for segwit inputs. + * This is an array of hex strings, each representing a witness element. + * @see https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki + */ + witness: string[]; +} + +/** + * Represents a fee rate in satoshis per virtual byte. + * This is used for fee estimation and fee bumping calculations. + * @see https://bitcoinops.org/en/topics/fee-estimation/ + */ +export type FeeRateSatsPerVByte = number; + +/** + * Represents an amount in satoshis. + * Satoshis are the smallest unit of bitcoin (1 BTC = 100,000,000 satoshis). + */ +export type Satoshis = string; + +/** + * Represents an amount in bitcoin. + */ +export type BTC = string; + +/** + * Configuration options for creating a transaction template. + * This is used to set up the initial state and parameters for a new transaction. + */ +export interface TransactionTemplateOptions { + /** + * The target fee rate in satoshis per virtual byte. + * This is used to calculate the appropriate fee for the transaction. + */ + targetFeeRate: FeeRateSatsPerVByte; + + /** + * The dust threshold in satoshis. + * Outputs below this value are considered uneconomical to spend. + * @see https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp + */ + dustThreshold?: Satoshis; + + /** + * The Bitcoin network to use (mainnet, testnet, or regtest). + */ + network: Network; + + /** + * The type of script used for the transaction (e.g., "p2pkh", "p2sh", "p2wpkh", "p2wsh"). + * This affects how the transaction is constructed and signed. + */ + scriptType: ScriptType; + + /** + * Optional array of input templates to use in the transaction. + */ + inputs?: BtcTxInputTemplate[]; + + /** + * Optional array of output templates to use in the transaction. + */ + outputs?: BtcTxOutputTemplate[]; + + /** + * The number of signatures required in a multisig setup. + * This is used for multisig transactions and affects the transaction size. + */ + requiredSigners: number; + + /** + * The total number of signers in a multisig setup. + * This is used along with requiredSigners for multisig transactions. + */ + totalSigners: number; +} + +/** + * Options for creating a cancel RBF transaction. + */ +export interface CancelRbfOptions { + /** + * The hex-encoded original transaction to be replaced. + */ + originalTx: string; + + /** + * Array of available UTXOs, including the original transaction's inputs. + */ + availableInputs: UTXO[]; + + /** + * The address where all funds will be sent in the cancellation transaction. + */ + cancelAddress: string; + + /** + * The Bitcoin network being used (e.g., mainnet, testnet). + */ + network: Network; + + /** + * The dust threshold in satoshis. Outputs below this value are considered "dust" + * and may not be economically viable to spend. + */ + dustThreshold: Satoshis; + + /** + * The type of script used for the transaction (e.g., P2PKH, P2SH, P2WSH). + */ + scriptType: ScriptType; + + /** + * The number of required signers for the multisig setup. + */ + requiredSigners: number; + + /** + * The total number of signers in the multisig setup. + */ + totalSigners: number; + + /** + * The target fee rate in satoshis per virtual byte. This is used to calculate + * the appropriate fee for the transaction. + */ + targetFeeRate: FeeRateSatsPerVByte; + + /** + * The absolute fee of the original transaction in satoshis. + */ + absoluteFee: Satoshis; + + /** + * Whether to attempt full RBF even if the transaction doesn't signal it. + * @default false + */ + fullRBF?: boolean; + + /** + * If true, enforces stricter validation rules. + * + * When set to true, the following stricter rules (among others) are applied: + * - Ensures the new fee is significantly higher than the original fee + * - Strictly enforces output value rules (no increases except for fee) + * - Requires change outputs to be above the dust threshold + * - Strictly validates RBF signaling on input sequence numbers + * @default false + */ + strict?: boolean; + + /** + * Whether to reuse all inputs from the original transaction in the replacement. + * + * For cancel transactions, this defaults to false as there's no risk of double-paying. + * Setting this to true will include all original inputs, potentially increasing fees + * but ensuring maximum conflict with the original transaction. + * + * @default false + */ + reuseAllInputs?: boolean; +} + +/** + * Options for creating an accelerated RBF transaction. + */ +export interface AcceleratedRbfOptions + extends Omit { + /** + * The index of the change output in the original transaction. + * Use this option to specify which output from the original transaction + * should be treated as the change output and potentially modified. + * + * @remarks + * - Provide either changeIndex or changeAddress, not both. + * - If changeIndex is provided, the address of the output at this index + * in the original transaction will be used for the new change output. + * - Must be a non-negative integer. + */ + changeIndex?: number; + + /** + * The address to use for the new change output, if different from the original. + * Use this option to specify a new address for the change output. + * + * @remarks + * - Provide either changeAddress or changeIndex, not both. + * - If changeAddress is provided, this address will be used for the new change output, + * regardless of the original transaction's change output address. + * - Must be a valid Bitcoin address for the specified network. + */ + changeAddress?: string; + + /** + * Whether to reuse all inputs from the original transaction in the replacement while accelerating the transaction. + * + * Setting this to false can potentially lead to a "replacement cycle attack" + * where multiple versions of a transaction could be confirmed if they don't + * conflict with each other. Only set this to false if you fully understand + * the risks and have implemented appropriate safeguards. + * + * @see https://bitcoinops.org/en/newsletters/2023/10/25/#fn:rbf-warning + * @default true + */ + reuseAllInputs?: boolean; +} + +/** + * Options for creating a CPFP transaction. + */ +export interface CPFPOptions { + /** + * The hex-encoded original (parent) transaction to be accelerated. + */ + originalTx: string; + + /** + * Array of available UTXOs, including the spendable output from the parent transaction. + */ + availableInputs: UTXO[]; + + /** + * The index of the output in the parent transaction that will be spent in the child transaction. + */ + spendableOutputIndex: number; + + /** + * The address where any excess funds (change) will be sent in the child transaction. + */ + changeAddress: string; + + /** + * The Bitcoin network being used (e.g., mainnet, testnet). + */ + network: Network; + + /** + * The dust threshold in satoshis. Outputs below this value are considered "dust". + */ + dustThreshold: Satoshis; + + /** + * The type of script used for the transaction (e.g., P2PKH, P2SH, P2WSH). + */ + scriptType: ScriptType; + + /** + * The target fee rate in satoshis per virtual byte. This is used to calculate + * the appropriate fee for the transaction. + */ + targetFeeRate: FeeRateSatsPerVByte; + + /** + * The absolute fee of the original transaction in satoshis. + */ + absoluteFee: Satoshis; + + /** + * The number of required signers for the multisig setup. + */ + requiredSigners: number; + + /** + * The total number of signers in the multisig setup. + */ + totalSigners: number; + + /** + * If true, enforces stricter validation rules. + * When set to true, the following stricter rules (among others) are applied: + * - Ensures the new fee is significantly higher than the original fee + * - Requires change outputs to be above the dust threshold + * @default false + */ + strict?: boolean; +} + +/** + * Comprehensive object containing all supported Bitcoin script types. + * This includes multisig address types from Caravan and additional types. + * + * @readonly + * @enum {string} + */ +export const SCRIPT_TYPES = { + /** Pay to Public Key Hash */ + P2PKH: "P2PKH", + /** Pay to Witness Public Key Hash (Native SegWit) */ + P2WPKH: "P2WPKH", + /** Pay to Script Hash wrapping a Pay to Witness Public Key Hash (Nested SegWit) */ + P2SH_P2WPKH: "P2SH_P2WPKH", + /** Unknown or unsupported script type */ + UNKNOWN: "UNKNOWN", + /** Pay to Script Hash */ + P2SH: MULTISIG_ADDRESS_TYPES.P2SH, + /** Pay to Script Hash wrapping a Pay to Witness Script Hash */ + P2SH_P2WSH: MULTISIG_ADDRESS_TYPES.P2SH_P2WSH, + /** Pay to Witness Script Hash (Native SegWit for scripts) */ + P2WSH: MULTISIG_ADDRESS_TYPES.P2WSH, +} as const; + +/** + * Union type representing all possible Bitcoin script types. + * This type can be used for type checking and autocompletion in functions + * that deal with different Bitcoin address formats. + * + * @type {typeof SCRIPT_TYPES[keyof typeof SCRIPT_TYPES]} ScriptType + */ +export type ScriptType = (typeof SCRIPT_TYPES)[keyof typeof SCRIPT_TYPES]; + +/** + * Represents the comprehensive analysis of a Bitcoin transaction. + * This interface encapsulates various metrics and properties of a transaction, + * including size, fees, RBF and CPFP capabilities, and recommended fee bump strategy. + * + * @interface TxAnalysis + * @property {string} txid - The transaction ID (hash) of the analyzed transaction. + * @property {number} vsize - The virtual size of the transaction in virtual bytes (vBytes). + * @property {number} weight - The weight of the transaction in weight units (WU). + * @property {Satoshis} fee - The total fee of the transaction in satoshis. + * @property {FeeRateSatsPerVByte} feeRate - The fee rate of the transaction in satoshis per virtual byte. + * @property {BtcTxInputTemplate[]} inputs - An array of the transaction's inputs. + * @property {BtcTxOutputTemplate[]} outputs - An array of the transaction's outputs. + * @property {boolean} isRBFSignaled - Indicates whether the transaction signals RBF (Replace-By-Fee). + * @property {boolean} canRBF - Indicates whether RBF is possible for this transaction. + * @property {boolean} canCPFP - Indicates whether CPFP (Child-Pays-For-Parent) is possible for this transaction. + * @property {FeeBumpStrategy} recommendedStrategy - The recommended fee bumping strategy for this transaction. + * @property {Satoshis} estimatedRBFFee - The estimated fee required for a successful RBF, in satoshis. + * @property {Satoshis} estimatedCPFPFee - The estimated fee required for a successful CPFP, in satoshis. + * + * @remarks + * - The `vsize` and `weight` properties are important for fee calculation in segwit transactions. + * - `isRBFSignaled` is true if at least one input has a sequence number < 0xfffffffe. + * - `canRBF` considers both RBF signaling and the availability of inputs for replacement. + * - `canCPFP` is true if there's at least one unspent output that can be used for a child transaction. + * - The `recommendedStrategy` is based on the current transaction state and network conditions. + * - `estimatedRBFFee` and `estimatedCPFPFee` are calculated based on current network fee rates and minimum required increases. + * + * @example + * const txAnalyzer = new TransactionAnalyzer(options); + * const analysis: TxAnalysis = txAnalyzer.analyze(); + * console.log(`Transaction ${analysis.txid} has a fee rate of ${analysis.feeRate} sat/vB`); + * if (analysis.canRBF) { + * console.log(`RBF is possible with an estimated fee of ${analysis.estimatedRBFFee} satoshis`); + * } + */ +export interface TxAnalysis { + txid: string; + vsize: number; + weight: number; + fee: Satoshis; + feeRate: FeeRateSatsPerVByte; + inputs: BtcTxInputTemplate[]; + outputs: BtcTxOutputTemplate[]; + isRBFSignaled: boolean; + canRBF: boolean; + canCPFP: boolean; + recommendedStrategy: FeeBumpStrategy; + estimatedRBFFee: Satoshis; + estimatedCPFPFee: Satoshis; +} diff --git a/packages/caravan-fees/src/utils.ts b/packages/caravan-fees/src/utils.ts new file mode 100644 index 00000000..358abb81 --- /dev/null +++ b/packages/caravan-fees/src/utils.ts @@ -0,0 +1,462 @@ +import { + estimateMultisigP2SHTransactionVSize, + estimateMultisigP2SH_P2WSHTransactionVSize, + estimateMultisigP2WSHTransactionVSize, + Network, + validateAddress, +} from "@caravan/bitcoin"; +import { + payments, + address as bitcoinAddress, + networks, + Transaction, + Network as BitcoinJSNetwork, +} from "bitcoinjs-lib-v6"; +import { ScriptType, SCRIPT_TYPES } from "./types"; +import { PsbtV2 } from "@caravan/psbt"; +import BigNumber from "bignumber.js"; + +/** + * Creates an output script for a given Bitcoin address. + * + * This function validates the provided address and creates an appropriate + * output script based on the address type (P2PKH, P2SH, P2WPKH,P2TR or P2WSH). + * It supports both mainnet and testnet addresses. + * + * @param {string} destinationAddress - The Bitcoin address to create an output script for. + * @param {Network} network - The Bitcoin network (mainnet or testnet) the address belongs to. + * @returns {Buffer} The output script as a Buffer. + * @throws {Error} If the address is invalid or unsupported, or if the output script cannot be created. + * + * @example + * const script = createOutputScript('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', Network.MAINNET); + */ +export function createOutputScript( + destinationAddress: string, + network: Network, +): Buffer { + // Validate the address + const addressValidationError = validateAddress(destinationAddress, network); + if (addressValidationError) { + throw new Error(addressValidationError); + } + + // Convert Caravan Network to bitcoinjs-lib network + const bitcoinJsNetwork = + network === Network.TESTNET ? networks.testnet : networks.bitcoin; + + try { + // First, try to create an output script using bitcoinjs-lib + return bitcoinAddress.toOutputScript(destinationAddress, bitcoinJsNetwork); + } catch (error) { + // If toOutputScript fails, it might be a native SegWit address + try { + // Try creating a P2WPKH output + const p2wpkh = payments.p2wpkh({ + address: destinationAddress, + network: bitcoinJsNetwork, + }); + if (p2wpkh.output) { + return p2wpkh.output; + } + + // If not P2WPKH, try P2WSH + const p2wsh = payments.p2wsh({ + address: destinationAddress, + network: bitcoinJsNetwork, + }); + if (p2wsh.output) { + return p2wsh.output; + } + + // If not P2WSH, try P2TR + const p2tr = payments.p2tr({ + address: destinationAddress, + network: bitcoinJsNetwork, + }); + if (p2tr.output) { + return p2tr.output; + } + + throw new Error("Unsupported address type"); + } catch (segwitError) { + throw new Error(`Invalid or unsupported address: ${destinationAddress}`); + } + } +} + +/** + * Attempts to derive the address from an output script. + * @param {Buffer} script - The output script + * @param {Network} network - The Bitcoin network (e.g., mainnet, testnet) to use for address derivation. + * @returns {string} The derived address or an error message if unable to derive + * @protected + */ +export function getOutputAddress(script: Buffer, network: Network): string { + const bitcoinjsNetwork = mapCaravanNetworkToBitcoinJS(network); + + try { + // Check for P2PKH (25 bytes, starting with OP_DUP (0x76) and OP_HASH160 (0xa9)) + if (script.length === 25 && script[0] === 0x76 && script[1] === 0xa9) { + const p2pkhAddress = payments.p2pkh({ + output: script, + network: bitcoinjsNetwork, + }).address; + if (p2pkhAddress) return p2pkhAddress; + } + + // Check for P2WPKH + if (script.length === 22 && script[0] === 0x00 && script[1] === 0x14) { + const p2wpkhAddress = payments.p2wpkh({ + output: script, + network: bitcoinjsNetwork, + }).address; + if (p2wpkhAddress) return p2wpkhAddress; + } + + // Check for P2WSH + if (script.length === 34 && script[0] === 0x00 && script[1] === 0x20) { + const p2wshAddress = payments.p2wsh({ + output: script, + network: bitcoinjsNetwork, + }).address; + if (p2wshAddress) return p2wshAddress; + } + + // Check for P2SH (23 bytes, starting with OP_HASH160 (0xa9)) + if (script.length === 23 && script[0] === 0xa9 && script[22] === 0x87) { + return ( + payments.p2sh({ output: script, network: bitcoinjsNetwork }).address || + "" + ); + } + + // Check for P2TR (Taproot, 34 bytes, starting with OP_1 (0x51)) + if (script.length === 34 && script[0] === 0x51) { + const p2trAddress = payments.p2tr({ + output: script, + network: bitcoinjsNetwork, + }).address; + if (p2trAddress) return p2trAddress; + } + + // If we couldn't derive an address, return an error message + return "Unable to derive address"; + } catch (e) { + console.error("Error deriving address:", e); + return "Unable to derive address"; + } +} + +/** + * Estimates the virtual size (vsize) of a transaction. + * + * This function calculates the estimated vsize for different address types, + * including P2SH, P2SH_P2WSH, P2WSH, and P2WPKH. The vsize is crucial for + * fee estimation in Bitcoin transactions, especially for SegWit transactions + * where the witness data is discounted. + * + * Calculation Method: + * 1. For non-SegWit (P2SH): vsize = transaction size + * 2. For SegWit (P2SH_P2WSH, P2WSH, P2WPKH): + * vsize = (transaction weight) / 4, rounded up + * where transaction weight = (base size * 3) + total size + * + * References: + * - BIP141 (SegWit): https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki + * - Bitcoin Core weight calculation: https://github.com/bitcoin/bitcoin/blob/master/src/consensus/validation.h + * + * @param config - Configuration object for the transaction + * @param config.addressType - The type of address used (P2SH, P2SH_P2WSH, P2WSH, P2WPKH) + * @param config.numInputs - Number of inputs in the transaction + * @param config.numOutputs - Number of outputs in the transaction + * @param config.m - Number of required signatures (for multisig) + * @param config.n - Total number of possible signers (for multisig) + * + * @returns The estimated virtual size (vsize) of the transaction in vbytes + * + * @throws Will throw an error if the address type is unsupported + */ +export function estimateTransactionVsize({ + addressType = SCRIPT_TYPES.P2SH, + numInputs = 1, + numOutputs = 1, + m = 1, + n = 2, +}: { + addressType?: ScriptType; + numInputs?: number; + numOutputs?: number; + m?: number; + n?: number; +} = {}): number { + switch (addressType) { + case SCRIPT_TYPES.P2SH: + return estimateMultisigP2SHTransactionVSize({ + numInputs, + numOutputs, + m, + n, + }); + case SCRIPT_TYPES.P2SH_P2WSH: + return estimateMultisigP2SH_P2WSHTransactionVSize({ + numInputs, + numOutputs, + m, + n, + }); + case SCRIPT_TYPES.P2WSH: + return estimateMultisigP2WSHTransactionVSize({ + numInputs, + numOutputs, + m, + n, + }); + + default: + throw new Error(`Unsupported address type: ${addressType}`); + } +} + +/** + * Initializes the parent PSBT from various input formats. + * + * This method supports initializing from a PsbtV2 object, a serialized PSBT string, + * or a Buffer. It attempts to parse the input as a PsbtV2 and falls back to PsbtV0 + * if necessary, providing backwards compatibility. + * + * @private + * @param {PsbtV2 | string | Buffer} psbt - The parent PSBT in various formats + * @returns {PsbtV2} An initialized PsbtV2 object + * @throws {Error} If the PSBT cannot be parsed or converted + */ +export function initializePsbt(psbt: PsbtV2 | string | Buffer): PsbtV2 { + if (psbt instanceof PsbtV2) { + return psbt; + } + try { + return new PsbtV2(psbt); + } catch (error) { + try { + return PsbtV2.FromV0(psbt); + } catch (conversionError) { + throw new Error( + "Unable to initialize PSBT. Neither V2 nor V0 format recognized.", + ); + } + } +} + +/** + * Calculates the total input value from the given PSBT. + * + * This function aggregates the total value of all inputs, considering both + * witness and non-witness UTXOs. It uses helper functions to parse and sum up + * the values of each input. + * + * @param psbt - The PsbtV2 instance representing the partially signed Bitcoin transaction. + * @returns The total input value as a BigNumber. + */ +export function calculateTotalInputValue(psbt: PsbtV2): BigNumber { + let total = new BigNumber(0); + for (let i = 0; i < psbt.PSBT_GLOBAL_INPUT_COUNT; i++) { + const witnessUtxo = psbt.PSBT_IN_WITNESS_UTXO[i]; + const nonWitnessUtxo = psbt.PSBT_IN_NON_WITNESS_UTXO[i]; + if (witnessUtxo) { + total = total.plus(parseWitnessUtxoValue(witnessUtxo, i)); + } else if (nonWitnessUtxo) { + if (!nonWitnessUtxo) { + throw new Error( + `Output index for non-witness UTXO at index ${i} is undefined`, + ); + } + total = total.plus(parseNonWitnessUtxoValue(nonWitnessUtxo, i)); + } else { + throw new Error(`No UTXO data found for input at index ${i}`); + } + } + return total; +} + +/** + * Parses the value of a witness UTXO. + * + * Witness UTXOs are expected to have their value encoded in the first 8 bytes + * of the hex string in little-endian byte order. This function extracts and + * converts that value to a BigNumber. + * + * @param utxo - The hex string representing the witness UTXO. + * @param index - The index of the UTXO in the PSBT input list. + * @returns The parsed value as a BigNumber. + */ +export function parseWitnessUtxoValue( + utxo: string | null, + index: number, +): BigNumber { + if (!utxo) { + console.warn(`Witness UTXO at index ${index} is null`); + return new BigNumber(0); + } + try { + const buffer = Buffer.from(utxo, "hex"); + // The witness UTXO format is: [value][scriptPubKey] + // The value is an 8-byte little-endian integer + const value = buffer.readBigUInt64LE(0); + return new BigNumber(value.toString()); + } catch (error) { + console.warn(`Failed to parse witness UTXO at index ${index}:`, error); + return new BigNumber(0); + } +} + +/** + * Parses the value of a non-witness UTXO. + * + * @param rawTx - The raw transaction hex string. + * @param outputIndex - The index of the output in the transaction. + * @returns The parsed value as a BigNumber. + * @throws Error if the transaction cannot be parsed or the output index is invalid. + */ +export function parseNonWitnessUtxoValue( + rawTx: string, + outputIndex: number, +): BigNumber { + try { + const tx = Transaction.fromHex(rawTx); + + if (outputIndex < 0 || outputIndex >= tx.outs.length) { + throw new Error(`Invalid output index: ${outputIndex}`); + } + + const output = tx.outs[outputIndex]; + return new BigNumber(output.value); + } catch (error) { + throw new Error( + `Failed to parse non-witness UTXO: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Calculates the total output value from the given PSBT. + * + * This function sums the values of all outputs in the PSBT. + * + * @param psbt - The PsbtV2 instance representing the partially signed Bitcoin transaction. + * @returns The total output value as a BigNumber. + */ +export function calculateTotalOutputValue(psbt: PsbtV2): BigNumber { + const sum = psbt.PSBT_OUT_AMOUNT.reduce((acc, amount) => acc + amount, 0n); + return new BigNumber(sum.toString()); +} + +/** + * Maps a Caravan Network to its corresponding BitcoinJS Network. + * + * @param {CaravanNetwork} network - The Caravan Network to map. + * @returns {BitcoinJSNetwork} The corresponding BitcoinJS Network. + * @throws {Error} If an unsupported network is provided. + */ +export function mapCaravanNetworkToBitcoinJS( + network: Network, +): BitcoinJSNetwork { + switch (network) { + case Network.MAINNET: + return networks.bitcoin; + case Network.TESTNET: + return networks.testnet; + case Network.REGTEST: + return networks.regtest; + case Network.SIGNET: + // As of the last check, bitcoinjs-lib doesn't have built-in support for signet. + // If signet support is crucial, you might need to define a custom network. + throw new Error( + "Signet is not directly supported in bitcoinjs-lib. Consider defining a custom network if needed.", + ); + default: + throw new Error(`Unsupported network: ${network}`); + } +} + +/** + * Validates a non-witness UTXO (Unspent Transaction Output) for use in a PSBT. + * + * This function performs several checks on the provided UTXO to ensure it's valid: + * 1. Verifies that the transaction can be parsed from the buffer. + * 2. Checks if the specified output index (vout) is within the range of available outputs. + * 3. Validates that the output value is a positive number. + * 4. Ensures that the output script is a valid Buffer. + * + * Note: This function does not validate the txid, as the provided buffer represents + * the previous transaction, not the transaction containing this input. + * + * @param {Buffer} utxoBuffer - The raw transaction buffer containing the UTXO. + * @param {string} txid - The transaction ID of the input (not used in validation, but included for potential future use). + * @param {number} vout - The index of the output in the transaction that we're spending. + * @returns {boolean} True if the UTXO is valid, false otherwise. + * + * @throws {Error} Implicitly, if there's an issue parsing the transaction. The error is caught and logged, returning false. + */ +export function validateNonWitnessUtxo( + utxoBuffer: Buffer, + txid: string, + vout: number, +): boolean { + try { + const tx = Transaction.fromBuffer(utxoBuffer); + + // Validate that the vout is within range + if (vout < 0 || vout >= tx.outs.length) { + return false; + } + + // Get the specific output we're spending + const output = tx.outs[vout]; + + // Validate the output value (should be a positive number) + if (typeof output.value !== "number" || output.value <= 0) { + return false; + } + + // Validate that the output script is a Buffer + if (!Buffer.isBuffer(output.script)) { + return false; + } + + // Note: We can't validate the txid here because tx.getId() would give us + // the txid of this previous transaction, not our input's txid. + + return true; + } catch (error) { + console.error("Error validating non-witness UTXO:", error); + return false; + } +} + +/** + * Validates the sequence number of a transaction input. + * + * In Bitcoin transactions, the sequence number is used for various purposes including: + * - Signaling Replace-By-Fee (RBF) when set to a value less than 0xffffffff - 1 + * - Enabling relative timelock when bit 31 is not set (value < 0x80000000) + * + * This function checks if the provided sequence number is a valid 32-bit unsigned integer. + * + * @param {number} sequence - The sequence number to validate. + * @returns {boolean} True if the sequence number is valid, false otherwise. + * + * @example + * console.log(validateSequence(0xffffffff)); // true + * console.log(validateSequence(0xfffffffe)); // true (signals RBF) + * console.log(validateSequence(0x80000000)); // true (disables relative timelock) + * console.log(validateSequence(-1)); // false (negative) + * console.log(validateSequence(0x100000000)); // false (exceeds 32-bit) + * console.log(validateSequence(1.5)); // false (not an integer) + * + * @see https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki BIP 68 for relative lock-time + * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki BIP 125 for opt-in full Replace-by-Fee signaling + */ +export function validateSequence(sequence: number): boolean { + // Sequence should be a 32-bit unsigned integer + return Number.isInteger(sequence) && sequence >= 0 && sequence <= 0xffffffff; +} diff --git a/packages/caravan-fees/tsconfig.json b/packages/caravan-fees/tsconfig.json new file mode 100644 index 00000000..34347fdd --- /dev/null +++ b/packages/caravan-fees/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@caravan/typescript-config/base.json", + "compilerOptions": { + "allowJs": true + } +} diff --git a/packages/caravan-fees/tsup.config.ts b/packages/caravan-fees/tsup.config.ts new file mode 100644 index 00000000..9169adf0 --- /dev/null +++ b/packages/caravan-fees/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; +import { polyfillNode } from "esbuild-plugin-polyfill-node"; + +export default defineConfig({ + esbuildPlugins: [ + polyfillNode({ + globals: { + process: true, + global: true, + }, + }), + ], +}); diff --git a/packages/caravan-psbt/src/psbtv2/psbtv2.test.ts b/packages/caravan-psbt/src/psbtv2/psbtv2.test.ts index 05e97595..0f98c358 100644 --- a/packages/caravan-psbt/src/psbtv2/psbtv2.test.ts +++ b/packages/caravan-psbt/src/psbtv2/psbtv2.test.ts @@ -1593,3 +1593,75 @@ describe("PsbtV2.setProprietaryValue", () => { ); }); }); + +describe("PsbtV2.setInputSequence", () => { + let psbt: PsbtV2; + + beforeEach(() => { + psbt = new PsbtV2(); + psbt.addInput({ previousTxId: Buffer.from([0x00]), outputIndex: 0 }); + psbt.addInput({ previousTxId: Buffer.from([0x01]), outputIndex: 0 }); + psbt.PSBT_GLOBAL_TX_MODIFIABLE = [PsbtGlobalTxModifiableBits.INPUTS]; + }); + + it("Successfully sets the sequence for a valid input", () => { + const newSequence = 0xfffffffd; // RBF signaling sequence + psbt.setInputSequence(0, newSequence); + expect(psbt.PSBT_IN_SEQUENCE[0]).toBe(newSequence); + }); + + it("Throws an error when trying to set sequence for a non-existent input", () => { + expect(() => psbt.setInputSequence(2, 0xfffffffd)).toThrow( + "Input at index 2 does not exist.", + ); + }); + + it("Throws an error when PSBT is not ready for Updater", () => { + psbt.PSBT_GLOBAL_TX_MODIFIABLE = []; // Remove input modifiability + expect(() => psbt.setInputSequence(0, 0xfffffffd)).toThrow( + "PSBT is not ready for the Updater role.", + ); + }); + + it("Overwrites existing sequence when setting a new one", () => { + psbt.setInputSequence(0, 0xfffffffe); + psbt.setInputSequence(0, 0xfffffffd); + expect(psbt.PSBT_IN_SEQUENCE[0]).toBe(0xfffffffd); + }); + + it("Correctly handles setting sequence to 0", () => { + psbt.setInputSequence(0, 0); + expect(psbt.PSBT_IN_SEQUENCE[0]).toBe(0); + }); + + it("Returns false when no inputs signal RBF", () => { + expect(psbt.isRBFSignaled).toBe(false); + }); + + it("Returns true when at least one input signals RBF", () => { + psbt.setInputSequence(0, 0xfffffffd); + expect(psbt.isRBFSignaled).toBe(true); + }); + + it("Returns true when only one of multiple inputs signals RBF", () => { + psbt.setInputSequence(0, 0xfffffffe); // Non-RBF + psbt.setInputSequence(1, 0xfffffffd); // RBF + expect(psbt.isRBFSignaled).toBe(true); + }); + + it("Returns false when all inputs have sequence numbers that don't signal RBF", () => { + psbt.setInputSequence(0, 0xfffffffe); + psbt.setInputSequence(1, 0xffffffff); + expect(psbt.isRBFSignaled).toBe(false); + }); + + it("Returns true when an input has a sequence number of 0", () => { + psbt.setInputSequence(0, 0); + expect(psbt.isRBFSignaled).toBe(true); + }); + + it("Returns false when the PSBT has no inputs", () => { + const emptyPsbt = new PsbtV2(); + expect(emptyPsbt.isRBFSignaled).toBe(false); + }); +}); diff --git a/packages/caravan-psbt/src/psbtv2/psbtv2.ts b/packages/caravan-psbt/src/psbtv2/psbtv2.ts index 01e22734..ee7336b0 100644 --- a/packages/caravan-psbt/src/psbtv2/psbtv2.ts +++ b/packages/caravan-psbt/src/psbtv2/psbtv2.ts @@ -787,6 +787,88 @@ export class PsbtV2 extends PsbtV2Maps { } } + /** + * Sets the sequence number for a specific input in the transaction. + * + * This private helper method is crucial for implementing RBF and other + * sequence-based transaction features. It writes the provided sequence + * number as a 32-bit little-endian unsigned integer and stores it in the + * appropriate input's map using the PSBT_IN_SEQUENCE key. + * + * The sequence number has multiple uses in Bitcoin transactions: + * 1. Signaling RBF (values < 0xfffffffe) + * 2. Enabling nLockTime (values < 0xffffffff) + * 3. Relative timelock with BIP68 (if bit 31 is not set) + * + * According to BIP125 (Opt-in Full Replace-by-Fee Signaling): + * + * - For a transaction to be considered opt-in RBF, it must have at least + * one input with a sequence number < 0xfffffffe. + * - The recommended sequence for RBF is 0xffffffff-2 (0xfffffffd). + * + * Sequence number meanings: + * - = 0xffffffff: Then the transaction is final no matter the nLockTime. + * - < 0xfffffffe: Transaction signals for RBF. + * - < 0xefffffff : Then the transaction signals BIP68 relative locktime. + * + * For using nLocktime along with Opt-in RBF, the sequence value + * should be between 0xf0000000 and 0xfffffffd. + * + * Care should be taken when setting sequence numbers to ensure the desired + * transaction properties are correctly signaled. Improper use can lead to + * unexpected transaction behavior or rejection by the network. + * + * References: + * - BIP125: Opt-in Full Replace-by-Fee Signaling + * https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + * - BIP68: Relative lock-time using consensus-enforced sequence numbers + * https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki + */ + public setInputSequence(inputIndex: number, sequence: number) { + // Check if the PSBT is ready for the Updater role + if (!this.isReadyForUpdater) { + throw new Error( + "PSBT is not ready for the Updater role. Sequence cannot be changed.", + ); + } + + // Check if the input exists + if (inputIndex < 0 || inputIndex >= this.PSBT_GLOBAL_INPUT_COUNT) { + throw new Error(`Input at index ${inputIndex} does not exist.`); + } + + // Set the sequence number + const bw = new BufferWriter(); + bw.writeU32(sequence); + this.inputMaps[inputIndex].set(KeyType.PSBT_IN_SEQUENCE, bw.render()); + } + + /** + * Checks if the transaction signals Replace-by-Fee (RBF). + * + * This method determines whether the transaction is eligible for RBF by + * examining the sequence numbers of all inputs. As per BIP125, a transaction + * is considered to have opted in to RBF if it contains at least one input + * with a sequence number less than (0xffffffff - 1). + * + * Return value: + * - true: If any input has a sequence number < 0xfffffffe, indicating RBF. + * - false: If all inputs have sequence numbers >= 0xfffffffe, indicating no RBF. + * + * This method is useful for wallets, block explorers, or any service that + * needs to determine if a transaction can potentially be replaced before + * confirmation. + * + * References: + * - BIP125: Opt-in Full Replace-by-Fee Signaling + * https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki + */ + get isRBFSignaled(): boolean { + return this.PSBT_IN_SEQUENCE.some( + (seq) => seq !== null && seq < 0xfffffffe, + ); + } + /** * This method is provided for compatibility issues and probably shouldn't be * used since a PsbtV2 with PSBT_GLOBAL_TX_VERSION = 1 is BIP0370 @@ -875,6 +957,7 @@ export class PsbtV2 extends PsbtV2Maps { map.set(KeyType.PSBT_IN_PREVIOUS_TXID, bw.render()); bw.writeI32(outputIndex); map.set(KeyType.PSBT_IN_OUTPUT_INDEX, bw.render()); + if (sequence) { bw.writeI32(sequence); map.set(KeyType.PSBT_IN_SEQUENCE, bw.render()); From 0b9be78359c91d6f46fdc8699e365a9de48a5440 Mon Sep 17 00:00:00 2001 From: buck Date: Thu, 7 Nov 2024 15:32:48 -0600 Subject: [PATCH 05/19] update package-lock (#143) --- package-lock.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4504d8da..965d10bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2726,6 +2726,10 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@caravan/fees": { + "resolved": "packages/caravan-fees", + "link": true + }, "node_modules/@caravan/health": { "resolved": "packages/caravan-health", "link": true @@ -27363,6 +27367,43 @@ "webidl-conversions": "^4.0.2" } }, + "packages/caravan-fees": { + "name": "@caravan/fees", + "version": "1.0.0-beta", + "license": "MIT", + "dependencies": { + "@caravan/bitcoin": "*", + "@caravan/psbt": "*", + "bignumber.js": "^9.1.2", + "bitcoinjs-lib-v6": "npm:bitcoinjs-lib@^6.1.5" + }, + "devDependencies": { + "@caravan/typescript-config": "*", + "@inrupt/jest-jsdom-polyfills": "^3.2.1", + "esbuild-plugin-polyfill-node": "^0.3.0", + "prettier": "^3.2.5", + "ts-jest": "^29.0.5", + "tsup": "^7.2.0", + "typescript": "^4.9.5" + }, + "engines": { + "node": ">=20" + } + }, + "packages/caravan-fees/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "packages/caravan-health": { "name": "@caravan/health", "version": "1.0.0-beta", From f4552b7c6002c9a6d2846829ea97fb2e41563cb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 Nov 2024 16:08:45 -0600 Subject: [PATCH 06/19] Version Packages (#144) Co-authored-by: github-actions[bot] --- .changeset/real-ravens-roll.md | 5 ----- package-lock.json | 2 +- packages/caravan-psbt/CHANGELOG.md | 6 ++++++ packages/caravan-psbt/package.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/real-ravens-roll.md diff --git a/.changeset/real-ravens-roll.md b/.changeset/real-ravens-roll.md deleted file mode 100644 index c243aa61..00000000 --- a/.changeset/real-ravens-roll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@caravan/psbt": minor ---- - -Added methods to handle sequence numbers within PSBTs for better RBF (Replace-By-Fee) support: diff --git a/package-lock.json b/package-lock.json index 965d10bc..a6080f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27422,7 +27422,7 @@ }, "packages/caravan-psbt": { "name": "@caravan/psbt", - "version": "1.4.3", + "version": "1.5.0", "license": "ISC", "dependencies": { "@caravan/bitcoin": "*", diff --git a/packages/caravan-psbt/CHANGELOG.md b/packages/caravan-psbt/CHANGELOG.md index 66168079..1a02c3c6 100644 --- a/packages/caravan-psbt/CHANGELOG.md +++ b/packages/caravan-psbt/CHANGELOG.md @@ -1,5 +1,11 @@ # @caravan/psbt +## 1.5.0 + +### Minor Changes + +- [#114](https://github.com/caravan-bitcoin/caravan/pull/114) [`8d79fc6`](https://github.com/caravan-bitcoin/caravan/commit/8d79fc6cfbd63bee37f076c4396a94d30e412e6f) Thanks [@Legend101Zz](https://github.com/Legend101Zz)! - Added methods to handle sequence numbers within PSBTs for better RBF (Replace-By-Fee) support: + ## 1.4.3 ### Patch Changes diff --git a/packages/caravan-psbt/package.json b/packages/caravan-psbt/package.json index b7e0548c..c48ebcc4 100644 --- a/packages/caravan-psbt/package.json +++ b/packages/caravan-psbt/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/psbt", - "version": "1.4.3", + "version": "1.5.0", "description": "typescript library for working with PSBTs", "main": "./dist/index.js", "types": "./dist/index.d.ts", From d0e08e872a63011d2ed72b6b9a68e54db1649b44 Mon Sep 17 00:00:00 2001 From: benma Date: Fri, 8 Nov 2024 23:15:41 +0100 Subject: [PATCH 07/19] add support for the BitBox02 hardware wallet (#117) * add support for the BitBox02 hardware wallet Using the `bitbox-api` NPM package, which loads a WASM module. Note about CJS: Currently the `bitbox-api` package is an ESM package with a `module: ...` entrypoint, so it is not compatible with the `cjs` target of caravan-wallets. The only workaround that I could find where compilation succeeds and the package works in the browser is to mark bitbox-api as external in tsup.config.ts. Note about signing tests: - The BitBox02 requires the previous transaction of each input to be present in the PSBT (`PSBT_IN_NON_WITNESS_UTXO`), so it can verify the input amount and avoid fee attacks. The signing test fixtures are missing these, so they fail. - The BitBox02 uses the anti-klepto (anti-exfil) protocol to mitigate covert nonce exfil attacks. This results in random signatures. The unit test fixtures hardcode the expected signatures, assuming they are always the same. As a result, also here the tests fail. To fix this, the tests should rather verify the ECDSA signatures against the transaction sighash for each input. * hide BitBox02 menu item for P2SH BitBox02 does not support legacy P2SH. * bitbox: display pairing code The BitBox, if not paired yet, will show a pairing code for confirmation. This can happen in any BitBox interaction. This commit adds a `showPairingCode` param to all BitBox interactions. If not provided, a default implementation is used which shows the pairing code in a browser popup. The current `messages()` system is not a good fit, as the client does not know when to call `messagesFor()` to display it. Having a separate UI button to pair the BitBox is not good UX (why should the user be bothered to click a "pair" button first? What if the user doesn't) and also fragile (a re-pairing could be needed at any time). * add regtest support for BitBox02 --------- Co-authored-by: buck --- .changeset/heavy-kids-confess.md | 6 + .../src/actions/keystoreActions.ts | 3 +- .../HardwareWalletPublicKeyImporter.jsx | 3 +- .../CreateAddress/PublicKeyImporter.jsx | 4 +- .../RegisterWallet/RegisterBitBoxButton.jsx | 39 ++ .../src/components/RegisterWallet/index.jsx | 2 + .../ScriptExplorer/SignatureImporter.jsx | 8 +- .../src/components/Slices/ConfirmAddress.jsx | 6 + .../TestSuiteRun/KeystorePicker.tsx | 2 + .../TestSuiteRun/TestSuiteRunSummary.tsx | 3 + .../Wallet/ExtendedPublicKeyImporter.jsx | 8 +- .../src/components/Wallet/RegisterWallet.jsx | 4 + .../src/components/Wallet/index.jsx | 1 + apps/coordinator/src/tests/bitbox.js | 13 + apps/coordinator/src/tests/index.js | 4 +- apps/coordinator/src/tests/registration.jsx | 6 +- apps/coordinator/src/tests/signing.jsx | 12 + apps/coordinator/vite.config.ts | 1 + package-lock.json | 7 + packages/caravan-wallets/package.json | 1 + packages/caravan-wallets/src/bitbox.ts | 558 ++++++++++++++++++ packages/caravan-wallets/src/index.ts | 68 ++- packages/caravan-wallets/tsconfig.json | 2 + packages/caravan-wallets/tsup.config.ts | 1 + 24 files changed, 749 insertions(+), 13 deletions(-) create mode 100644 .changeset/heavy-kids-confess.md create mode 100644 apps/coordinator/src/components/RegisterWallet/RegisterBitBoxButton.jsx create mode 100644 apps/coordinator/src/tests/bitbox.js create mode 100644 packages/caravan-wallets/src/bitbox.ts diff --git a/.changeset/heavy-kids-confess.md b/.changeset/heavy-kids-confess.md new file mode 100644 index 00000000..0a9a5c09 --- /dev/null +++ b/.changeset/heavy-kids-confess.md @@ -0,0 +1,6 @@ +--- +"@caravan/wallets": minor +"caravan-coordinator": minor +--- + +Add support for the BitBox02 hardware wallet diff --git a/apps/coordinator/src/actions/keystoreActions.ts b/apps/coordinator/src/actions/keystoreActions.ts index 91c7445d..cb928bee 100644 --- a/apps/coordinator/src/actions/keystoreActions.ts +++ b/apps/coordinator/src/actions/keystoreActions.ts @@ -1,10 +1,11 @@ -import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; +import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; export const SET_KEYSTORE = "SET_KEYSTORE"; export const SET_KEYSTORE_NOTE = "SET_KEYSTORE_NOTE"; export const SET_KEYSTORE_STATUS = "SET_KEYSTORE_STATUS"; type KeyStoreType = + | typeof BITBOX | typeof TREZOR | typeof LEDGER | typeof HERMIT diff --git a/apps/coordinator/src/components/CreateAddress/HardwareWalletPublicKeyImporter.jsx b/apps/coordinator/src/components/CreateAddress/HardwareWalletPublicKeyImporter.jsx index 9c6c0d48..fb2a1c29 100644 --- a/apps/coordinator/src/components/CreateAddress/HardwareWalletPublicKeyImporter.jsx +++ b/apps/coordinator/src/components/CreateAddress/HardwareWalletPublicKeyImporter.jsx @@ -6,6 +6,7 @@ import { ACTIVE, ERROR, ExportPublicKey, + BITBOX, TREZOR, LEDGER, } from "@caravan/wallets"; @@ -172,7 +173,7 @@ const HardwareWalletPublicKeyImporter = ({ HardwareWalletPublicKeyImporter.propTypes = { network: PropTypes.string.isRequired, - method: PropTypes.oneOf([LEDGER, TREZOR]).isRequired, + method: PropTypes.oneOf([BITBOX, LEDGER, TREZOR]).isRequired, defaultBIP32Path: PropTypes.string.isRequired, validatePublicKey: PropTypes.func.isRequired, enableChangeMethod: PropTypes.func.isRequired, diff --git a/apps/coordinator/src/components/CreateAddress/PublicKeyImporter.jsx b/apps/coordinator/src/components/CreateAddress/PublicKeyImporter.jsx index 0dcce513..df0c865c 100644 --- a/apps/coordinator/src/components/CreateAddress/PublicKeyImporter.jsx +++ b/apps/coordinator/src/components/CreateAddress/PublicKeyImporter.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { validatePublicKey as baseValidatePublicKey } from "@caravan/bitcoin"; -import { TREZOR, LEDGER } from "@caravan/wallets"; +import { BITBOX, TREZOR, LEDGER } from "@caravan/wallets"; // Components import { @@ -213,6 +213,7 @@ const PublicKeyImporter = ({ const renderImportByMethod = () => { if ( + publicKeyImporter.method === BITBOX || publicKeyImporter.method === TREZOR || publicKeyImporter.method === LEDGER ) { @@ -261,6 +262,7 @@ const PublicKeyImporter = ({ variant="standard" > {"< Select method >"} + BitBox Trezor Ledger Derive from extended public key diff --git a/apps/coordinator/src/components/RegisterWallet/RegisterBitBoxButton.jsx b/apps/coordinator/src/components/RegisterWallet/RegisterBitBoxButton.jsx new file mode 100644 index 00000000..ce9c3c65 --- /dev/null +++ b/apps/coordinator/src/components/RegisterWallet/RegisterBitBoxButton.jsx @@ -0,0 +1,39 @@ +import React, { useState } from "react"; +import { Button } from "@mui/material"; +import { useDispatch, useSelector } from "react-redux"; +import { BITBOX, RegisterWalletPolicy } from "@caravan/wallets"; +import { getWalletConfig } from "../../selectors/wallet"; +import { setErrorNotification } from "../../actions/errorNotificationActions"; + +const RegisterBitBoxButton = ({ ...otherProps }) => { + const [isActive, setIsActive] = useState(false); + const walletConfig = useSelector(getWalletConfig); + const dispatch = useDispatch(); + + const registerWallet = async () => { + setIsActive(true); + try { + const interaction = new RegisterWalletPolicy({ + keystore: BITBOX, + ...walletConfig, + }); + await interaction.run(); + } catch (e) { + dispatch(setErrorNotification(e.message)); + } finally { + setIsActive(false); + } + }; + return ( + + ); +}; + +export default RegisterBitBoxButton; diff --git a/apps/coordinator/src/components/RegisterWallet/index.jsx b/apps/coordinator/src/components/RegisterWallet/index.jsx index 2823b9c0..132dc2f4 100644 --- a/apps/coordinator/src/components/RegisterWallet/index.jsx +++ b/apps/coordinator/src/components/RegisterWallet/index.jsx @@ -1,9 +1,11 @@ import DownloadColdardConfigButton from "./DownloadColdcardConfig"; import PolicyRegistrationTable from "./PolicyRegistrationsTable"; +import RegisterBitBoxButton from "./RegisterBitBoxButton"; import RegisterLedgerButton from "./RegisterLedgerButton"; export { DownloadColdardConfigButton, PolicyRegistrationTable, + RegisterBitBoxButton, RegisterLedgerButton, }; diff --git a/apps/coordinator/src/components/ScriptExplorer/SignatureImporter.jsx b/apps/coordinator/src/components/ScriptExplorer/SignatureImporter.jsx index 211f6317..466e70d4 100644 --- a/apps/coordinator/src/components/ScriptExplorer/SignatureImporter.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/SignatureImporter.jsx @@ -7,8 +7,9 @@ import { multisigBIP32Root, validateBIP32Path, getMaskedDerivation, + P2SH, } from "@caravan/bitcoin"; -import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; +import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; import { Card, CardHeader, @@ -93,7 +94,7 @@ class SignatureImporter extends React.Component { }; renderImport = () => { - const { signatureImporter, number, isWallet } = this.props; + const { signatureImporter, number, isWallet, addressType } = this.props; const currentNumber = this.getCurrent(); const notMyTurn = number > currentNumber; const { disableChangeMethod } = this.state; @@ -119,6 +120,7 @@ class SignatureImporter extends React.Component { onChange={this.handleMethodChange} > {"< Select method >"} + {addressType != P2SH && BitBox} Trezor Ledger @@ -155,7 +157,7 @@ class SignatureImporter extends React.Component { } = this.props; const { method } = signatureImporter; - if (method === TREZOR || method === LEDGER) { + if (method === BITBOX || method === TREZOR || method === LEDGER) { return ( { } // FIXME - hardcoded to just show up for trezor if ( + extendedPublicKeyImporter.method === BITBOX || extendedPublicKeyImporter.method === TREZOR || extendedPublicKeyImporter.method === LEDGER ) { @@ -230,6 +233,9 @@ const ConfirmAddress = ({ slice, network }) => { variant="standard" > {"< Select method >"} + {addressType != P2SH && ( + BitBox + )} Trezor Ledger diff --git a/apps/coordinator/src/components/TestSuiteRun/KeystorePicker.tsx b/apps/coordinator/src/components/TestSuiteRun/KeystorePicker.tsx index 310242b4..ef2e7eb6 100644 --- a/apps/coordinator/src/components/TestSuiteRun/KeystorePicker.tsx +++ b/apps/coordinator/src/components/TestSuiteRun/KeystorePicker.tsx @@ -2,6 +2,7 @@ import React from "react"; import { connect } from "react-redux"; import { + BITBOX, TREZOR, LEDGER, HERMIT, @@ -89,6 +90,7 @@ const KeystorePickerBase = ({ variant="standard" > {"< Select type >"} + BitBox Trezor Ledger Coldcard diff --git a/apps/coordinator/src/components/TestSuiteRun/TestSuiteRunSummary.tsx b/apps/coordinator/src/components/TestSuiteRun/TestSuiteRunSummary.tsx index 316eddfc..bead66d6 100644 --- a/apps/coordinator/src/components/TestSuiteRun/TestSuiteRunSummary.tsx +++ b/apps/coordinator/src/components/TestSuiteRun/TestSuiteRunSummary.tsx @@ -3,6 +3,7 @@ import moment from "moment"; import { useSelector, useDispatch } from "react-redux"; import Bowser from "bowser"; import { + BITBOX, TREZOR, LEDGER, HERMIT, @@ -178,6 +179,8 @@ const TestSuiteRunSummaryBase = () => { const keystoreName = (type: string) => { switch (type) { + case BITBOX: + return "BitBox"; case TREZOR: return "Trezor"; case LEDGER: diff --git a/apps/coordinator/src/components/Wallet/ExtendedPublicKeyImporter.jsx b/apps/coordinator/src/components/Wallet/ExtendedPublicKeyImporter.jsx index 04836c5b..6e68f27e 100644 --- a/apps/coordinator/src/components/Wallet/ExtendedPublicKeyImporter.jsx +++ b/apps/coordinator/src/components/Wallet/ExtendedPublicKeyImporter.jsx @@ -7,8 +7,9 @@ import { convertExtendedPublicKey, validateExtendedPublicKey, Network, + P2SH, } from "@caravan/bitcoin"; -import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; +import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; import { Card, CardHeader, @@ -68,7 +69,7 @@ class ExtendedPublicKeyImporter extends React.Component { }; renderImport = () => { - const { extendedPublicKeyImporter, number } = this.props; + const { extendedPublicKeyImporter, number, addressType } = this.props; const { disableChangeMethod } = this.state; return (
@@ -82,6 +83,7 @@ class ExtendedPublicKeyImporter extends React.Component { variant="standard" onChange={this.handleMethodChange} > + {addressType != P2SH && BitBox} Trezor Coldcard Ledger @@ -105,7 +107,7 @@ class ExtendedPublicKeyImporter extends React.Component { } = this.props; const { method } = extendedPublicKeyImporter; - if (method === TREZOR || method === LEDGER) { + if (method === BITBOX || method === TREZOR || method === LEDGER) { return ( { + + + diff --git a/apps/coordinator/src/components/Wallet/index.jsx b/apps/coordinator/src/components/Wallet/index.jsx index 3023e881..dbc14a86 100644 --- a/apps/coordinator/src/components/Wallet/index.jsx +++ b/apps/coordinator/src/components/Wallet/index.jsx @@ -150,6 +150,7 @@ class CreateWallet extends React.Component { method: (method, index) => { if ( [ + "bitbox", "trezor", "coldcard", "ledger", diff --git a/apps/coordinator/src/tests/bitbox.js b/apps/coordinator/src/tests/bitbox.js new file mode 100644 index 00000000..e3d073ad --- /dev/null +++ b/apps/coordinator/src/tests/bitbox.js @@ -0,0 +1,13 @@ +import { BITBOX } from "@caravan/wallets"; + +import publicKeyTests from "./publicKeys"; +import extendedPublicKeyTests from "./extendedPublicKeys"; +import { signingTests } from "./signing"; +import addressTests from "./addresses"; +import registrationTests from "./registration"; + +export default publicKeyTests(BITBOX) + .concat(extendedPublicKeyTests(BITBOX)) + .concat(signingTests(BITBOX)) + .concat(addressTests(BITBOX)) + .concat(registrationTests(BITBOX)); diff --git a/apps/coordinator/src/tests/index.js b/apps/coordinator/src/tests/index.js index 3a181836..4b6c6f9d 100644 --- a/apps/coordinator/src/tests/index.js +++ b/apps/coordinator/src/tests/index.js @@ -1,6 +1,7 @@ -import { TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; +import { BITBOX, TREZOR, LEDGER, HERMIT, COLDCARD } from "@caravan/wallets"; import { TEST_FIXTURES } from "@caravan/bitcoin"; +import bitboxTests from "./bitbox"; import trezorTests from "./trezor"; import ledgerTests from "./ledger"; import hermitTests from "./hermit"; @@ -8,6 +9,7 @@ import coldcardTests from "./coldcard"; const SUITE = {}; +SUITE[BITBOX] = bitboxTests; SUITE[TREZOR] = trezorTests; SUITE[LEDGER] = ledgerTests; SUITE[HERMIT] = hermitTests; diff --git a/apps/coordinator/src/tests/registration.jsx b/apps/coordinator/src/tests/registration.jsx index 0a5d8894..efc85dea 100644 --- a/apps/coordinator/src/tests/registration.jsx +++ b/apps/coordinator/src/tests/registration.jsx @@ -1,7 +1,7 @@ import React from "react"; import { TEST_FIXTURES } from "@caravan/bitcoin"; -import { RegisterWalletPolicy } from "@caravan/wallets"; +import { BITBOX, RegisterWalletPolicy } from "@caravan/wallets"; import { Box, Table, TableBody, TableRow, TableCell } from "@mui/material"; import Test from "./Test"; @@ -13,6 +13,10 @@ class RegisterWalletPolicyTest extends Test { } expected() { + if (this.params.keystore === BITBOX) { + // BitBox does not use HMACs to register policies. + return undefined; + } return this.params.policyHmac; } diff --git a/apps/coordinator/src/tests/signing.jsx b/apps/coordinator/src/tests/signing.jsx index 9612030e..194e2b3e 100644 --- a/apps/coordinator/src/tests/signing.jsx +++ b/apps/coordinator/src/tests/signing.jsx @@ -8,6 +8,7 @@ import { TEST_FIXTURES, } from "@caravan/bitcoin"; import { + BITBOX, COLDCARD, HERMIT, LEDGER, @@ -193,6 +194,17 @@ export function signingTests(keystore) { } }); return transactions; + case BITBOX: + return TEST_FIXTURES.transactions + .filter((fixture) => fixture.braidDetails) + .map( + (fixture) => + new SignMultisigTransactionTest({ + ...fixture, + ...{ keystore }, + returnSignatureArray: true, + }), + ); case LEDGER: return TEST_FIXTURES.transactions .filter((fixture) => fixture.policyHmac && fixture.braidDetails) diff --git a/apps/coordinator/vite.config.ts b/apps/coordinator/vite.config.ts index 41e2cfb3..150f450b 100644 --- a/apps/coordinator/vite.config.ts +++ b/apps/coordinator/vite.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ }), ], build: { + target: "esnext", // browsers can handle the latest ES features outDir: "build", rollupOptions: { onwarn(warning, warn) { diff --git a/package-lock.json b/package-lock.json index a6080f3a..5b74feac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28137,6 +28137,7 @@ "@typescript-eslint/parser": "^5.51.0", "babel-jest": "^29.7.0", "babel-plugin-transform-inline-environment-variables": "^0.4.3", + "bitbox-api": "^0.7.0", "esbuild-plugin-polyfill-node": "^0.3.0", "eslint": "^8.34.0", "eslint-plugin-import": "^2.27.5", @@ -28493,6 +28494,12 @@ "node": "*" } }, + "packages/caravan-wallets/node_modules/bitbox-api": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/bitbox-api/-/bitbox-api-0.7.0.tgz", + "integrity": "sha512-uoE6MEV+KyAeX9+P/Gf7ujVFgy1eJWfnpd3Z8n8QXk4OgXha81x3O9PwN1XJv8zcNFUekS1OfsI/yJWvPbQysg==", + "dev": true + }, "packages/caravan-wallets/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 6e9d409f..df2f601e 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -51,6 +51,7 @@ "@typescript-eslint/parser": "^5.51.0", "babel-jest": "^29.7.0", "babel-plugin-transform-inline-environment-variables": "^0.4.3", + "bitbox-api": "^0.7.0", "esbuild-plugin-polyfill-node": "^0.3.0", "eslint": "^8.34.0", "eslint-plugin-import": "^2.27.5", diff --git a/packages/caravan-wallets/src/bitbox.ts b/packages/caravan-wallets/src/bitbox.ts new file mode 100644 index 00000000..75bd71a1 --- /dev/null +++ b/packages/caravan-wallets/src/bitbox.ts @@ -0,0 +1,558 @@ +/*** This module provides classes for BitBox hardware wallets. + * + * The following API classes are implemented: + * + * * BitBoxGetMetadata + * * BitBoxExportPublicKey + * * BitBoxExportExtendedPublicKey + * * BitBoxSignMultisigTransaction + */ + +import { + BitcoinNetwork, + getPsbtVersionNumber, + PsbtV2, + ExtendedPublicKey, + MultisigAddressType, +} from "@caravan/bitcoin"; +import { + ACTIVE, + PENDING, + INFO, + DirectKeystoreInteraction, +} from "./interaction"; + +import { MultisigWalletConfig } from "./types"; + +import { + BtcCoin, + BtcMultisigScriptType, + BtcScriptConfig, + PairedBitBox, +} from 'bitbox-api'; + +/** + * Constant defining BitBox interactions. + */ +export const BITBOX = "bitbox"; + +// Callback for showing the BitBox pairing code. It returns a function to hide it again. +export type TShowPairingCode = (pairingCode: string) => (() => void) | null; + +function convertNetwork(network: BitcoinNetwork): BtcCoin { + switch (network) { + case 'mainnet': + return 'btc'; + case 'regtest': + return 'rbtc'; + default: + return 'tbtc'; + } +} + +function convertToBtcMultisigScriptType(addressType: MultisigAddressType): BtcMultisigScriptType { + switch (addressType) { + case 'P2WSH': + return 'p2wsh'; + case 'P2SH-P2WSH': + return 'p2wshP2sh'; + default: + throw new Error(`BitBox does not support ${addressType} multisig.`); + } +} + +async function convertMultisig(pairedBitBox: PairedBitBox, walletConfig: MultisigWalletConfig): Promise<{ scriptConfig: BtcScriptConfig; keypathAccount: string; }> { + const ourRootFingerprint = await pairedBitBox.rootFingerprint(); + + const ourXpubIndex = walletConfig.extendedPublicKeys.findIndex(key => key.xfp == ourRootFingerprint); + if (ourXpubIndex === -1) { + throw new Error('This BitBox02 seems to not be present in the multisig quorum.'); + } + const scriptConfig = { + multisig: { + threshold: walletConfig.quorum.requiredSigners, + scriptType: convertToBtcMultisigScriptType(walletConfig.addressType), + xpubs: walletConfig.extendedPublicKeys.map(key => key.xpub), + ourXpubIndex, + }, + }; + const keypathAccount = walletConfig.extendedPublicKeys[ourXpubIndex].bip32Path; + return { + scriptConfig, + keypathAccount, + } +} + +/** + * Base class for interactions with BitBox hardware wallets. + * + * Subclasses must implement their own `run()` method. They may use + * the `withDevice` method to connect to the BitBox API. + * + * @example + * import {BitBoxInteraction} from "@caravan/wallets"; + * // Simple subclass + * + * class SimpleBitBoxInteraction extends BitBoxInteraction { + * + * constructor({param}) { + * super({}); + * this.param = param; + * } + * + * async run() { + * return await this.withDevice(async (pairedBitBox) => { + * return pairedBitBox.doSomething(this.param); // Not a real BitBox API call + * }); + * } + * + * } + * + * // usage + * const interaction = new SimpleBitBoxInteraction({param: "foo"}); + * const result = await interaction.run(); + * console.log(result); // whatever value `app.doSomething(...)` returns + * + * The `showPairingCode` callback can be supplied to display and hide the BitBox pairing code. + * If not provided, the default is to open a browser popup showing the pairing code. + */ +export class BitBoxInteraction extends DirectKeystoreInteraction { + appVersion?: string; + + appName?: string; + + showPairingCode?: TShowPairingCode; + + constructor({ showPairingCode }: { showPairingCode?: TShowPairingCode }) { + super(); + this.showPairingCode = showPairingCode; + } + + /** + * Adds `pending` messages at the `info` level about ensuring the + * device is plugged in (`device.connect`) and unlocked + * (`device.unlock`). Adds an `active` message at the `info` level + * when communicating with the device (`device.active`). + */ + messages() { + const messages = super.messages(); + messages.push({ + state: PENDING, + level: INFO, + text: "Please plug in your BitBox.", + code: "device.setup", + }); + messages.push({ + state: ACTIVE, + level: INFO, + text: "Communicating with BitBox...", + code: "device.active", + }); + return messages; + } + + showPairingCodePopup(pairingCode: string): (() => void) | null { + if (this.showPairingCode) { + return this.showPairingCode(pairingCode); + } + const htmlContent = ` + + + + + + BitBox02 pairing + + +

BitBox02 pairing code

+
${pairingCode}
+ + +`; + const popup = window.open( + '', + 'popupWindow', + 'width=400,height=300', + ); + if (popup) { + popup.document.write(htmlContent); + popup.document.close(); + return () => { + popup.close(); + }; + } + return null; + } + + async withDevice(f: (device: PairedBitBox) => Promise): Promise { + const bitbox = await import('bitbox-api'); + + let hidePairingCode: (() => void) | null = null; + try { + const unpaired = await bitbox.bitbox02ConnectAuto(() => { + if (hidePairingCode) { + hidePairingCode(); + } + }) + const pairing = await unpaired.unlockAndPair() + const pairingCode = pairing.getPairingCode() + if (pairingCode) { + hidePairingCode = this.showPairingCodePopup(pairingCode); + // TODO: display pairing code to the user. + console.log('Pairing code:', pairingCode); + } + const pairedBitBox = await pairing.waitConfirm() + if (hidePairingCode) { + hidePairingCode(); + } + try { + return await f(pairedBitBox) + } finally { + pairedBitBox.close() + } + } catch (err) { + const typedErr = bitbox.ensureError(err) + const isErrorUnknown = typedErr.code === 'unknown-js' + const errorMessage = isErrorUnknown ? typedErr.err! : typedErr.message + throw new Error(errorMessage); + } finally { + if (hidePairingCode) { + hidePairingCode(); + } + } + } + + async maybeRegisterMultisig(pairedBitBox: PairedBitBox, walletConfig: MultisigWalletConfig): Promise<{ scriptConfig: BtcScriptConfig, keypathAccount: string; }> { + const { scriptConfig, keypathAccount } = await convertMultisig(pairedBitBox, walletConfig); + const isRegistered = await pairedBitBox.btcIsScriptConfigRegistered( + convertNetwork(walletConfig.network), + scriptConfig, + keypathAccount, + ); + // No name means the user inputs it on the device. + // eslint-disable-next-line no-undefined + const name = undefined; + if (!isRegistered) { + await pairedBitBox.btcRegisterScriptConfig( + convertNetwork(walletConfig.network), + scriptConfig, + keypathAccount, + 'autoXpubTpub', + name, + ); + } + return { scriptConfig, keypathAccount }; + } + + // Dummy to satsify the return types of all subclass run() functions. + async run(): Promise { + } +} + +/** + * Returns metadata about the BitBox device. + * + * @example + * import {BitBoxGetMetadata} from "@caravan/wallets"; + * const interaction = new BitBoxGetMetadata(); + * const result = await interaction.run(); + * console.log(result); + * { + * spec: "bitbox02-btconly 9.18.0", + * version: { + * major: "9", + * minor: "18", + * patch: "0", + * string: "9.18.0"", + * }, + + * model: "bitbox02-btconly", + * } + * + */ +export class BitBoxGetMetadata extends BitBoxInteraction { + constructor({ showPairingCode }: { showPairingCode?: TShowPairingCode }) { + super({ showPairingCode }); + } + + async run() { + return this.withDevice(async (pairedBitBox) => { + const product = pairedBitBox.product(); + const version = pairedBitBox.version(); + const [majorVersion, minorVersion, patchVersion] = (version || "").split( + "." + ); + return { + spec: `${product} v${version}`, + version: { + major: majorVersion, + minor: minorVersion, + patch: patchVersion, + string: version, + }, + model: product, + }; + }); + } +} + +/** + * Class for public key interaction at a given BIP32 path. + */ +export class BitBoxExportPublicKey extends BitBoxInteraction { + network: BitcoinNetwork; + + bip32Path: string; + + includeXFP: boolean; + + /** + * @param {string} bip32Path path + * @param {boolean} includeXFP - return xpub with root fingerprint concatenated + */ + constructor({ showPairingCode, network, bip32Path, includeXFP }: { + showPairingCode?: TShowPairingCode; + network: BitcoinNetwork; + bip32Path: string; + includeXFP: boolean; + }) { + super({ showPairingCode }); + this.network = network; + this.bip32Path = bip32Path; + this.includeXFP = includeXFP; + } + + messages() { + return super.messages(); + } + + /** + * Retrieve the compressed public key for a given BIP32 path + */ + async run() { + return await this.withDevice(async (pairedBitBox) => { + const xpub = await pairedBitBox.btcXpub( + convertNetwork(this.network), + this.bip32Path, + this.network === 'mainnet' ? 'xpub' : 'tpub', + false); + const publicKey = ExtendedPublicKey.fromBase58(xpub).pubkey; + if (this.includeXFP) { + const rootFingerprint = await pairedBitBox.rootFingerprint(); + return { publicKey, rootFingerprint }; + } + return publicKey; + }); + } +} + +/** + * Class for wallet extended public key (xpub) interaction at a given BIP32 path. + */ +export class BitBoxExportExtendedPublicKey extends BitBoxInteraction { + bip32Path: string; + + network: BitcoinNetwork; + + includeXFP: boolean; + + /** + * @param {string} bip32Path path + * @param {string} network bitcoin network + * @param {boolean} includeXFP - return xpub with root fingerprint concatenated + */ + constructor({ showPairingCode, bip32Path, network, includeXFP }: { + showPairingCode?: TShowPairingCode; + network: BitcoinNetwork; + bip32Path: string; + includeXFP: boolean; + }) { + super({ showPairingCode }); + this.bip32Path = bip32Path; + this.network = network; + this.includeXFP = includeXFP; + } + + messages() { + return super.messages(); + } + + /** + * Retrieve extended public key (xpub) from BitBox device for a given BIP32 path + * @example + * import {BitBoxExportExtendedPublicKey} from "@caravan/wallets"; + * const interaction = new BitBoxExportExtendedPublicKey({network, bip32Path}); + * const xpub = await interaction.run(); + * console.log(xpub); + */ + async run() { + return await this.withDevice(async (pairedBitBox) => { + const xpub = await pairedBitBox.btcXpub( + convertNetwork(this.network), + this.bip32Path, + this.network === 'mainnet' ? 'xpub' : 'tpub', + false); + if (this.includeXFP) { + const rootFingerprint = await pairedBitBox.rootFingerprint(); + return { xpub, rootFingerprint }; + } + return xpub; + }); + } +} + +export class BitBoxRegisterWalletPolicy extends BitBoxInteraction { + walletConfig: MultisigWalletConfig; + + constructor({ + showPairingCode, + walletConfig + }: { + showPairingCode?: TShowPairingCode; + walletConfig: MultisigWalletConfig; + }) { + super({ showPairingCode }); + this.walletConfig = walletConfig; + } + + messages() { + const messages = super.messages(); + return messages; + } + + async run() { + return await this.withDevice(async (pairedBitBox) => { + await this.maybeRegisterMultisig(pairedBitBox, this.walletConfig); + // BitBox does not use HMACs for registrations, so we we don't return anything here. + }); + } +} + +export class BitBoxConfirmMultisigAddress extends BitBoxInteraction { + network: BitcoinNetwork; + + bip32Path: string; + + walletConfig: MultisigWalletConfig; + + constructor({ showPairingCode, network, bip32Path, walletConfig }: { + showPairingCode?: TShowPairingCode; + network: BitcoinNetwork; + bip32Path: string; + walletConfig: MultisigWalletConfig; + }) { + super({ showPairingCode }); + this.network = network; + this.bip32Path = bip32Path; + this.walletConfig = walletConfig; + } + + /** + * Adds messages about BIP32 path warnings. + */ + messages() { + const messages = super.messages(); + return messages; + } + + async run() { + const display = true; + return await this.withDevice(async (pairedBitBox) => { + const { scriptConfig } = await this.maybeRegisterMultisig(pairedBitBox, this.walletConfig); + const address = await pairedBitBox.btcAddress( + convertNetwork(this.network), + this.bip32Path, + scriptConfig, + display, + ); + return { + address, + serializedPath: this.bip32Path, + }; + }); + } +} + +function parsePsbt(psbt: string): PsbtV2 { + const psbtVersion = getPsbtVersionNumber(psbt); + switch (psbtVersion) { + case 0: + return PsbtV2.FromV0(psbt, true); + case 2: + return new PsbtV2(psbt); + default: + throw new Error(`PSBT of unsupported version ${psbtVersion}`); + } +} + +export class BitBoxSignMultisigTransaction extends BitBoxInteraction { + private walletConfig: MultisigWalletConfig; + + private returnSignatureArray: boolean; + + // keeping this until we have a way to add signatures to psbtv2 directly + // this will store the the PSBT that was was passed in via args + private unsignedPsbt: string; + + constructor({ + showPairingCode, + walletConfig, + psbt, + returnSignatureArray = false, + }: { + showPairingCode?: TShowPairingCode; + walletConfig: MultisigWalletConfig; + psbt: any; + returnSignatureArray: boolean; + }) { + super({ showPairingCode }); + this.walletConfig = walletConfig; + this.returnSignatureArray = returnSignatureArray; + + this.unsignedPsbt = Buffer.isBuffer(psbt) ? psbt.toString("base64") : psbt; + } + + async run() { + return await this.withDevice(async (pairedBitBox) => { + const { scriptConfig, keypathAccount } = await this.maybeRegisterMultisig(pairedBitBox, this.walletConfig); + const signedPsbt = await pairedBitBox.btcSignPSBT( + convertNetwork(this.walletConfig.network), + this.unsignedPsbt, + { + scriptConfig, + keypath: keypathAccount, + }, + 'default', + ); + if (this.returnSignatureArray) { + // Extract the sigs for that belong to us (identified by the root fingerprint). + // This only works reliably if the fingerprint is unique, i.e. all signers have different + // fingerprints. + const ourRootFingerprint = await pairedBitBox.rootFingerprint(); + const parsedPsbt = parsePsbt(signedPsbt); + let sigArray: string[] = []; + for (let i = 0; i < parsedPsbt.PSBT_GLOBAL_INPUT_COUNT; i++) { + const bip32Derivations = parsedPsbt.PSBT_IN_BIP32_DERIVATION[i]; + if (!Array.isArray(bip32Derivations)) { + throw new Error('bip32 derivations expected to be an array'); + } + const bip32Derivation = bip32Derivations.find(entry => entry.value!.substr(0, 8) == ourRootFingerprint); + if (!bip32Derivation) { + throw new Error('could not find our pubkey in the signed PSBT'); + } + // First byte of the key is 0x06, the PSBT key. + const pubKey = bip32Derivation.key.substr(2); + // First byte of the key is 0x02, the PSBT key. + const partialSig = parsedPsbt.PSBT_IN_PARTIAL_SIG[i].find(e => e.key.substr(2) === pubKey); + if (!partialSig) { + throw new Error('could not find our signature in the signed PSBT'); + } + sigArray.push(partialSig.value!); + } + + return sigArray; + } + return signedPsbt; + }); + } +} diff --git a/packages/caravan-wallets/src/index.ts b/packages/caravan-wallets/src/index.ts index cfeec789..7fef3746 100644 --- a/packages/caravan-wallets/src/index.ts +++ b/packages/caravan-wallets/src/index.ts @@ -2,6 +2,15 @@ // @ts-ignore import { version } from "../package.json"; import { UNSUPPORTED, UnsupportedInteraction } from "./interaction"; +import { + BITBOX, + BitBoxGetMetadata, + BitBoxExportPublicKey, + BitBoxExportExtendedPublicKey, + BitBoxConfirmMultisigAddress, + BitBoxRegisterWalletPolicy, + BitBoxSignMultisigTransaction, +} from "./bitbox"; import { COLDCARD, ColdcardExportPublicKey, @@ -65,6 +74,7 @@ export const MULTISIG_ROOT = "m/45'"; * Keystores which support direct interactions. */ export const DIRECT_KEYSTORES = { + BITBOX, TREZOR, LEDGER, LEDGER_V2, @@ -94,7 +104,7 @@ export type KEYSTORE_TYPES = (typeof KEYSTORES)[KEYSTORE_KEYS]; * Return an interaction class for obtaining metadata from the given * `keystore`. * - * **Supported keystores:** Trezor, Ledger + * **Supported keystores:** BitBox, Trezor, Ledger * * @example * import {GetMetadata, TREZOR} from "@caravan/wallets"; @@ -104,6 +114,8 @@ export type KEYSTORE_TYPES = (typeof KEYSTORES)[KEYSTORE_KEYS]; */ export function GetMetadata({ keystore }: { keystore: KEYSTORE_TYPES }) { switch (keystore) { + case BITBOX: + return new BitBoxGetMetadata({}); case LEDGER: return new LedgerGetMetadata(); case TREZOR: @@ -141,6 +153,12 @@ export function ExportPublicKey({ includeXFP: boolean; }) { switch (keystore) { + case BITBOX: + return new BitBoxExportPublicKey({ + network, + bip32Path, + includeXFP, + }); case COLDCARD: return new ColdcardExportPublicKey({ network, @@ -224,6 +242,12 @@ export function ExportExtendedPublicKey({ includeXFP: boolean; }) { switch (keystore) { + case BITBOX: + return new BitBoxExportExtendedPublicKey({ + bip32Path, + network, + includeXFP, + }); case COLDCARD: return new ColdcardExportExtendedPublicKey({ bip32Path, @@ -335,6 +359,20 @@ export function SignMultisigTransaction({ progressCallback, }: SignMultisigTransactionArgs) { switch (keystore) { + case BITBOX: { + let _psbt = psbt; + if (!_psbt) + _psbt = getUnsignedMultisigPsbtV0({ + network, + inputs: inputs ? inputs.map(convertLegacyInput) : [], + outputs: outputs ? outputs.map(convertLegacyOutput) : [], + }).toBase64(); + return new BitBoxSignMultisigTransaction({ + walletConfig, + psbt, + returnSignatureArray, + }); + } case COLDCARD: return new ColdcardSignMultisigTransaction({ network, @@ -480,6 +518,16 @@ export function ConfirmMultisigAddress({ walletConfig?: MultisigWalletConfig; }) { switch (keystore) { + case BITBOX: { + const braidDetails: BraidDetails = JSON.parse(multisig.braidDetails); + const _walletConfig = + walletConfig || braidDetailsToWalletConfig(braidDetails); + return new BitBoxConfirmMultisigAddress({ + network, + bip32Path, + walletConfig: _walletConfig, + }); + } case TREZOR: return new TrezorConfirmMultisigAddress({ network, @@ -514,7 +562,7 @@ export function ConfirmMultisigAddress({ /** * Return a class for registering a wallet policy. - * **Supported keystores:** Ledger + * **Supported keystores:** BitBox, Ledger */ // TODO: superfluous with the ConfigAdapter? // This name sounds better, but ConfigAdapter can cover Coldcard too @@ -529,6 +577,10 @@ export function RegisterWalletPolicy({ verify: boolean; } & MultisigWalletConfig) { switch (keystore) { + case BITBOX: + return new BitBoxRegisterWalletPolicy({ + walletConfig, + }); case LEDGER: return new LedgerRegisterWalletPolicy({ ...walletConfig, @@ -557,6 +609,17 @@ export function ConfigAdapter({ policyHmac?: string; }) { switch (KEYSTORE) { + case BITBOX: { + let walletConfig: MultisigWalletConfig; + if (typeof jsonConfig === "string") { + walletConfig = JSON.parse(jsonConfig); + } else { + walletConfig = jsonConfig; + } + return new BitBoxRegisterWalletPolicy({ + walletConfig, + }); + } case COLDCARD: return new ColdcardMultisigWalletConfig({ jsonConfig, @@ -580,6 +643,7 @@ export function ConfigAdapter({ } export * from "./interaction"; +export * from "./bitbox"; export * from "./bcur"; export * from "./coldcard"; export * from "./custom"; diff --git a/packages/caravan-wallets/tsconfig.json b/packages/caravan-wallets/tsconfig.json index eb0f52c7..101237c8 100644 --- a/packages/caravan-wallets/tsconfig.json +++ b/packages/caravan-wallets/tsconfig.json @@ -2,6 +2,8 @@ "compilerOptions": { // be explicit about the root directory of the project "rootDir": "./src", + // Allow dynamic imports. + "module": "esnext", // Target latest version of ECMAScript. "target": "esnext", // Search under node_modules for non-relative imports. diff --git a/packages/caravan-wallets/tsup.config.ts b/packages/caravan-wallets/tsup.config.ts index 300c38ad..b80f05bf 100644 --- a/packages/caravan-wallets/tsup.config.ts +++ b/packages/caravan-wallets/tsup.config.ts @@ -7,4 +7,5 @@ export default defineConfig({ globals: { process: false }, }), ], + external: ['bitbox-api'], }); From 4776fe01f0c6049eba36c6f17be29d5e723788fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:22:22 -0600 Subject: [PATCH 08/19] Version Packages (#145) Co-authored-by: github-actions[bot] --- .changeset/heavy-kids-confess.md | 6 ------ apps/coordinator/CHANGELOG.md | 11 +++++++++++ apps/coordinator/package.json | 2 +- package-lock.json | 4 ++-- packages/caravan-wallets/CHANGELOG.md | 6 ++++++ packages/caravan-wallets/package.json | 2 +- 6 files changed, 21 insertions(+), 10 deletions(-) delete mode 100644 .changeset/heavy-kids-confess.md diff --git a/.changeset/heavy-kids-confess.md b/.changeset/heavy-kids-confess.md deleted file mode 100644 index 0a9a5c09..00000000 --- a/.changeset/heavy-kids-confess.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@caravan/wallets": minor -"caravan-coordinator": minor ---- - -Add support for the BitBox02 hardware wallet diff --git a/apps/coordinator/CHANGELOG.md b/apps/coordinator/CHANGELOG.md index dbef99b2..3636ace0 100644 --- a/apps/coordinator/CHANGELOG.md +++ b/apps/coordinator/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.4.0 + +### Minor Changes + +- [#117](https://github.com/caravan-bitcoin/caravan/pull/117) [`d0e08e8`](https://github.com/caravan-bitcoin/caravan/commit/d0e08e872a63011d2ed72b6b9a68e54db1649b44) Thanks [@benma](https://github.com/benma)! - Add support for the BitBox02 hardware wallet + +### Patch Changes + +- Updated dependencies [[`d0e08e8`](https://github.com/caravan-bitcoin/caravan/commit/d0e08e872a63011d2ed72b6b9a68e54db1649b44)]: + - @caravan/wallets@0.3.0 + ## 1.3.0 ### Minor Changes diff --git a/apps/coordinator/package.json b/apps/coordinator/package.json index 9e2caed9..e888de55 100644 --- a/apps/coordinator/package.json +++ b/apps/coordinator/package.json @@ -1,7 +1,7 @@ { "name": "caravan-coordinator", "private": true, - "version": "1.3.0", + "version": "1.4.0", "description": "Unchained Capital's Bitcoin Multisig Coordinator Application", "main": "index.jsx", "type": "module", diff --git a/package-lock.json b/package-lock.json index 5b74feac..b1349dd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ }, "apps/coordinator": { "name": "caravan-coordinator", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@caravan/bip32": "*", @@ -28101,7 +28101,7 @@ }, "packages/caravan-wallets": { "name": "@caravan/wallets", - "version": "0.2.4", + "version": "0.3.0", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.7.0", diff --git a/packages/caravan-wallets/CHANGELOG.md b/packages/caravan-wallets/CHANGELOG.md index 5036df68..299b498f 100644 --- a/packages/caravan-wallets/CHANGELOG.md +++ b/packages/caravan-wallets/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.0 + +### Minor Changes + +- [#117](https://github.com/caravan-bitcoin/caravan/pull/117) [`d0e08e8`](https://github.com/caravan-bitcoin/caravan/commit/d0e08e872a63011d2ed72b6b9a68e54db1649b44) Thanks [@benma](https://github.com/benma)! - Add support for the BitBox02 hardware wallet + ## 0.2.4 ### Patch Changes diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index df2f601e..7ad56b80 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/wallets", - "version": "0.2.4", + "version": "0.3.0", "description": "Unchained Capital's HWI Library", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 7607c24c287053382bd3ea8632a3ad0b353287dc Mon Sep 17 00:00:00 2001 From: buck Date: Sun, 10 Nov 2024 15:21:20 -0600 Subject: [PATCH 09/19] fix: fees package private (#146) --- packages/caravan-fees/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/caravan-fees/package.json b/packages/caravan-fees/package.json index 0c0633f7..98de227c 100644 --- a/packages/caravan-fees/package.json +++ b/packages/caravan-fees/package.json @@ -5,6 +5,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "module": "./dist/index.mjs", + "private": true, "exports": { ".": { "require": "./dist/index.js", From 0a73b094984fd59c7564eda0fa31eb8f05b96927 Mon Sep 17 00:00:00 2001 From: buck Date: Wed, 13 Nov 2024 20:45:18 -0600 Subject: [PATCH 10/19] Translate segwit psbt (#147) * chore: fix turbo.json for test runner * update deps * feat: dds support for translatePSBT for segwit PSBTs * pass key details to sig interaction just in case --- .changeset/shy-dogs-wonder.md | 7 + .../DirectSignatureImporter.jsx | 6 +- package-lock.json | 445 +++++++----------- packages/caravan-psbt/package.json | 1 + packages/caravan-psbt/src/psbtv0/psbt.test.ts | 55 ++- packages/caravan-psbt/src/psbtv0/psbt.ts | 17 +- turbo.json | 4 +- 7 files changed, 242 insertions(+), 293 deletions(-) create mode 100644 .changeset/shy-dogs-wonder.md diff --git a/.changeset/shy-dogs-wonder.md b/.changeset/shy-dogs-wonder.md new file mode 100644 index 00000000..56c4fc67 --- /dev/null +++ b/.changeset/shy-dogs-wonder.md @@ -0,0 +1,7 @@ +--- +"@caravan/psbt": minor +"@caravan/wallets": minor +"caravan-coordinator": patch +--- + +Adds support for translatePSBT for segwit PSBTs. This enables loading tx data directly from a psbt for ledger and trezor diff --git a/apps/coordinator/src/components/ScriptExplorer/DirectSignatureImporter.jsx b/apps/coordinator/src/components/ScriptExplorer/DirectSignatureImporter.jsx index c2107aef..6e1b2f04 100644 --- a/apps/coordinator/src/components/ScriptExplorer/DirectSignatureImporter.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/DirectSignatureImporter.jsx @@ -56,7 +56,10 @@ class DirectSignatureImporter extends React.Component { const policyHmac = walletConfig.ledgerPolicyHmacs.find( (hmac) => hmac.xfp === extendedPublicKeyImporter.rootXfp, )?.policyHmac; - + const keyDetails = { + xfp: extendedPublicKeyImporter.rootXfp, + path: signatureImporter.bip32Path, + }; return SignMultisigTransaction({ network, keystore, @@ -65,6 +68,7 @@ class DirectSignatureImporter extends React.Component { bip32Paths, walletConfig, policyHmac, + keyDetails, psbt: unsignedPSBT, returnSignatureArray: true, }); diff --git a/package-lock.json b/package-lock.json index b1349dd5..949312d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6230,208 +6230,252 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.26.0.tgz", + "integrity": "sha512-gJNwtPDGEaOEgejbaseY6xMFu+CPltsc8/T+diUTTbOQLqD+bnrJq9ulH6WD69TqwqWmrfRAtUv30cCFZlbGTQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.26.0.tgz", + "integrity": "sha512-YJa5Gy8mEZgz5JquFruhJODMq3lTHWLm1fOy+HIANquLzfIOzE9RA5ie3JjCdVb9r46qfAQY/l947V0zfGJ0OQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.26.0.tgz", + "integrity": "sha512-ErTASs8YKbqTBoPLp/kA1B1Um5YSom8QAc4rKhg7b9tyyVqDBlQxy7Bf2wW7yIlPGPg2UODDQcbkTlruPzDosw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.26.0.tgz", + "integrity": "sha512-wbgkYDHcdWW+NqP2mnf2NOuEbOLzDblalrOWcPyY6+BRbVhliavon15UploG7PpBRQ2bZJnbmh8o3yLoBvDIHA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.26.0.tgz", + "integrity": "sha512-Y9vpjfp9CDkAG4q/uwuhZk96LP11fBz/bYdyg9oaHYhtGZp7NrbkQrj/66DYMMP2Yo/QPAsVHkV891KyO52fhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.26.0.tgz", + "integrity": "sha512-A/jvfCZ55EYPsqeaAt/yDAG4q5tt1ZboWMHEvKAH9Zl92DWvMIbnZe/f/eOXze65aJaaKbL+YeM0Hz4kLQvdwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.26.0.tgz", + "integrity": "sha512-paHF1bMXKDuizaMODm2bBTjRiHxESWiIyIdMugKeLnjuS1TCS54MF5+Y5Dx8Ui/1RBPVRE09i5OUlaLnv8OGnA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz", - "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.26.0.tgz", + "integrity": "sha512-cwxiHZU1GAs+TMxvgPfUDtVZjdBdTsQwVnNlzRXC5QzIJ6nhfB4I1ahKoe9yPmoaA/Vhf7m9dB1chGPpDRdGXg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.26.0.tgz", + "integrity": "sha512-4daeEUQutGRCW/9zEo8JtdAgtJ1q2g5oHaoQaZbMSKaIWKDQwQ3Yx0/3jJNmpzrsScIPtx/V+1AfibLisb3AMQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.26.0.tgz", + "integrity": "sha512-eGkX7zzkNxvvS05ROzJ/cO/AKqNvR/7t1jA3VZDi2vRniLKwAWxUr85fH3NsvtxU5vnUUKFHKh8flIBdlo2b3Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz", - "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.26.0.tgz", + "integrity": "sha512-Odp/lgHbW/mAqw/pU21goo5ruWsytP7/HCC/liOt0zcGG0llYWKrd10k9Fj0pdj3prQ63N5yQLCLiE7HTX+MYw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.26.0.tgz", + "integrity": "sha512-MBR2ZhCTzUgVD0OJdTzNeF4+zsVogIR1U/FsyuFerwcqjZGvg2nYe24SAHp8O5sN8ZkRVbHwlYeHqcSQ8tcYew==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz", - "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.26.0.tgz", + "integrity": "sha512-YYcg8MkbN17fMbRMZuxwmxWqsmQufh3ZJFxFGoHjrE7bv0X+T6l3glcdzd7IKLiwhT+PZOJCblpnNlz1/C3kGQ==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.26.0.tgz", + "integrity": "sha512-ZuwpfjCwjPkAOxpjAEjabg6LRSfL7cAJb6gSQGZYjGhadlzKKywDkCUnJ+KEfrNY1jH5EEoSIKLCb572jSiglA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.26.0.tgz", + "integrity": "sha512-+HJD2lFS86qkeF8kNu0kALtifMpPCZU80HvwztIKnYwym3KnA1os6nsX4BGSTLtS2QVAGG1P3guRgsYyMA0Yhg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.26.0.tgz", + "integrity": "sha512-WUQzVFWPSw2uJzX4j6YEbMAiLbs0BUysgysh8s817doAYhR5ybqTI1wtKARQKo6cGop3pHnrUJPFCsXdoFaimQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.26.0.tgz", + "integrity": "sha512-D4CxkazFKBfN1akAIY6ieyOqzoOoBV1OICxgUblWxff/pSjCA2khXlASUx7mK6W1oP4McqhgcCsu6QaLj3WMWg==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.26.0.tgz", + "integrity": "sha512-2x8MO1rm4PGEP0xWbubJW5RtbNLk3puzAMaLQd3B3JHVw4KcHlmXcO+Wewx9zCoo7EUFiMlu/aZbCJ7VjMzAag==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -11053,9 +11097,10 @@ "integrity": "sha512-wA2A2LQCqnEwQAvwADQq3KpMpNwgAUBnRmrFgRzHnPhbQUFArTR32Ab46f4p0MovDLcg4uqd4nCsN2hTltslpA==" }, "node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "license": "MIT", "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", @@ -19290,9 +19335,10 @@ } }, "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "license": "MIT", "dependencies": { "isarray": "0.0.1" } @@ -21004,9 +21050,10 @@ } }, "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -22866,10 +22913,11 @@ } }, "node_modules/tsup/node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -23612,9 +23660,10 @@ } }, "node_modules/vite": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", - "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", + "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", + "license": "MIT", "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -23691,9 +23740,10 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -26915,6 +26965,13 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "packages/caravan-clients/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "packages/caravan-clients/node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -27225,12 +27282,13 @@ } }, "packages/caravan-clients/node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.26.0.tgz", + "integrity": "sha512-ilcl12hnWonG8f+NxU6BlgysVA0gvY2l8N0R84S1HcINbW20bvwuCngJkkInV6LXhwRpucsW5k1ovDwEdBVrNg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -27240,19 +27298,24 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", + "@rollup/rollup-android-arm-eabi": "4.26.0", + "@rollup/rollup-android-arm64": "4.26.0", + "@rollup/rollup-darwin-arm64": "4.26.0", + "@rollup/rollup-darwin-x64": "4.26.0", + "@rollup/rollup-freebsd-arm64": "4.26.0", + "@rollup/rollup-freebsd-x64": "4.26.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.26.0", + "@rollup/rollup-linux-arm-musleabihf": "4.26.0", + "@rollup/rollup-linux-arm64-gnu": "4.26.0", + "@rollup/rollup-linux-arm64-musl": "4.26.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.26.0", + "@rollup/rollup-linux-riscv64-gnu": "4.26.0", + "@rollup/rollup-linux-s390x-gnu": "4.26.0", + "@rollup/rollup-linux-x64-gnu": "4.26.0", + "@rollup/rollup-linux-x64-musl": "4.26.0", + "@rollup/rollup-win32-arm64-msvc": "4.26.0", + "@rollup/rollup-win32-ia32-msvc": "4.26.0", + "@rollup/rollup-win32-x64-msvc": "4.26.0", "fsevents": "~2.3.2" } }, @@ -27435,6 +27498,7 @@ "uint8array-tools": "^0.0.7" }, "devDependencies": { + "@caravan/bip32": "*", "@caravan/eslint-config": "*", "@caravan/multisig": "*", "@caravan/typescript-config": "*", @@ -30250,174 +30314,12 @@ "node": ">=12" } }, - "packages/multisig/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", - "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-android-arm64": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz", - "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz", - "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz", - "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz", - "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz", - "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz", - "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz", - "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz", - "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz", - "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz", - "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz", - "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "packages/multisig/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz", - "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==", - "cpu": [ - "x64" - ], + "packages/multisig/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true, - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "packages/multisig/node_modules/@types/jest": { "version": "29.5.12", @@ -30609,12 +30511,13 @@ } }, "packages/multisig/node_modules/rollup": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", - "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.26.0.tgz", + "integrity": "sha512-ilcl12hnWonG8f+NxU6BlgysVA0gvY2l8N0R84S1HcINbW20bvwuCngJkkInV6LXhwRpucsW5k1ovDwEdBVrNg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -30624,22 +30527,24 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.17.2", - "@rollup/rollup-android-arm64": "4.17.2", - "@rollup/rollup-darwin-arm64": "4.17.2", - "@rollup/rollup-darwin-x64": "4.17.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", - "@rollup/rollup-linux-arm-musleabihf": "4.17.2", - "@rollup/rollup-linux-arm64-gnu": "4.17.2", - "@rollup/rollup-linux-arm64-musl": "4.17.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", - "@rollup/rollup-linux-riscv64-gnu": "4.17.2", - "@rollup/rollup-linux-s390x-gnu": "4.17.2", - "@rollup/rollup-linux-x64-gnu": "4.17.2", - "@rollup/rollup-linux-x64-musl": "4.17.2", - "@rollup/rollup-win32-arm64-msvc": "4.17.2", - "@rollup/rollup-win32-ia32-msvc": "4.17.2", - "@rollup/rollup-win32-x64-msvc": "4.17.2", + "@rollup/rollup-android-arm-eabi": "4.26.0", + "@rollup/rollup-android-arm64": "4.26.0", + "@rollup/rollup-darwin-arm64": "4.26.0", + "@rollup/rollup-darwin-x64": "4.26.0", + "@rollup/rollup-freebsd-arm64": "4.26.0", + "@rollup/rollup-freebsd-x64": "4.26.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.26.0", + "@rollup/rollup-linux-arm-musleabihf": "4.26.0", + "@rollup/rollup-linux-arm64-gnu": "4.26.0", + "@rollup/rollup-linux-arm64-musl": "4.26.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.26.0", + "@rollup/rollup-linux-riscv64-gnu": "4.26.0", + "@rollup/rollup-linux-s390x-gnu": "4.26.0", + "@rollup/rollup-linux-x64-gnu": "4.26.0", + "@rollup/rollup-linux-x64-musl": "4.26.0", + "@rollup/rollup-win32-arm64-msvc": "4.26.0", + "@rollup/rollup-win32-ia32-msvc": "4.26.0", + "@rollup/rollup-win32-x64-msvc": "4.26.0", "fsevents": "~2.3.2" } }, diff --git a/packages/caravan-psbt/package.json b/packages/caravan-psbt/package.json index c48ebcc4..cd68707f 100644 --- a/packages/caravan-psbt/package.json +++ b/packages/caravan-psbt/package.json @@ -51,6 +51,7 @@ "author": "unchained capital", "license": "ISC", "devDependencies": { + "@caravan/bip32": "*", "@caravan/eslint-config": "*", "@caravan/multisig": "*", "@caravan/typescript-config": "*", diff --git a/packages/caravan-psbt/src/psbtv0/psbt.test.ts b/packages/caravan-psbt/src/psbtv0/psbt.test.ts index 4204d912..cdc76d3d 100644 --- a/packages/caravan-psbt/src/psbtv0/psbt.test.ts +++ b/packages/caravan-psbt/src/psbtv0/psbt.test.ts @@ -4,6 +4,7 @@ import { generateMultisigFromHex, + getRelativeBIP32Path, P2WSH, ROOT_FINGERPRINT, TEST_FIXTURES, @@ -15,6 +16,8 @@ import { } from "./psbt"; import _ from "lodash"; import { psbtArgsFromFixture } from "./utils"; +import assert from "assert"; +import { combineBip32Paths } from "@caravan/bip32"; describe("getUnsignedMultisigPsbtV0", () => { TEST_FIXTURES.transactions @@ -105,20 +108,46 @@ describe("translatePsbt", () => { const tx = _.cloneDeep(TEST_FIXTURES.transactions[0]); const ms = MULTISIGS[0]; + it("handles P2WSH transactions", () => { + const fixture = TEST_FIXTURES.transactions.find((tx) => tx.segwit); + const signingKey = fixture.braidDetails.extendedPublicKeys[0]; + const psbt = fixture.psbt; + const translated = translatePSBT(tx.network, P2WSH, psbt, { + xfp: signingKey.rootFingerprint, + path: signingKey.path, + }); + // make typescript happy in the desctructuring + assert(translated !== null); + + const { unchainedInputs, unchainedOutputs, bip32Derivations } = translated; + expect(unchainedInputs).toHaveLength(fixture.inputs.length); + expect(unchainedOutputs).toHaveLength(fixture.outputs.length); + expect(bip32Derivations).toHaveLength(fixture.signature.length); + + for (const input of unchainedInputs) { + const match = fixture.inputs.find( + (fixtureInput) => input.txid === fixtureInput.txid, + ); + expect(match).toBeDefined(); + expect(+input.amountSats).toEqual(+match.amountSats); + } - it("throws error with non-p2sh address type", () => { - expect(() => - translatePSBT( - tx.network, - P2WSH, - // @ts-expect-error - we are testing an error case - {}, - { - xfp: ROOT_FINGERPRINT, - path: "m/45'/1'/100'", - }, - ), - ).toThrow(/Unsupported addressType/i); + for (const output of unchainedOutputs) { + const match = fixture.outputs.find( + (fixtureOutput) => output.address === fixtureOutput.address, + ); + expect(match).toBeDefined(); + expect(+output.amountSats).toEqual(+match.amountSats); + } + + for (const derivation of bip32Derivations) { + expect(derivation.masterFingerprint.toString("hex")).toEqual( + signingKey.rootFingerprint, + ); + const path = getRelativeBIP32Path(signingKey.path, derivation.path); + const combined = combineBip32Paths(signingKey.path, `m/${path}`); + expect(combined).toEqual(derivation.path); + } }); it(`returns the inputs/outputs translated from the psbt`, () => { diff --git a/packages/caravan-psbt/src/psbtv0/psbt.ts b/packages/caravan-psbt/src/psbtv0/psbt.ts index c3d30a34..b6bb9d49 100644 --- a/packages/caravan-psbt/src/psbtv0/psbt.ts +++ b/packages/caravan-psbt/src/psbtv0/psbt.ts @@ -8,6 +8,7 @@ import { Network, networkData, P2SH, + P2WSH, signatureNoSighashType, } from "@caravan/bitcoin"; import { Psbt, Transaction } from "bitcoinjs-lib-v6"; @@ -288,12 +289,6 @@ export function translatePSBT( psbt: string, signingKeyDetails, ) { - if (addressType !== P2SH) { - throw new Error( - "Unsupported addressType -- only P2SH is supported right now", - ); - } - const localPSBT = autoLoadPSBT(psbt, { network: networkData(network) }); if (localPSBT === null) return null; @@ -333,14 +328,20 @@ export function translatePSBT( function getUnchainedInputsFromPSBT(network, addressType, psbt) { return psbt.txInputs.map((input, index) => { const dataInput = psbt.data.inputs[index]; + const spendingScript = dataInput.witnessScript || dataInput.redeemScript; + + if (!dataInput?.nonWitnessUtxo && addressType === P2WSH) { + // https://blog.trezor.io/details-of-firmware-updates-for-trezor-one-version-1-9-1-and-trezor-model-t-version-2-3-1-1eba8f60f2dd + throw new Error(`Non-witness UTXO now required for P2WSH +inputs to protect against large fee attack`); + } - // FIXME - this is where we're currently only handling P2SH correctly const fundingTxHex = dataInput.nonWitnessUtxo.toString("hex"); const fundingTx = Transaction.fromHex(fundingTxHex); const multisig = generateMultisigFromHex( network, addressType, - dataInput.redeemScript.toString("hex"), + spendingScript.toString("hex"), ); return { diff --git a/turbo.json b/turbo.json index bfaca968..6e8502b6 100644 --- a/turbo.json +++ b/turbo.json @@ -23,7 +23,9 @@ "test:watch": { "dependsOn": [ "^build" - ] + ], + "cache": false, + "persistent": true }, "dev": { "cache": false, From 7d9b5c9f03b622345e2f03ac4ab70e90129bb192 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 Nov 2024 20:53:08 -0600 Subject: [PATCH 11/19] Version Packages (#148) Co-authored-by: github-actions[bot] --- .changeset/shy-dogs-wonder.md | 7 ------- apps/coordinator/CHANGELOG.md | 10 ++++++++++ apps/coordinator/package.json | 2 +- package-lock.json | 6 +++--- packages/caravan-psbt/CHANGELOG.md | 6 ++++++ packages/caravan-psbt/package.json | 2 +- packages/caravan-wallets/CHANGELOG.md | 11 +++++++++++ packages/caravan-wallets/package.json | 2 +- 8 files changed, 33 insertions(+), 13 deletions(-) delete mode 100644 .changeset/shy-dogs-wonder.md diff --git a/.changeset/shy-dogs-wonder.md b/.changeset/shy-dogs-wonder.md deleted file mode 100644 index 56c4fc67..00000000 --- a/.changeset/shy-dogs-wonder.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@caravan/psbt": minor -"@caravan/wallets": minor -"caravan-coordinator": patch ---- - -Adds support for translatePSBT for segwit PSBTs. This enables loading tx data directly from a psbt for ledger and trezor diff --git a/apps/coordinator/CHANGELOG.md b/apps/coordinator/CHANGELOG.md index 3636ace0..c8753070 100644 --- a/apps/coordinator/CHANGELOG.md +++ b/apps/coordinator/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.4.1 + +### Patch Changes + +- [#147](https://github.com/caravan-bitcoin/caravan/pull/147) [`0a73b09`](https://github.com/caravan-bitcoin/caravan/commit/0a73b094984fd59c7564eda0fa31eb8f05b96927) Thanks [@bucko13](https://github.com/bucko13)! - Adds support for translatePSBT for segwit PSBTs. This enables loading tx data directly from a psbt for ledger and trezor + +- Updated dependencies [[`0a73b09`](https://github.com/caravan-bitcoin/caravan/commit/0a73b094984fd59c7564eda0fa31eb8f05b96927)]: + - @caravan/psbt@1.6.0 + - @caravan/wallets@0.4.0 + ## 1.4.0 ### Minor Changes diff --git a/apps/coordinator/package.json b/apps/coordinator/package.json index e888de55..c8a4c63c 100644 --- a/apps/coordinator/package.json +++ b/apps/coordinator/package.json @@ -1,7 +1,7 @@ { "name": "caravan-coordinator", "private": true, - "version": "1.4.0", + "version": "1.4.1", "description": "Unchained Capital's Bitcoin Multisig Coordinator Application", "main": "index.jsx", "type": "module", diff --git a/package-lock.json b/package-lock.json index 949312d1..85a13918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ }, "apps/coordinator": { "name": "caravan-coordinator", - "version": "1.4.0", + "version": "1.4.1", "license": "MIT", "dependencies": { "@caravan/bip32": "*", @@ -27485,7 +27485,7 @@ }, "packages/caravan-psbt": { "name": "@caravan/psbt", - "version": "1.5.0", + "version": "1.6.0", "license": "ISC", "dependencies": { "@caravan/bitcoin": "*", @@ -28165,7 +28165,7 @@ }, "packages/caravan-wallets": { "name": "@caravan/wallets", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.7.0", diff --git a/packages/caravan-psbt/CHANGELOG.md b/packages/caravan-psbt/CHANGELOG.md index 1a02c3c6..3b8eb9bd 100644 --- a/packages/caravan-psbt/CHANGELOG.md +++ b/packages/caravan-psbt/CHANGELOG.md @@ -1,5 +1,11 @@ # @caravan/psbt +## 1.6.0 + +### Minor Changes + +- [#147](https://github.com/caravan-bitcoin/caravan/pull/147) [`0a73b09`](https://github.com/caravan-bitcoin/caravan/commit/0a73b094984fd59c7564eda0fa31eb8f05b96927) Thanks [@bucko13](https://github.com/bucko13)! - Adds support for translatePSBT for segwit PSBTs. This enables loading tx data directly from a psbt for ledger and trezor + ## 1.5.0 ### Minor Changes diff --git a/packages/caravan-psbt/package.json b/packages/caravan-psbt/package.json index cd68707f..09972180 100644 --- a/packages/caravan-psbt/package.json +++ b/packages/caravan-psbt/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/psbt", - "version": "1.5.0", + "version": "1.6.0", "description": "typescript library for working with PSBTs", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/caravan-wallets/CHANGELOG.md b/packages/caravan-wallets/CHANGELOG.md index 299b498f..9d7d5b4c 100644 --- a/packages/caravan-wallets/CHANGELOG.md +++ b/packages/caravan-wallets/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.4.0 + +### Minor Changes + +- [#147](https://github.com/caravan-bitcoin/caravan/pull/147) [`0a73b09`](https://github.com/caravan-bitcoin/caravan/commit/0a73b094984fd59c7564eda0fa31eb8f05b96927) Thanks [@bucko13](https://github.com/bucko13)! - Adds support for translatePSBT for segwit PSBTs. This enables loading tx data directly from a psbt for ledger and trezor + +### Patch Changes + +- Updated dependencies [[`0a73b09`](https://github.com/caravan-bitcoin/caravan/commit/0a73b094984fd59c7564eda0fa31eb8f05b96927)]: + - @caravan/psbt@1.6.0 + ## 0.3.0 ### Minor Changes diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 7ad56b80..70a19b19 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/wallets", - "version": "0.3.0", + "version": "0.4.0", "description": "Unchained Capital's HWI Library", "main": "./dist/index.js", "types": "./dist/index.d.ts", From baee2cd3c95db7ead90ff672ad1ce506cf2c6a57 Mon Sep 17 00:00:00 2001 From: buck Date: Wed, 13 Nov 2024 21:46:16 -0600 Subject: [PATCH 12/19] fix: move bitbox to normal dependency (#149) --- .changeset/friendly-rabbits-smile.md | 5 +++++ package-lock.json | 5 ++--- packages/caravan-wallets/package.json | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 .changeset/friendly-rabbits-smile.md diff --git a/.changeset/friendly-rabbits-smile.md b/.changeset/friendly-rabbits-smile.md new file mode 100644 index 00000000..5c53bf82 --- /dev/null +++ b/.changeset/friendly-rabbits-smile.md @@ -0,0 +1,5 @@ +--- +"@caravan/wallets": patch +--- + +move bitbox to normal dependency diff --git a/package-lock.json b/package-lock.json index 85a13918..7fe217dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28178,6 +28178,7 @@ "@trezor/connect-web": "^9.1.12", "axios": "1.6.7", "bignumber.js": "^8.1.1", + "bitbox-api": "^0.7.0", "bitcoinjs-lib": "^5.1.10", "bowser": "^2.6.1", "core-js": "^2.6.10", @@ -28201,7 +28202,6 @@ "@typescript-eslint/parser": "^5.51.0", "babel-jest": "^29.7.0", "babel-plugin-transform-inline-environment-variables": "^0.4.3", - "bitbox-api": "^0.7.0", "esbuild-plugin-polyfill-node": "^0.3.0", "eslint": "^8.34.0", "eslint-plugin-import": "^2.27.5", @@ -28561,8 +28561,7 @@ "packages/caravan-wallets/node_modules/bitbox-api": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/bitbox-api/-/bitbox-api-0.7.0.tgz", - "integrity": "sha512-uoE6MEV+KyAeX9+P/Gf7ujVFgy1eJWfnpd3Z8n8QXk4OgXha81x3O9PwN1XJv8zcNFUekS1OfsI/yJWvPbQysg==", - "dev": true + "integrity": "sha512-uoE6MEV+KyAeX9+P/Gf7ujVFgy1eJWfnpd3Z8n8QXk4OgXha81x3O9PwN1XJv8zcNFUekS1OfsI/yJWvPbQysg==" }, "packages/caravan-wallets/node_modules/brace-expansion": { "version": "1.1.11", diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 70a19b19..b09e6ebd 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -51,7 +51,6 @@ "@typescript-eslint/parser": "^5.51.0", "babel-jest": "^29.7.0", "babel-plugin-transform-inline-environment-variables": "^0.4.3", - "bitbox-api": "^0.7.0", "esbuild-plugin-polyfill-node": "^0.3.0", "eslint": "^8.34.0", "eslint-plugin-import": "^2.27.5", @@ -90,6 +89,7 @@ "@trezor/connect-web": "^9.1.12", "axios": "1.6.7", "bignumber.js": "^8.1.1", + "bitbox-api": "^0.7.0", "bitcoinjs-lib": "^5.1.10", "bowser": "^2.6.1", "core-js": "^2.6.10", From 5314e31478e3ee37583543eb541dd86045943e8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 Nov 2024 21:49:51 -0600 Subject: [PATCH 13/19] Version Packages (#150) Co-authored-by: github-actions[bot] --- .changeset/friendly-rabbits-smile.md | 5 ----- package-lock.json | 2 +- packages/caravan-wallets/CHANGELOG.md | 6 ++++++ packages/caravan-wallets/package.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/friendly-rabbits-smile.md diff --git a/.changeset/friendly-rabbits-smile.md b/.changeset/friendly-rabbits-smile.md deleted file mode 100644 index 5c53bf82..00000000 --- a/.changeset/friendly-rabbits-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@caravan/wallets": patch ---- - -move bitbox to normal dependency diff --git a/package-lock.json b/package-lock.json index 7fe217dc..8d1e0f5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28165,7 +28165,7 @@ }, "packages/caravan-wallets": { "name": "@caravan/wallets", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.7.0", diff --git a/packages/caravan-wallets/CHANGELOG.md b/packages/caravan-wallets/CHANGELOG.md index 9d7d5b4c..8df2784a 100644 --- a/packages/caravan-wallets/CHANGELOG.md +++ b/packages/caravan-wallets/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.1 + +### Patch Changes + +- [#149](https://github.com/caravan-bitcoin/caravan/pull/149) [`baee2cd`](https://github.com/caravan-bitcoin/caravan/commit/baee2cd3c95db7ead90ff672ad1ce506cf2c6a57) Thanks [@bucko13](https://github.com/bucko13)! - move bitbox to normal dependency + ## 0.4.0 ### Minor Changes diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index b09e6ebd..c66b63d5 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/wallets", - "version": "0.4.0", + "version": "0.4.1", "description": "Unchained Capital's HWI Library", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 5c3c456d78e8ab017cb960027d316cff98dc3ca2 Mon Sep 17 00:00:00 2001 From: buck Date: Thu, 14 Nov 2024 13:30:19 -0600 Subject: [PATCH 14/19] fix(wallets): bundle internal dependencies with production bundle (#151) --- .changeset/thick-clouds-run.md | 5 +++++ packages/caravan-wallets/tsup.config.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/thick-clouds-run.md diff --git a/.changeset/thick-clouds-run.md b/.changeset/thick-clouds-run.md new file mode 100644 index 00000000..a22573e3 --- /dev/null +++ b/.changeset/thick-clouds-run.md @@ -0,0 +1,5 @@ +--- +"@caravan/wallets": patch +--- + +fix bundling of internal dependencies diff --git a/packages/caravan-wallets/tsup.config.ts b/packages/caravan-wallets/tsup.config.ts index b80f05bf..0d72484e 100644 --- a/packages/caravan-wallets/tsup.config.ts +++ b/packages/caravan-wallets/tsup.config.ts @@ -7,5 +7,9 @@ export default defineConfig({ globals: { process: false }, }), ], - external: ['bitbox-api'], + // make sure that the bitbox-api package is not bundled + external: ["bitbox-api"], + // noExternal makes sure that certain packages are bundled + // in the final package rather than independently installed + noExternal: ["@caravan/psbt", "@caravan/bitcoin"], }); From 2409a756425b801af43b142069bbcfa97a95a177 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 Nov 2024 13:39:26 -0600 Subject: [PATCH 15/19] Version Packages (#152) Co-authored-by: github-actions[bot] --- .changeset/thick-clouds-run.md | 5 ----- package-lock.json | 2 +- packages/caravan-wallets/CHANGELOG.md | 6 ++++++ packages/caravan-wallets/package.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/thick-clouds-run.md diff --git a/.changeset/thick-clouds-run.md b/.changeset/thick-clouds-run.md deleted file mode 100644 index a22573e3..00000000 --- a/.changeset/thick-clouds-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@caravan/wallets": patch ---- - -fix bundling of internal dependencies diff --git a/package-lock.json b/package-lock.json index 8d1e0f5b..c1d1e372 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28165,7 +28165,7 @@ }, "packages/caravan-wallets": { "name": "@caravan/wallets", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.7.0", diff --git a/packages/caravan-wallets/CHANGELOG.md b/packages/caravan-wallets/CHANGELOG.md index 8df2784a..7ad4bbb6 100644 --- a/packages/caravan-wallets/CHANGELOG.md +++ b/packages/caravan-wallets/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.2 + +### Patch Changes + +- [#151](https://github.com/caravan-bitcoin/caravan/pull/151) [`5c3c456`](https://github.com/caravan-bitcoin/caravan/commit/5c3c456d78e8ab017cb960027d316cff98dc3ca2) Thanks [@bucko13](https://github.com/bucko13)! - fix bundling of internal dependencies + ## 0.4.1 ### Patch Changes diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index c66b63d5..50107cda 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/wallets", - "version": "0.4.1", + "version": "0.4.2", "description": "Unchained Capital's HWI Library", "main": "./dist/index.js", "types": "./dist/index.d.ts", From 480761b3c7c8a8708978a22bacf11442dd6d4868 Mon Sep 17 00:00:00 2001 From: buck Date: Thu, 14 Nov 2024 15:01:24 -0600 Subject: [PATCH 16/19] fix: @caravan/bitcoin shouldn't be noExternal (#153) --- .changeset/brave-pigs-repeat.md | 5 +++++ packages/caravan-wallets/tsup.config.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/brave-pigs-repeat.md diff --git a/.changeset/brave-pigs-repeat.md b/.changeset/brave-pigs-repeat.md new file mode 100644 index 00000000..c4268d4c --- /dev/null +++ b/.changeset/brave-pigs-repeat.md @@ -0,0 +1,5 @@ +--- +"@caravan/wallets": patch +--- + +fix an issue where caravan/bitcoin couldn't be pre-bundled diff --git a/packages/caravan-wallets/tsup.config.ts b/packages/caravan-wallets/tsup.config.ts index 0d72484e..63f147d5 100644 --- a/packages/caravan-wallets/tsup.config.ts +++ b/packages/caravan-wallets/tsup.config.ts @@ -11,5 +11,5 @@ export default defineConfig({ external: ["bitbox-api"], // noExternal makes sure that certain packages are bundled // in the final package rather than independently installed - noExternal: ["@caravan/psbt", "@caravan/bitcoin"], + noExternal: ["@caravan/psbt"], }); From d5720a2281f859c563f81cf459cdc09433cb16ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:09:15 -0600 Subject: [PATCH 17/19] Version Packages (#154) Co-authored-by: github-actions[bot] --- .changeset/brave-pigs-repeat.md | 5 ----- package-lock.json | 2 +- packages/caravan-wallets/CHANGELOG.md | 6 ++++++ packages/caravan-wallets/package.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/brave-pigs-repeat.md diff --git a/.changeset/brave-pigs-repeat.md b/.changeset/brave-pigs-repeat.md deleted file mode 100644 index c4268d4c..00000000 --- a/.changeset/brave-pigs-repeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@caravan/wallets": patch ---- - -fix an issue where caravan/bitcoin couldn't be pre-bundled diff --git a/package-lock.json b/package-lock.json index c1d1e372..633d5c3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28165,7 +28165,7 @@ }, "packages/caravan-wallets": { "name": "@caravan/wallets", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.7.0", diff --git a/packages/caravan-wallets/CHANGELOG.md b/packages/caravan-wallets/CHANGELOG.md index 7ad4bbb6..e9821453 100644 --- a/packages/caravan-wallets/CHANGELOG.md +++ b/packages/caravan-wallets/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.3 + +### Patch Changes + +- [#153](https://github.com/caravan-bitcoin/caravan/pull/153) [`480761b`](https://github.com/caravan-bitcoin/caravan/commit/480761b3c7c8a8708978a22bacf11442dd6d4868) Thanks [@bucko13](https://github.com/bucko13)! - fix an issue where caravan/bitcoin couldn't be pre-bundled + ## 0.4.2 ### Patch Changes diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 50107cda..38087805 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/wallets", - "version": "0.4.2", + "version": "0.4.3", "description": "Unchained Capital's HWI Library", "main": "./dist/index.js", "types": "./dist/index.d.ts", From a35893d3f2733b8ba6661bc955713813daa3a75d Mon Sep 17 00:00:00 2001 From: chadchapnick <143517546+chadchapnick@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:10:02 -0600 Subject: [PATCH 18/19] fix: unknown network error for regtest (#156) * support regtest network in coldcard and custom interactions * add changeset --- .changeset/stupid-starfishes-taste.md | 5 +++++ packages/caravan-wallets/src/coldcard.ts | 6 +++++- packages/caravan-wallets/src/custom.ts | 6 +++++- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/stupid-starfishes-taste.md diff --git a/.changeset/stupid-starfishes-taste.md b/.changeset/stupid-starfishes-taste.md new file mode 100644 index 00000000..de09d4cb --- /dev/null +++ b/.changeset/stupid-starfishes-taste.md @@ -0,0 +1,5 @@ +--- +"@caravan/wallets": patch +--- + +support regtest network in coldcard and custom interactions diff --git a/packages/caravan-wallets/src/coldcard.ts b/packages/caravan-wallets/src/coldcard.ts index 65843b1a..d53000db 100644 --- a/packages/caravan-wallets/src/coldcard.ts +++ b/packages/caravan-wallets/src/coldcard.ts @@ -75,7 +75,11 @@ class ColdcardMultisigSettingsFileParser extends ColdcardInteraction { bip32Path: string; }) { super(); - if ([Network.MAINNET, Network.TESTNET].find((net) => net === network)) { + if ( + [Network.MAINNET, Network.TESTNET, Network.REGTEST].find( + (net) => net === network + ) + ) { this.network = network; } else { throw new Error("Unknown network."); diff --git a/packages/caravan-wallets/src/custom.ts b/packages/caravan-wallets/src/custom.ts index 7febc3f6..e207b729 100644 --- a/packages/caravan-wallets/src/custom.ts +++ b/packages/caravan-wallets/src/custom.ts @@ -68,7 +68,11 @@ export class CustomExportExtendedPublicKey extends CustomInteraction { constructor({ network, bip32Path }) { super(); - if ([Network.MAINNET, Network.TESTNET].find((net) => net === network)) { + if ( + [Network.MAINNET, Network.TESTNET, Network.REGTEST].find( + (net) => net === network + ) + ) { this.network = network; } else { throw new Error("Unknown network."); From 48da7dfae98a0d486b098cc449c7961122f69af1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:14:13 -0600 Subject: [PATCH 19/19] Version Packages (#157) Co-authored-by: github-actions[bot] --- .changeset/stupid-starfishes-taste.md | 5 ----- package-lock.json | 2 +- packages/caravan-wallets/CHANGELOG.md | 6 ++++++ packages/caravan-wallets/package.json | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/stupid-starfishes-taste.md diff --git a/.changeset/stupid-starfishes-taste.md b/.changeset/stupid-starfishes-taste.md deleted file mode 100644 index de09d4cb..00000000 --- a/.changeset/stupid-starfishes-taste.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@caravan/wallets": patch ---- - -support regtest network in coldcard and custom interactions diff --git a/package-lock.json b/package-lock.json index 633d5c3c..0b21a5bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28165,7 +28165,7 @@ }, "packages/caravan-wallets": { "name": "@caravan/wallets", - "version": "0.4.3", + "version": "0.4.4", "license": "MIT", "dependencies": { "@babel/polyfill": "^7.7.0", diff --git a/packages/caravan-wallets/CHANGELOG.md b/packages/caravan-wallets/CHANGELOG.md index e9821453..a024ddfb 100644 --- a/packages/caravan-wallets/CHANGELOG.md +++ b/packages/caravan-wallets/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.4 + +### Patch Changes + +- [#156](https://github.com/caravan-bitcoin/caravan/pull/156) [`a35893d`](https://github.com/caravan-bitcoin/caravan/commit/a35893d3f2733b8ba6661bc955713813daa3a75d) Thanks [@chadchapnick](https://github.com/chadchapnick)! - support regtest network in coldcard and custom interactions + ## 0.4.3 ### Patch Changes diff --git a/packages/caravan-wallets/package.json b/packages/caravan-wallets/package.json index 38087805..214f5158 100644 --- a/packages/caravan-wallets/package.json +++ b/packages/caravan-wallets/package.json @@ -1,6 +1,6 @@ { "name": "@caravan/wallets", - "version": "0.4.3", + "version": "0.4.4", "description": "Unchained Capital's HWI Library", "main": "./dist/index.js", "types": "./dist/index.d.ts",