├── clippy.toml ├── .gitignore ├── .github ├── workflows │ ├── audit.yml │ ├── code_coverage.yml │ └── cont_integration.yml ├── ISSUE_TEMPLATE │ ├── enhancement_request.md │ ├── bug_report.md │ ├── minor_release.md │ └── patch_release.md └── pull_request_template.md ├── LICENSE ├── LICENSE-MIT ├── tests ├── cli_flags.rs └── integration.rs ├── src ├── persister.rs ├── main.rs ├── payjoin │ ├── ohttp.rs │ └── mod.rs ├── error.rs ├── commands.rs ├── utils.rs └── handlers.rs ├── DEVELOPMENT_CYCLE.md ├── Cargo.toml ├── Justfile ├── CONTRIBUTING.md ├── CHANGELOG.md ├── README.md └── LICENSE-APACHE /clippy.toml: -------------------------------------------------------------------------------- 1 | msrv="1.75.0" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | *.swp 4 | .idea 5 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Audit 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**/Cargo.toml' 7 | - '**/Cargo.lock' 8 | schedule: 9 | - cron: '0 0 * * 0' # Once per week 10 | 11 | jobs: 12 | 13 | security_audit: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions-rust-lang/audit@v1 18 | with: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Request a new feature or change to an existing feature 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the enhancement** 11 | 12 | 13 | **Use case** 14 | 15 | 16 | **Additional context** 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under [Apache 2.0](LICENSE-APACHE) or 2 | [MIT](LICENSE-MIT), at your option. 3 | 4 | Some files retain their own copyright notice, however, for full authorship 5 | information, see version control history. 6 | 7 | Except as otherwise noted in individual files, all files in this repository are 8 | licensed under the Apache License, Version 2.0 or the MIT license , at your option. 11 | 12 | You may not use, copy, modify, merge, publish, distribute, sublicense, and/or 13 | sell copies of this software or any files in this repository except in 14 | accordance with one or both of these licenses. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **Expected behavior** 17 | 18 | 19 | **Build environment** 20 | - BDK-CLI tag/commit: 21 | - OS+version: 22 | - Rust/Cargo version: 23 | - Rust/Cargo target: 24 | 25 | **Additional context** 26 | 27 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of 2 | this software and associated documentation files (the "Software"), to deal in 3 | the Software without restriction, including without limitation the rights to 4 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 5 | the Software, and to permit persons to whom the Software is furnished to do so, 6 | subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Description 4 | 5 | 6 | 7 | ### Notes to the reviewers 8 | 9 | 11 | 12 | ## Changelog notice 13 | 14 | 15 | 16 | 17 | ### Checklists 18 | 19 | #### All Submissions: 20 | 21 | * [ ] I've signed all my commits 22 | * [ ] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk-cli/blob/master/CONTRIBUTING.md) 23 | * [ ] I ran `cargo fmt` and `cargo clippy` before committing 24 | 25 | #### New Features: 26 | 27 | * [ ] I've added tests for the new feature 28 | * [ ] I've added docs for the new feature 29 | * [ ] I've updated `CHANGELOG.md` 30 | 31 | #### Bugfixes: 32 | 33 | * [ ] This pull request breaks the existing API 34 | * [ ] I've added tests to reproduce the issue which are now passing 35 | * [ ] I'm linking the issue being fixed by this PR 36 | -------------------------------------------------------------------------------- /tests/cli_flags.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 Bitcoin Dev Kit Developers 2 | // 3 | // This file is licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your option. 6 | // You may not use this file except in accordance with one or both of these 7 | // licenses. 8 | 9 | //! CLI Flags Tests 10 | //! 11 | //! Tests for global CLI flags and their behavior 12 | 13 | use std::process::Command; 14 | 15 | #[test] 16 | fn test_without_pretty_flag() { 17 | let output = Command::new("cargo") 18 | .args("run -- key generate".split_whitespace()) 19 | .output() 20 | .unwrap(); 21 | 22 | assert!(output.status.success()); 23 | 24 | let stdout = String::from_utf8_lossy(&output.stdout); 25 | assert!(serde_json::from_str::(&stdout).is_ok()); 26 | } 27 | 28 | #[test] 29 | fn test_pretty_flag_before_subcommand() { 30 | let output = Command::new("cargo") 31 | .args("run -- --pretty key generate".split_whitespace()) 32 | .output() 33 | .unwrap(); 34 | 35 | assert!(output.status.success()); 36 | } 37 | 38 | #[test] 39 | fn test_pretty_flag_after_subcommand() { 40 | let output = Command::new("cargo") 41 | .args("run -- key generate --pretty".split_whitespace()) 42 | .output() 43 | .unwrap(); 44 | 45 | assert!(output.status.success()); 46 | } 47 | -------------------------------------------------------------------------------- /src/persister.rs: -------------------------------------------------------------------------------- 1 | use crate::error::BDKCliError; 2 | use bdk_wallet::WalletPersister; 3 | 4 | // Types of Persistence backends supported by bdk-cli 5 | pub(crate) enum Persister { 6 | #[cfg(feature = "sqlite")] 7 | Connection(bdk_wallet::rusqlite::Connection), 8 | #[cfg(feature = "redb")] 9 | RedbStore(bdk_redb::Store), 10 | } 11 | 12 | impl WalletPersister for Persister { 13 | type Error = BDKCliError; 14 | 15 | fn initialize(persister: &mut Self) -> Result { 16 | match persister { 17 | #[cfg(feature = "sqlite")] 18 | Persister::Connection(connection) => { 19 | WalletPersister::initialize(connection).map_err(BDKCliError::from) 20 | } 21 | #[cfg(feature = "redb")] 22 | Persister::RedbStore(store) => { 23 | WalletPersister::initialize(store).map_err(BDKCliError::from) 24 | } 25 | } 26 | } 27 | 28 | fn persist(persister: &mut Self, changeset: &bdk_wallet::ChangeSet) -> Result<(), Self::Error> { 29 | match persister { 30 | #[cfg(feature = "sqlite")] 31 | Persister::Connection(connection) => { 32 | WalletPersister::persist(connection, changeset).map_err(BDKCliError::from) 33 | } 34 | #[cfg(feature = "redb")] 35 | Persister::RedbStore(store) => { 36 | WalletPersister::persist(store, changeset).map_err(BDKCliError::from) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/code_coverage.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Code Coverage 4 | 5 | jobs: 6 | Codecov: 7 | name: Code Coverage 8 | runs-on: ubuntu-latest 9 | env: 10 | RUSTFLAGS: "-Cinstrument-coverage" 11 | RUSTDOCFLAGS: "-Cinstrument-coverage" 12 | LLVM_PROFILE_FILE: "./target/coverage/%p-%m.profraw" 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | - name: Install lcov tools 20 | run: sudo apt-get install lcov -y 21 | - name: Install Rust toolchain 22 | uses: actions-rs/toolchain@v1 23 | with: 24 | toolchain: stable 25 | override: true 26 | profile: minimal 27 | components: llvm-tools-preview 28 | - name: Rust Cache 29 | uses: Swatinem/rust-cache@v2.7.8 30 | - name: Install grcov 31 | run: if [[ ! -e ~/.cargo/bin/grcov ]]; then cargo install grcov; fi 32 | - name: Test 33 | run: cargo test --all-features 34 | - name: Make coverage directory 35 | run: mkdir coverage 36 | - name: Run grcov 37 | run: grcov . --binary-path ./target/debug/ -s . -t lcov --branch --ignore-not-existing --keep-only 'src/**' --ignore 'tests/**' -o ./coverage/lcov.info 38 | - name: Check lcov.info 39 | run: cat ./coverage/lcov.info 40 | - name: Coveralls 41 | uses: coverallsapp/github-action@v2 42 | with: 43 | github-token: ${{ secrets.GITHUB_TOKEN }} 44 | file: ./coverage/lcov.info 45 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 Bitcoin Dev Kit Developers 2 | // 3 | // This file is licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your option. 6 | // You may not use this file except in accordance with one or both of these 7 | // licenses. 8 | 9 | #![doc = include_str!("../README.md")] 10 | #![doc(html_logo_url = "https://github.com/bitcoindevkit/bdk/raw/master/static/bdk.png")] 11 | #![warn(missing_docs)] 12 | 13 | mod commands; 14 | mod error; 15 | mod handlers; 16 | #[cfg(any( 17 | feature = "electrum", 18 | feature = "esplora", 19 | feature = "cbf", 20 | feature = "rpc" 21 | ))] 22 | mod payjoin; 23 | #[cfg(any(feature = "sqlite", feature = "redb"))] 24 | mod persister; 25 | mod utils; 26 | 27 | use bdk_wallet::bitcoin::Network; 28 | use log::{debug, error, warn}; 29 | 30 | use crate::commands::CliOpts; 31 | use crate::handlers::*; 32 | use clap::Parser; 33 | 34 | #[tokio::main] 35 | async fn main() { 36 | env_logger::init(); 37 | let cli_opts: CliOpts = CliOpts::parse(); 38 | 39 | let network = &cli_opts.network; 40 | debug!("network: {network:?}"); 41 | if network == &Network::Bitcoin { 42 | warn!( 43 | "This is experimental software and not currently recommended for use on Bitcoin mainnet, proceed with caution." 44 | ) 45 | } 46 | 47 | match handle_command(cli_opts).await { 48 | Ok(result) => println!("{result}"), 49 | Err(e) => { 50 | error!("{e}"); 51 | std::process::exit(1); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /DEVELOPMENT_CYCLE.md: -------------------------------------------------------------------------------- 1 | # Development Cycle 2 | 3 | This project follows a regular releasing schedule similar to the one [used by the Rust language]. In short, this means that a new release is made at a regular cadence, with all the feature/bugfixes that made it to `master` in time. This ensures that we don't keep delaying releases waiting for "just one more little thing". 4 | 5 | This project uses [Semantic Versioning], but is currently at MAJOR version zero (0.y.z) meaning it is still in initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable. Until we reach version `1.0.0` we will do our best to document any breaking API changes in the changelog info attached to each release tag. 6 | 7 | We decided to maintain a faster release cycle while the library is still in "beta", i.e. before release `1.0.0`: since we are constantly adding new features and, even more importantly, fixing issues, we want developers to have access to those updates as fast as possible. For this reason we will make a release **every 4 weeks**. 8 | 9 | Once the project reaches a more mature state (>= `1.0.0`), we will very likely switch to longer release cycles of **6 weeks**. 10 | 11 | The "feature freeze" will happen **one week before the release date**. This means a new branch will be created originating from the `master` tip at that time, and in that branch we will stop adding new features and only focus on ensuring the ones we've added are working properly. 12 | 13 | To create a new release a release manager will create a new issue using the `Release` template and follow the template instructions. 14 | 15 | [used by the Rust language]: https://doc.rust-lang.org/book/appendix-07-nightly-rust.html 16 | [Semantic Versioning]: https://semver.org/ 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bdk-cli" 3 | version = "2.0.0" 4 | edition = "2024" 5 | authors = ["Alekos Filini ", "Riccardo Casatta ", "Steve Myers "] 6 | homepage = "https://bitcoindevkit.org" 7 | repository = "https://github.com/bitcoindevkit/bdk-cli" 8 | documentation = "https://docs.rs/bdk-cli" 9 | description = "An experimental CLI wallet application and playground, powered by BDK" 10 | keywords = ["bitcoin", "wallet", "descriptor", "psbt", "taproot"] 11 | readme = "README.md" 12 | license = "MIT" 13 | 14 | [dependencies] 15 | bdk_wallet = { version = "2.1.0", features = ["rusqlite", "keys-bip39", "compiler", "std"] } 16 | clap = { version = "4.5", features = ["derive","env"] } 17 | dirs = { version = "6.0.0" } 18 | env_logger = "0.11.6" 19 | log = "0.4" 20 | serde_json = "1.0" 21 | thiserror = "2.0.11" 22 | tokio = { version = "1", features = ["full"] } 23 | cli-table = "0.5.0" 24 | tracing = "0.1.41" 25 | tracing-subscriber = "0.3.20" 26 | 27 | # Optional dependencies 28 | bdk_bitcoind_rpc = { version = "0.21.0", features = ["std"], optional = true } 29 | bdk_electrum = { version = "0.23.0", optional = true } 30 | bdk_esplora = { version = "0.22.1", features = ["async-https", "tokio"], optional = true } 31 | bdk_kyoto = { version = "0.15.1", optional = true } 32 | bdk_redb = { version = "0.1.0", optional = true } 33 | shlex = { version = "1.3.0", optional = true } 34 | payjoin = { version = "1.0.0-rc.1", features = ["v1", "v2", "io", "_test-utils"], optional = true} 35 | reqwest = { version = "0.12.23", default-features = false, optional = true } 36 | url = { version = "2.5.4", optional = true } 37 | 38 | [features] 39 | default = ["repl", "sqlite"] 40 | 41 | # To use the app in a REPL mode 42 | repl = ["shlex"] 43 | 44 | # Available database options 45 | sqlite = ["bdk_wallet/rusqlite"] 46 | redb = ["bdk_redb"] 47 | 48 | # Available blockchain client options 49 | cbf = ["bdk_kyoto", "_payjoin-dependencies"] 50 | electrum = ["bdk_electrum", "_payjoin-dependencies"] 51 | esplora = ["bdk_esplora", "_payjoin-dependencies"] 52 | rpc = ["bdk_bitcoind_rpc", "_payjoin-dependencies"] 53 | 54 | # Internal features 55 | _payjoin-dependencies = ["payjoin", "reqwest", "url"] 56 | 57 | # Use this to consensus verify transactions at sync time 58 | verify = [] 59 | 60 | # Extra utility tools 61 | # Compile policies 62 | compiler = [] 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/minor_release.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Minor Release 3 | about: Create a new minor release [for release managers only] 4 | title: 'Release MAJOR.MINOR+1.0' 5 | labels: 'release' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Create a new minor release 11 | 12 | ### Summary 13 | 14 | <--release summary to be used in announcements--> 15 | 16 | ### Commit 17 | 18 | <--latest commit ID to include in this release--> 19 | 20 | ### Changelog 21 | 22 | <--add notices from PRs merged since the prior release, see ["keep a changelog"]--> 23 | 24 | ### Checklist 25 | 26 | Release numbering must follow [Semantic Versioning]. These steps assume the current `master` 27 | branch **development** version is *MAJOR.MINOR.0*. 28 | 29 | #### On the day of the feature freeze 30 | 31 | Change the `master` branch to the next MINOR+1 version: 32 | 33 | - [ ] Switch to the `master` branch. 34 | - [ ] Create a new PR branch called `bump_dev_MAJOR_MINOR+1`, eg. `bump_dev_0_22`. 35 | - [ ] Bump the `bump_dev_MAJOR_MINOR+1` branch to the next development MINOR+1 version. 36 | - Change the `Cargo.toml` version value to `MAJOR.MINOR+1.0`. 37 | - The commit message should be "Bump version to MAJOR.MINOR+1.0". 38 | - [ ] Create PR and merge the `bump_dev_MAJOR_MINOR+1` branch to `master`. 39 | - Title PR "Bump version to MAJOR.MINOR+1.0". 40 | 41 | #### On the day of the release 42 | 43 | Tag and publish new release: 44 | 45 | - [ ] Double check that your local `master` is up-to-date with the upstream repo. 46 | - [ ] Create a new branch called `release/MAJOR.MINOR+1` from `master`. 47 | - [ ] Add a tag to the `HEAD` commit in the `release/MAJOR.MINOR+1` branch. 48 | - The tag name should be `vMAJOR.MINOR+1.0` 49 | - The first line of the tag message should be "Release MAJOR.MINOR+1.0". 50 | - In the body of the tag message put a copy of the **Summary** and **Changelog** for the release. 51 | - Make sure the tag is signed, for extra safety use the explicit `--sign` flag. 52 | - [ ] Wait for the CI to finish one last time. 53 | - [ ] Push the new tag to the `bitcoindevkit/bdk-cli` repo. 54 | - [ ] Publish **all** the updated crates to crates.io. 55 | - [ ] Create the release on GitHub. 56 | - Go to "tags", click on the dots on the right and select "Create Release". 57 | - Set the title to `Release MAJOR.MINOR+1.0`. 58 | - In the release notes body put the **Summary** and **Changelog**. 59 | - Use the "+ Auto-generate release notes" button to add details from included PRs. 60 | - Until we reach a `1.0.0` release check the "Pre-release" box. 61 | - [ ] Make sure the new release shows up on [crates.io] and that the docs are built correctly on [docs.rs]. 62 | - [ ] Announce the release, using the **Summary**, on Discord, Twitter and Mastodon. 63 | - [ ] Celebrate 🎉 64 | 65 | [Semantic Versioning]: https://semver.org/ 66 | [crates.io]: https://crates.io/crates/bdk-cli 67 | [docs.rs]: https://docs.rs/bdk-cli/latest/bdk-cli 68 | ["keep a changelog"]: https://keepachangelog.com/en/1.0.0/ 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/patch_release.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Patch Release 3 | about: Create a new patch release [for release managers only] 4 | title: 'Release MAJOR.MINOR.PATCH+1' 5 | labels: 'release' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Create a new patch release 11 | 12 | ### Summary 13 | 14 | <--release summary to be used in announcements--> 15 | 16 | ### Commit 17 | 18 | <--latest commit ID to include in this release--> 19 | 20 | ### Changelog 21 | 22 | <--add notices from PRs merged since the prior release, see ["keep a changelog"]--> 23 | 24 | ### Checklist 25 | 26 | Release numbering must follow [Semantic Versioning]. These steps assume the current `master` 27 | branch **development** version is *MAJOR.MINOR.PATCH*. 28 | 29 | ### On the day of the patch release 30 | 31 | Change the `master` branch to the new PATCH+1 version: 32 | 33 | - [ ] Switch to the `master` branch. 34 | - [ ] Create a new PR branch called `bump_dev_MAJOR_MINOR_PATCH+1`, eg. `bump_dev_0_22_1`. 35 | - [ ] Bump the `bump_dev_MAJOR_MINOR` branch to the next development PATCH+1 version. 36 | - Change the `Cargo.toml` version value to `MAJOR.MINOR.PATCH+1`. 37 | - The commit message should be "Bump version to MAJOR.MINOR.PATCH+1". 38 | - [ ] Create PR and merge the `bump_dev_MAJOR_MINOR_PATCH+1` branch to `master`. 39 | - Title PR "Bump version to MAJOR.MINOR.PATCH+1". 40 | 41 | Cherry-pick, tag and publish new PATCH+1 release: 42 | 43 | - [ ] Merge fix PRs to the `master` branch. 44 | - [ ] Git cherry-pick fix commits to the `release/MAJOR.MINOR` branch to be patched. 45 | - [ ] Verify fixes in `release/MAJOR.MINOR` branch. 46 | - [ ] Bump the `release/MAJOR.MINOR.PATCH+1` branch to `MAJOR.MINOR.PATCH+1` version. 47 | - Change the `Cargo.toml` version value to `MAJOR.MINOR.MINOR.PATCH+1`. 48 | - The commit message should be "Bump version to MAJOR.MINOR.PATCH+1". 49 | - [ ] Add a tag to the `HEAD` commit in the `release/MAJOR.MINOR` branch. 50 | - The tag name should be `vMAJOR.MINOR.PATCH+1` 51 | - The first line of the tag message should be "Release MAJOR.MINOR.PATCH+1". 52 | - In the body of the tag message put a copy of the **Summary** and **Changelog** for the release. 53 | - Make sure the tag is signed, for extra safety use the explicit `--sign` flag. 54 | - [ ] Wait for the CI to finish one last time. 55 | - [ ] Push the new tag to the `bitcoindevkit/bdk-cli` repo. 56 | - [ ] Publish **all** the updated crates to crates.io. 57 | - [ ] Create the release on GitHub. 58 | - Go to "tags", click on the dots on the right and select "Create Release". 59 | - Set the title to `Release MAJOR.MINOR.PATCH+1`. 60 | - In the release notes body put the **Summary** and **Changelog**. 61 | - Use the "+ Auto-generate release notes" button to add details from included PRs. 62 | - Until we reach a `1.0.0` release check the "Pre-release" box. 63 | - [ ] Make sure the new release shows up on [crates.io] and that the docs are built correctly on [docs.rs]. 64 | - [ ] Announce the release, using the **Summary**, on Discord, Twitter and Mastodon. 65 | - [ ] Celebrate 🎉 66 | 67 | [Semantic Versioning]: https://semver.org/ 68 | [crates.io]: https://crates.io/crates/bdk-cli 69 | [docs.rs]: https://docs.rs/bdk-cli/latest/bdk-cli 70 | ["keep a changelog"]: https://keepachangelog.com/en/1.0.0/ 71 | -------------------------------------------------------------------------------- /.github/workflows/cont_integration.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: CI 4 | 5 | jobs: 6 | 7 | build-test: 8 | name: Build and test 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | rust: 13 | - stable # STABLE 14 | features: 15 | - --features default 16 | - --no-default-features 17 | - --all-features 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Generate cache key 22 | run: echo "${{ matrix.rust }} ${{ matrix.features }}" | tee .cache_key 23 | - name: Cache 24 | uses: actions/cache@v4 25 | with: 26 | path: | 27 | ~/.cargo/registry 28 | ~/.cargo/git 29 | target 30 | key: ${{ runner.os }}-cargo-${{ hashFiles('.cache_key') }}-${{ hashFiles('**/Cargo.toml','**/Cargo.lock') }} 31 | - name: Setup Rust Toolchain 32 | uses: actions-rs/toolchain@v1 33 | with: 34 | toolchain: ${{ matrix.rust }} 35 | profile: minimal 36 | override: true 37 | components: rustfmt, clippy 38 | - name: Build 39 | run: cargo build ${{ matrix.features }} 40 | - name: Clippy 41 | run: cargo clippy -- -D warnings 42 | - name: Test 43 | run: cargo test ${{ matrix.features }} 44 | 45 | # TODO: fix or remove this 46 | # wasm-build: 47 | # name: Build WASM 48 | # runs-on: ubuntu-20.04 49 | # env: 50 | # CC: clang-10 51 | # CFLAGS: -I/usr/include 52 | # steps: 53 | # - name: Checkout 54 | # uses: actions/checkout@v4 55 | # - name: Generate cache key 56 | # run: echo "Build WASM" | tee .cache_key 57 | # - name: Cache 58 | # uses: actions/cache@v4 59 | # with: 60 | # path: | 61 | # ~/.cargo/registry 62 | # ~/.cargo/git 63 | # target 64 | # key: ${{ runner.os }}-cargo-${{ hashFiles('.cache_key') }}-${{ hashFiles('**/Cargo.toml','**/Cargo.lock') }} 65 | # # Install a recent version of clang that supports wasm32 66 | # - run: wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - || exit 1 67 | # - run: sudo apt-get update || exit 1 68 | # - run: sudo apt-get install -y libclang-common-10-dev clang-10 libc6-dev-i386 || exit 1 69 | # - name: Set default toolchain 70 | # run: rustup default stable 71 | # - name: Set profile 72 | # run: rustup set profile minimal 73 | # - name: Add target wasm32 74 | # run: rustup target add wasm32-unknown-unknown 75 | # - name: Update toolchain 76 | # run: rustup update 77 | # - name: Build 78 | # run: cargo build --target wasm32-unknown-unknown --no-default-features --features esplora,compiler,dev-getrandom-wasm 79 | 80 | fmt: 81 | name: Rust fmt 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: Checkout 85 | uses: actions/checkout@v4 86 | - name: Setup Rust Toolchain 87 | uses: actions-rs/toolchain@v1 88 | with: 89 | toolchain: stable 90 | profile: minimal 91 | override: true 92 | components: rustfmt, clippy 93 | - name: Check fmt 94 | run: cargo fmt --all -- --check 95 | -------------------------------------------------------------------------------- /src/payjoin/ohttp.rs: -------------------------------------------------------------------------------- 1 | use crate::error::BDKCliError as Error; 2 | use std::sync::{Arc, Mutex}; 3 | 4 | #[derive(Debug, Clone)] 5 | pub(crate) struct RelayManager { 6 | selected_relay: Option, 7 | failed_relays: Vec, 8 | } 9 | 10 | impl RelayManager { 11 | pub fn new() -> Self { 12 | RelayManager { 13 | selected_relay: None, 14 | failed_relays: Vec::new(), 15 | } 16 | } 17 | 18 | pub fn set_selected_relay(&mut self, relay: url::Url) { 19 | self.selected_relay = Some(relay); 20 | } 21 | 22 | pub fn get_selected_relay(&self) -> Option { 23 | self.selected_relay.clone() 24 | } 25 | 26 | pub fn add_failed_relay(&mut self, relay: url::Url) { 27 | self.failed_relays.push(relay); 28 | } 29 | 30 | pub fn get_failed_relays(&self) -> Vec { 31 | self.failed_relays.clone() 32 | } 33 | } 34 | 35 | pub(crate) struct ValidatedOhttpKeys { 36 | pub(crate) ohttp_keys: payjoin::OhttpKeys, 37 | pub(crate) relay_url: url::Url, 38 | } 39 | 40 | pub(crate) async fn fetch_ohttp_keys( 41 | relays: Vec, 42 | payjoin_directory: impl payjoin::IntoUrl, 43 | relay_manager: Arc>, 44 | ) -> Result { 45 | use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; 46 | 47 | loop { 48 | let failed_relays = relay_manager 49 | .lock() 50 | .expect("Lock should not be poisoned") 51 | .get_failed_relays(); 52 | 53 | let remaining_relays: Vec<_> = relays 54 | .iter() 55 | .filter(|r| !failed_relays.contains(r)) 56 | .cloned() 57 | .collect(); 58 | 59 | if remaining_relays.is_empty() { 60 | return Err(Error::Generic( 61 | "No valid OHTTP relays available".to_string(), 62 | )); 63 | } 64 | 65 | let selected_relay = 66 | match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) { 67 | Some(relay) => relay.clone(), 68 | None => { 69 | return Err(Error::Generic( 70 | "Failed to select from remaining relays".to_string(), 71 | )); 72 | } 73 | }; 74 | 75 | relay_manager 76 | .lock() 77 | .expect("Lock should not be poisoned") 78 | .set_selected_relay(selected_relay.clone()); 79 | 80 | let ohttp_keys = 81 | payjoin::io::fetch_ohttp_keys(selected_relay.as_str(), payjoin_directory.as_str()) 82 | .await; 83 | 84 | match ohttp_keys { 85 | Ok(keys) => { 86 | return Ok(ValidatedOhttpKeys { 87 | ohttp_keys: keys, 88 | relay_url: selected_relay, 89 | }); 90 | } 91 | Err(payjoin::io::Error::UnexpectedStatusCode(e)) => { 92 | return Err(Error::Generic(format!( 93 | "Unexpected error occurred when fetching OHTTP keys: {}", 94 | e 95 | ))); 96 | } 97 | Err(e) => { 98 | tracing::debug!( 99 | "Failed to connect to OHTTP relay: {}, {}", 100 | selected_relay, 101 | e 102 | ); 103 | relay_manager 104 | .lock() 105 | .expect("Lock should not be poisoned") 106 | .add_failed_relay(selected_relay); 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | set quiet := true 2 | msrv := "1.75.0" 3 | default_wallet := 'regtest_default_wallet' 4 | default_datadir := "$HOME/.bdk-bitcoin" 5 | rpc_user := 'user' 6 | rpc_password := 'password' 7 | 8 | # list of recipes 9 | default: 10 | just --list 11 | 12 | # format the project code 13 | fmt: 14 | cargo fmt 15 | 16 | # lint the project 17 | clippy: fmt 18 | cargo clippy --all-features --tests 19 | 20 | # build the project 21 | build: fmt 22 | cargo build --all-features --tests 23 | 24 | # test the project 25 | test: 26 | cargo test --all-features --tests 27 | 28 | # clean the project target directory 29 | clean: 30 | cargo clean 31 | 32 | # set the rust version to stable 33 | stable: clean 34 | rustup override set stable; cargo update 35 | 36 | # set the rust version to the msrv and pin dependencies 37 | msrv: clean 38 | rustup override set {{msrv}}; cargo update; ./ci/pin-msrv.sh 39 | 40 | # start regtest bitcoind in default data directory 41 | [group('rpc')] 42 | start: 43 | if [ ! -d "{{default_datadir}}" ]; then \ 44 | mkdir -p "{{default_datadir}}"; \ 45 | fi 46 | bitcoind -datadir={{default_datadir}} -regtest -server -fallbackfee=0.0002 -blockfilterindex=1 -peerblockfilters=1 \ 47 | -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0 -daemon 48 | 49 | # stop regtest bitcoind 50 | [group('rpc')] 51 | stop: 52 | pkill bitcoind 53 | 54 | # stop and delete regtest bitcoind data 55 | [group('rpc')] 56 | reset: stop 57 | rm -rf {{default_datadir}} 58 | 59 | [group('rpc')] 60 | create wallet=default_wallet: 61 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} createwallet {{wallet}} 62 | 63 | # load regtest wallet 64 | [group('rpc')] 65 | load wallet=default_wallet: 66 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} loadwallet {{wallet}} 67 | 68 | # unload regtest wallet 69 | [group('rpc')] 70 | unload wallet=default_wallet: 71 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} unloadwallet {{wallet}} 72 | 73 | 74 | # get regtest wallet address 75 | [group('rpc')] 76 | address wallet=default_wallet: 77 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} getnewaddress 78 | 79 | # generate n new blocks to given address 80 | [group('rpc')] 81 | generate n address: 82 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} generatetoaddress {{n}} {{address}} 83 | 84 | # get regtest wallet balance 85 | [group('rpc')] 86 | balance wallet=default_wallet: 87 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} getbalance 88 | 89 | # send n btc to address from wallet 90 | [group('rpc')] 91 | send n address wallet=default_wallet: 92 | bitcoin-cli -named -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} sendtoaddress address={{address}} amount={{n}} 93 | 94 | # list wallet descriptors info, private = (true | false) 95 | [group('rpc')] 96 | descriptors private wallet=default_wallet: 97 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} listdescriptors {{private}} 98 | 99 | # run any bitcoin-cli rpc command 100 | [group('rpc')] 101 | rpc command wallet=default_wallet: 102 | bitcoin-cli -datadir={{default_datadir}} -regtest -rpcwallet={{wallet}} -rpcuser={{rpc_user}} -rpcpassword={{rpc_password}} {{command}} -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use bdk_wallet::bitcoin::hex::HexToBytesError; 2 | use bdk_wallet::bitcoin::psbt::ExtractTxError; 3 | use bdk_wallet::bitcoin::{base64, consensus}; 4 | use thiserror::Error; 5 | 6 | #[derive(Debug, Error)] 7 | pub enum BDKCliError { 8 | #[error("BIP39 error: {0:?}")] 9 | BIP39Error(#[from] Option), 10 | 11 | #[error("BIP32 error: {0}")] 12 | BIP32Error(#[from] bdk_wallet::bitcoin::bip32::Error), 13 | 14 | #[error("FeeBump error: {0}")] 15 | BuildFeeBumpError(#[from] bdk_wallet::error::BuildFeeBumpError), 16 | 17 | #[allow(dead_code)] 18 | #[error("Checksum error")] 19 | ChecksumMismatch, 20 | 21 | #[error("Create transaction error: {0}")] 22 | CreateTx(#[from] bdk_wallet::error::CreateTxError), 23 | 24 | #[error("Descriptor error: {0}")] 25 | DescriptorError(#[from] bdk_wallet::descriptor::error::Error), 26 | 27 | #[error("Descriptor key parse error: {0}")] 28 | DescriptorKeyParseError(#[from] bdk_wallet::miniscript::descriptor::DescriptorKeyParseError), 29 | 30 | #[error("Base64 decoding error: {0}")] 31 | DecodeError(#[from] base64::DecodeError), 32 | 33 | #[error("Generic error: {0}")] 34 | Generic(String), 35 | 36 | #[error("Hex conversion error: {0}")] 37 | HexToArrayError(#[from] bdk_wallet::bitcoin::hashes::hex::HexToArrayError), 38 | 39 | #[error("Key error: {0}")] 40 | KeyError(#[from] bdk_wallet::keys::KeyError), 41 | 42 | #[error("LocalChain error: {0}")] 43 | LocalChainError(#[from] bdk_wallet::chain::local_chain::ApplyHeaderError), 44 | 45 | #[error("Miniscript error: {0}")] 46 | MiniscriptError(#[from] bdk_wallet::miniscript::Error), 47 | 48 | #[error("ParseError: {0}")] 49 | ParseError(#[from] bdk_wallet::bitcoin::address::ParseError), 50 | 51 | #[error("ParseOutPointError: {0}")] 52 | ParseOutPointError(#[from] bdk_wallet::bitcoin::blockdata::transaction::ParseOutPointError), 53 | 54 | #[error("PsbtExtractTxError: {0}")] 55 | PsbtExtractTxError(Box), 56 | 57 | #[error("PsbtError: {0}")] 58 | PsbtError(#[from] bdk_wallet::bitcoin::psbt::Error), 59 | 60 | #[cfg(feature = "sqlite")] 61 | #[error("Rusqlite error: {0}")] 62 | RusqliteError(#[from] bdk_wallet::rusqlite::Error), 63 | 64 | #[cfg(feature = "redb")] 65 | #[error("Redb StoreError: {0}")] 66 | RedbStoreError(#[from] bdk_redb::error::StoreError), 67 | 68 | #[cfg(feature = "redb")] 69 | #[error("Redb dabtabase error: {0}")] 70 | RedbDatabaseError(#[from] bdk_redb::redb::DatabaseError), 71 | 72 | #[error("Serde json error: {0}")] 73 | SerdeJson(#[from] serde_json::Error), 74 | 75 | #[error("Bitcoin consensus encoding error: {0}")] 76 | Serde(#[from] consensus::encode::Error), 77 | 78 | #[error("Signer error: {0}")] 79 | SignerError(#[from] bdk_wallet::signer::SignerError), 80 | 81 | #[cfg(feature = "electrum")] 82 | #[error("Electrum error: {0}")] 83 | Electrum(#[from] bdk_electrum::electrum_client::Error), 84 | 85 | #[cfg(feature = "esplora")] 86 | #[error("Esplora error: {0}")] 87 | Esplora(#[from] bdk_esplora::esplora_client::Error), 88 | 89 | #[error("Chain connect error: {0}")] 90 | Chain(#[from] bdk_wallet::chain::local_chain::CannotConnectError), 91 | 92 | #[error("Consensus decoding error: {0}")] 93 | Hex(#[from] HexToBytesError), 94 | 95 | #[cfg(feature = "rpc")] 96 | #[error("RPC error: {0}")] 97 | BitcoinCoreRpcError(#[from] bdk_bitcoind_rpc::bitcoincore_rpc::Error), 98 | 99 | #[cfg(feature = "cbf")] 100 | #[error("BDK-Kyoto builder error: {0}")] 101 | KyotoBuilderError(#[from] bdk_kyoto::builder::BuilderError), 102 | 103 | #[cfg(feature = "cbf")] 104 | #[error("BDK-Kyoto update error: {0}")] 105 | KyotoUpdateError(#[from] bdk_kyoto::UpdateError), 106 | 107 | #[cfg(any( 108 | feature = "electrum", 109 | feature = "esplora", 110 | feature = "rpc", 111 | feature = "cbf", 112 | ))] 113 | #[error("Reqwest error: {0}")] 114 | ReqwestError(#[from] reqwest::Error), 115 | } 116 | 117 | impl From for BDKCliError { 118 | fn from(value: ExtractTxError) -> Self { 119 | BDKCliError::PsbtExtractTxError(Box::new(value)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing to BDK 2 | ============================== 3 | 4 | The BDK project operates an open contributor model where anyone is welcome to 5 | contribute towards development in the form of peer review, documentation, 6 | testing and patches. 7 | 8 | Anyone is invited to contribute without regard to technical experience, 9 | "expertise", OSS experience, age, or other concern. However, the development of 10 | cryptocurrencies demands a high-level of rigor, adversarial thinking, thorough 11 | testing and risk-minimization. 12 | Any bug may cost users real money. That being said, we deeply welcome people 13 | contributing for the first time to an open source project or pick up Rust while 14 | contributing. Don't be shy, you'll learn. 15 | 16 | Communications Channels 17 | ----------------------- 18 | 19 | Communication about BDK happens primarily on the [BDK Discord](https://discord.gg/dstn4dQ). 20 | 21 | Discussion about code base improvements happens in GitHub [issues](https://github.com/bitcoindevkit/bdk/issues) and 22 | on [pull requests](https://github.com/bitcoindevkit/bdk/pulls). 23 | 24 | Contribution Workflow 25 | --------------------- 26 | 27 | The codebase is maintained using the "contributor workflow" where everyone 28 | without exception contributes patch proposals using "pull requests". This 29 | facilitates social contribution, easy testing and peer review. 30 | 31 | To contribute a patch, the worflow is a as follows: 32 | 33 | 1. Fork Repository 34 | 2. Create topic branch 35 | 3. Commit patches 36 | 37 | In general commits should be atomic and diffs should be easy to read. 38 | For this reason do not mix any formatting fixes or code moves with actual code 39 | changes. Further, each commit, individually, should compile and pass tests, in 40 | order to ensure git bisect and other automated tools function properly. 41 | 42 | When adding a new feature, thought must be given to the long term technical 43 | debt. 44 | Every new feature should be covered by functional tests where possible. 45 | 46 | When refactoring, structure your PR to make it easy to review and don't 47 | hesitate to split it into multiple small, focused PRs. 48 | 49 | The Minimal Supported Rust Version is 1.45 (enforced by our CI). 50 | 51 | Commits should cover both the issue fixed and the solution's rationale. 52 | These [guidelines](https://chris.beams.io/posts/git-commit/) should be kept in mind. 53 | 54 | To facilitate communication with other contributors, the project is making use 55 | of GitHub's "assignee" field. First check that no one is assigned and then 56 | comment suggesting that you're working on it. If someone is already assigned, 57 | don't hesitate to ask if the assigned party or previous commenters are still 58 | working on it if it has been awhile. 59 | 60 | Peer review 61 | ----------- 62 | 63 | Anyone may participate in peer review which is expressed by comments in the 64 | pull request. Typically reviewers will review the code for obvious errors, as 65 | well as test out the patch set and opine on the technical merits of the patch. 66 | PR should be reviewed first on the conceptual level before focusing on code 67 | style or grammar fixes. 68 | 69 | Coding Conventions 70 | ------------------ 71 | 72 | This codebase uses spaces, not tabs. 73 | Use `cargo fmt` with the default settings to format code before committing. 74 | This is also enforced by the CI. 75 | 76 | Security 77 | -------- 78 | 79 | Security is a high priority of BDK; disclosure of security vulnerabilites helps 80 | prevent user loss of funds. 81 | 82 | Note that BDK is currently considered "pre-production" during this time, there 83 | is no special handling of security issues. Please simply open an issue on 84 | Github. 85 | 86 | Testing 87 | ------- 88 | 89 | Related to the security aspect, BDK developers take testing very seriously. 90 | Due to the modular nature of the project, writing new functional tests is easy 91 | and good test coverage of the codebase is an important goal. 92 | Refactoring the project to enable fine-grained unit testing is also an ongoing 93 | effort. 94 | 95 | Going further 96 | ------------- 97 | 98 | You may be interested by Jon Atacks guide on [How to review Bitcoin Core PRs](https://github.com/jonatack/bitcoin-development/blob/master/how-to-review-bitcoin-core-prs.md) 99 | and [How to make Bitcoin Core PRs](https://github.com/jonatack/bitcoin-development/blob/master/how-to-make-bitcoin-core-prs.md). 100 | While there are differences between the projects in terms of context and 101 | maturity, many of the suggestions offered apply to this project. 102 | 103 | Overall, have fun :) 104 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | Changelog information can be found in each release's git tag and can be viewed with `git tag -ln100 "v*"`. 3 | Changelog info is also documented on the [GitHub releases](https://github.com/bitcoindevkit/bdk-cli/releases) 4 | page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. 5 | 6 | ## [Unreleased] 7 | 8 | ## [2.0.0] 9 | 10 | - Removed MSRV and bumped Rust Edition to 2024 11 | - Add `--pretty` flag for formatting outputs in human-readable form 12 | - Updated `bdk_wallet ` to `2.1.0`, `bdk_bitcoind_rpc` to `0.21.0`, `bdk_esplora` to `0.22.1`, `bdk_kyoto` to `0.13.1` 13 | - Updated `tracing-subscriber` to 0.3.20 14 | - Added `tr` script type to `compile` command to support creating taproot descriptors 15 | - Added `redb` as an alternative persistence to `sqlite` 16 | - Removed connection requirement for sending transactions in `bdk_kyoto` 17 | - Added `just` command runner for common commands and `regtest` bitcoind 18 | - Renamed `BuilderError` to `KyotoBuilderError` and added `KyotoUpdateError` 19 | - Updated `bdk_electrum` to 0.23.0 20 | - Added `just` example for starting, connecting and funding a wallet in regtest 21 | 22 | ## [1.0.0] 23 | 24 | - Changed the MSRV to 1.75.0 and bumped the rust edition to 2021 25 | - Changed `electrum` client to use `bdk_electrum`, `sqlite` feature to use `bdk_wallet/rusqlite` 26 | - Updated `repl` to use shlex instead of `regex`, `rustyline` and `fd-lock` 27 | - Updated `bdk_wallet` to 1.0.0 28 | - Updated `bdk_bitcoind_rpc` to `0.18.0`, `bdk_electrum` to `0.21.0`, `bdk_esplora` to `0.20.1`, `bdk-reserves` to `0.29.0` 29 | - Updated `electrsd` to v31.0 30 | - Updated `clap` to v4.5 31 | - Added `cbf` (compact block filter) feature using `bdk-kyoto` 32 | - Replaced `regtest-bitcoin` feature with `rpc` 33 | - Added custom error enum 34 | - Set `repl` and `sqlite` as the default features 35 | - Set default fee rate to `FeeRate::BROADCAST_MIN` 36 | - Enabled replace-by-fee by default 37 | - Replaced `ExtendedPrivateKey` with `Xpriv` 38 | - Replaced `list_transactions` with `transactions` 39 | - Replaced `allow_shringking` with `drain_to` methods 40 | - Replaced `Wallet` with `PersistedWallet` 41 | - Replaced `descriptor` CLI parameter with `ext-descriptor` and `change` with `int-descriptor` 42 | - Dropped support for `sled` 43 | - Dropped `key-value-db` feature 44 | - Dropped `esplora-ureq`, `esplora-reqwest`, `regtest-bitcoin`, `regtest-electrum`, `regtest-node` and `reserves` features 45 | 46 | ## [0.27.1] 47 | 48 | - Added hardware signers through the use of HWI. 49 | - Bumped rustc stable to 1.65. 50 | - Bumped electrsd version to v0.22.*. 51 | 52 | ## [0.26.0] 53 | 54 | - Check that a `PSBT` is signed before broadcast, else throw a useful error message to user. 55 | - Miniscript Translation capability to an `AliasMap` in wasm, to enhance the paly ground interface. 56 | - cli-app framework from `structop` to `clap`. 57 | - Temporarily disable `compact_filters` until `bdk v1.0.0` launch. 58 | 59 | ## [0.6.0] 60 | 61 | - Add distinct `key-value-db` and `sqlite-db` features, keep default as `key-value-db` 62 | - Reorganize existing codes in separate modules. Change crate type from lib to bin. 63 | - Rewrite relevant doc comments as `structopt` help document. 64 | - Update `bdk` and `bdk-reserves` to v0.22.0. 65 | - Change default database to `sqlite`. 66 | - Change the `esplora-reqwest` feature to always use async mode 67 | - Change rpc `--skip-blocks` option to `--start-time` which specifies time initial sync will start scanning from. 68 | - Add new `bdk-cli node []` to control the backend node deployed by `regtest-*` features. 69 | - Add an integration testing framework in `src/tests/integration.rs`. This framework uses the `regtest-*` feature to run automated testing with bdk-cli. 70 | - Add possible values for `network` option to improve help message, and fix typo in doc. 71 | - Add a module `wasm` containing objects to use bdk-cli from web assembly 72 | 73 | ## [0.5.0] 74 | 75 | - Re-license to dual MIT and Apache 2.0 and update project name to "Bitcoin Dev Kit" 76 | - Update to bdk and bdk-reserves to `0.18.0` 77 | - Add 'verify' feature flag which enables transaction verification against consensus rules during sync. 78 | - Add experimental `regtest-*` features to automatically deploy local regtest nodes 79 | (bitcoind, and electrs) while running cli commands. 80 | - Put cached wallet data in separate directories: ~/.bdk-bitcoin/ 81 | - New MSRV set to `1.56` 82 | 83 | ## [0.4.0] 84 | 85 | - Replace `wallet bump_fee` command `--send_all` with new `--shrink` option 86 | - Add 'reserve' feature to enable proof of reserve 87 | - If no wallet name is provided, derive one from the descriptor instead of using "main" 88 | - Add optional cookie authentication for rpc backend 89 | 90 | ## [0.3.0] 91 | 92 | - Add RPC backend support, after bdk v0.12.0 release 93 | - Update default feature to not include electrum 94 | - Upgrade to `bdk` v0.12.x 95 | - Add top level command "Compile" which compiles a miniscript policy to an output descriptor 96 | - Add `CompactFilterOpts` to `WalletOpts` to enable compact-filter blockchain configuration 97 | - Add `verbose` option to `WalletOpts` to display PSBTs and transaction details also in JSON format 98 | - Require at most one blockchain client feature be enabled at a time 99 | - Change default esplora server URL to https://blockstream.info/testnet/api/ to match default testnet network 100 | 101 | ## [0.2.0] 102 | 103 | - Add support for `wasm` 104 | - Upgrade `bdk` to `0.4.0` and `bdk-macros` to `0.3.0` 105 | - A wallet without a `Blockchain` is used when handling offline wallet sub-commands 106 | - Add top level commands "wallet", "key", and "repl" 107 | - Add "key" sub-commands to "generate" and "restore" a master private key 108 | - Add "key" sub-command to "derive" an extended public key from a master private key 109 | - "repl" command now has an "exit" sub-command 110 | - "wallet" sub-commands and options must be proceeded by "wallet" command 111 | - "repl" command loop now includes both "wallet" and "key" sub-commands 112 | 113 | ## [0.1.0] 114 | 115 | - Add CONTRIBUTING.md 116 | - Add CI and code coverage Discord badges to the README 117 | - Add CI and code coverage github actions workflows 118 | - Add scheduled audit check in CI 119 | - Add CHANGELOG.md 120 | - If an invalid network name return an error instead of defaulting to `testnet` 121 | 122 | ## [0.1.0-beta.1] 123 | 124 | [Unreleased]: https://github.com/bitcoindevkit/bdk-cli/compare/v2.0.0...HEAD 125 | [2.0.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v1.0.0....v2.0.0 126 | [1.0.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.27.1...v1.0.0 127 | [0.27.1]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.26.0...v0.27.1 128 | [0.26.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.6.0...v0.26.0 129 | [0.6.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.5.0...v0.6.0 130 | [0.5.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.4.0...v0.5.0 131 | [0.4.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.3.0...v0.4.0 132 | [0.3.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.2.0...v0.3.0 133 | [0.2.0]: https://github.com/bitcoindevkit/bdk-cli/compare/v0.1.0...v0.2.0 134 | [0.1.0]: https://github.com/bitcoindevkit/bdk-cli/compare/0.1.0-beta.1...v0.1.0 135 | [0.1.0-beta.1]: https://github.com/bitcoindevkit/bdk-cli/compare/84a02e35...0.1.0-beta.1 136 | -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 Bitcoin Dev Kit Developers 2 | // 3 | // This file is licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your option. 6 | // You may not use this file except in accordance with one or both of these 7 | // licenses. 8 | 9 | //! bdk-cli Integration Test Framework 10 | //! 11 | //! This modules performs the necessary integration test for bdk-cli 12 | //! The tests can be run using `cargo test` 13 | 14 | #[cfg(feature = "rpc")] 15 | mod test { 16 | use serde_json::{Value, json}; 17 | use std::convert::From; 18 | use std::env::temp_dir; 19 | use std::path::PathBuf; 20 | use std::process::Command; 21 | 22 | /// Testing errors for integration tests 23 | #[allow(dead_code)] 24 | #[derive(Debug)] 25 | enum IntTestError { 26 | // IO error 27 | IO(std::io::Error), 28 | // Command execution error 29 | CmdExec(String), 30 | // Json Data error 31 | JsonData(String), 32 | } 33 | 34 | impl From for IntTestError { 35 | fn from(e: std::io::Error) -> Self { 36 | IntTestError::IO(e) 37 | } 38 | } 39 | 40 | // Helper function 41 | // Runs a system command with given args 42 | #[allow(dead_code)] 43 | fn run_cmd_with_args(cmd: &str, args: &[&str]) -> Result { 44 | let output = Command::new(cmd).args(args).output().unwrap(); 45 | let mut value = output.stdout; 46 | let error = output.stderr; 47 | if value.is_empty() { 48 | return Err(IntTestError::CmdExec(String::from_utf8(error).unwrap())); 49 | } 50 | value.pop(); // remove `\n` at end 51 | let output_string = std::str::from_utf8(&value).unwrap(); 52 | let json_value: serde_json::Value = match serde_json::from_str(output_string) { 53 | Ok(value) => value, 54 | Err(_) => json!(output_string), // bitcoin-cli will sometime return raw string 55 | }; 56 | Ok(json_value) 57 | } 58 | 59 | // Helper Function 60 | // Transforms a json value to string 61 | #[allow(dead_code)] 62 | fn value_to_string(value: &Value) -> Result { 63 | match value { 64 | Value::Bool(bool) => match bool { 65 | true => Ok("true".to_string()), 66 | false => Ok("false".to_string()), 67 | }, 68 | Value::Number(n) => Ok(n.to_string()), 69 | Value::String(s) => Ok(s.to_string()), 70 | _ => Err(IntTestError::JsonData( 71 | "Value parsing not implemented for this type".to_string(), 72 | )), 73 | } 74 | } 75 | 76 | // Helper Function 77 | // Extracts value from a given json object and key 78 | #[allow(dead_code)] 79 | fn get_value(json: &Value, key: &str) -> Result { 80 | let map = json 81 | .as_object() 82 | .ok_or(IntTestError::JsonData("Json is not an object".to_string()))?; 83 | let value = map 84 | .get(key) 85 | .ok_or(IntTestError::JsonData("Invalid key".to_string()))? 86 | .to_owned(); 87 | let string_value = value_to_string(&value)?; 88 | Ok(string_value) 89 | } 90 | 91 | /// The bdk-cli command struct 92 | /// Use it to perform all bdk-cli operations 93 | #[allow(dead_code)] 94 | #[derive(Debug)] 95 | struct BdkCli { 96 | target: String, 97 | network: String, 98 | verbosity: bool, 99 | recv_desc: Option, 100 | chang_desc: Option, 101 | node_datadir: Option, 102 | } 103 | 104 | impl BdkCli { 105 | /// Construct a new [`BdkCli`] struct 106 | fn new( 107 | network: &str, 108 | node_datadir: Option, 109 | verbosity: bool, 110 | features: &[&str], 111 | ) -> Result { 112 | // Build bdk-cli with given features 113 | let mut feat = "--features=".to_string(); 114 | for item in features { 115 | feat.push_str(item); 116 | feat.push(','); 117 | } 118 | feat.pop(); // remove the last comma 119 | let _build = Command::new("cargo").args(["build", &feat]).output()?; 120 | 121 | let mut bdk_cli = Self { 122 | target: "./target/debug/bdk-cli".to_string(), 123 | network: network.to_string(), 124 | verbosity, 125 | recv_desc: None, 126 | chang_desc: None, 127 | node_datadir, 128 | }; 129 | 130 | println!("BDK-CLI Config : {bdk_cli:#?}"); 131 | let bdk_master_key = bdk_cli.key_exec(&["generate"])?; 132 | let bdk_xprv = get_value(&bdk_master_key, "xprv")?; 133 | 134 | let bdk_recv_desc = 135 | bdk_cli.key_exec(&["derive", "--path", "m/84h/1h/0h/0", "--xprv", &bdk_xprv])?; 136 | let bdk_recv_desc = get_value(&bdk_recv_desc, "xprv")?; 137 | let bdk_recv_desc = format!("wpkh({bdk_recv_desc})"); 138 | 139 | let bdk_chng_desc = 140 | bdk_cli.key_exec(&["derive", "--path", "m/84h/1h/0h/1", "--xprv", &bdk_xprv])?; 141 | let bdk_chng_desc = get_value(&bdk_chng_desc, "xprv")?; 142 | let bdk_chng_desc = format!("wpkh({bdk_chng_desc})"); 143 | 144 | bdk_cli.recv_desc = Some(bdk_recv_desc); 145 | bdk_cli.chang_desc = Some(bdk_chng_desc); 146 | 147 | Ok(bdk_cli) 148 | } 149 | 150 | /// Execute bdk-cli wallet commands with given args 151 | fn wallet_exec(&self, args: &[&str]) -> Result { 152 | // Check if data directory is specified 153 | let mut wallet_args = if let Some(datadir) = &self.node_datadir { 154 | let datadir = datadir.as_os_str().to_str().unwrap(); 155 | ["--network", &self.network, "--datadir", datadir, "wallet"].to_vec() 156 | } else { 157 | ["--network", &self.network, "wallet"].to_vec() 158 | }; 159 | 160 | if self.verbosity { 161 | wallet_args.push("-v"); 162 | } 163 | 164 | wallet_args.push("-d"); 165 | wallet_args.push(self.recv_desc.as_ref().unwrap()); 166 | wallet_args.push("-c"); 167 | wallet_args.push(self.chang_desc.as_ref().unwrap()); 168 | 169 | for arg in args { 170 | wallet_args.push(arg); 171 | } 172 | run_cmd_with_args(&self.target, &wallet_args) 173 | } 174 | 175 | /// Execute bdk-cli key commands with given args 176 | fn key_exec(&self, args: &[&str]) -> Result { 177 | let mut key_args = ["key"].to_vec(); 178 | for arg in args { 179 | key_args.push(arg); 180 | } 181 | run_cmd_with_args(&self.target, &key_args) 182 | } 183 | 184 | /// Execute bdk-cli node command 185 | fn node_exec(&self, args: &[&str]) -> Result { 186 | // Check if data directory is specified 187 | let mut node_args = if let Some(datadir) = &self.node_datadir { 188 | let datadir = datadir.as_os_str().to_str().unwrap(); 189 | ["--network", &self.network, "--datadir", datadir, "node"].to_vec() 190 | } else { 191 | ["--network", &self.network, "node"].to_vec() 192 | }; 193 | 194 | for arg in args { 195 | node_args.push(arg); 196 | } 197 | run_cmd_with_args(&self.target, &node_args) 198 | } 199 | } 200 | 201 | // Run A Basic wallet operation test, with given feature 202 | #[cfg(test)] 203 | #[allow(dead_code)] 204 | fn basic_wallet_ops(feature: &str) { 205 | // Create a temporary directory for testing env 206 | let mut test_dir = std::env::current_dir().unwrap(); 207 | test_dir.push("bdk-testing"); 208 | 209 | let test_dir = temp_dir(); 210 | // let test_dir = test_temp_dir.into_path().to_path_buf(); 211 | 212 | // Create bdk-cli instance 213 | let bdk_cli = BdkCli::new("regtest", Some(test_dir), false, &[feature]).unwrap(); 214 | 215 | // Generate 101 blocks 216 | bdk_cli.node_exec(&["generate", "101"]).unwrap(); 217 | 218 | // Get a bdk address 219 | let bdk_addr_json = bdk_cli.wallet_exec(&["get_new_address"]).unwrap(); 220 | let bdk_addr = get_value(&bdk_addr_json, "address").unwrap(); 221 | 222 | // Send coins from core to bdk 223 | bdk_cli 224 | .node_exec(&["sendtoaddress", &bdk_addr, "1000000000"]) 225 | .unwrap(); 226 | 227 | bdk_cli.node_exec(&["generate", "1"]).unwrap(); 228 | 229 | // Sync the bdk wallet 230 | bdk_cli.wallet_exec(&["sync"]).unwrap(); 231 | 232 | // Get the balance 233 | let balance_json = bdk_cli.wallet_exec(&["get_balance"]).unwrap(); 234 | let confirmed_balance = balance_json 235 | .as_object() 236 | .unwrap() 237 | .get("satoshi") 238 | .unwrap() 239 | .as_object() 240 | .unwrap() 241 | .get("confirmed") 242 | .unwrap() 243 | .as_u64() 244 | .unwrap(); 245 | assert_eq!(confirmed_balance, 1000000000u64); 246 | } 247 | 248 | // #[test] 249 | // #[cfg(feature = "regtest-bitcoin")] 250 | // fn test_basic_wallet_op_bitcoind() { 251 | // basic_wallet_ops("regtest-bitcoin") 252 | // } 253 | // 254 | // #[test] 255 | // #[cfg(feature = "regtest-electrum")] 256 | // fn test_basic_wallet_op_electrum() { 257 | // basic_wallet_ops("regtest-electrum") 258 | // } 259 | } 260 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

BDK-CLI

3 | 4 | 5 | 6 |

7 | A Command-line Bitcoin Wallet App in pure rust using BDK 8 |

9 | 10 |

11 | Crate Info 12 | MIT or Apache-2.0 Licensed 13 | CI Status 14 | 15 | API Docs 16 | Chat on Discord 17 |

18 | 19 |

20 | Project Homepage 21 | | 22 | Documentation 23 |

24 |
25 | 26 | 27 | ## About 28 | 29 | **EXPERIMENTAL** 30 | This crate has been updated to use `bdk_wallet` 1.x. Only use for testing on test networks. 31 | 32 | This project provides a command-line Bitcoin wallet application using the latest [BDK Wallet APIs](https://docs.rs/bdk_wallet/1.0.0/bdk_wallet/index.html) and chain sources ([RPC](https://docs.rs/bdk_bitcoind_rpc/0.18.0/bdk_bitcoind_rpc/index.html), [Electrum](https://docs.rs/bdk_electrum/0.21.0/bdk_electrum/index.html), [Esplora](https://docs.rs/bdk_esplora/0.21.0/bdk_esplora/), [Kyoto](https://docs.rs/bdk_kyoto/0.9.0/bdk_kyoto/)). This might look tiny and innocent, but by harnessing the power of BDK it provides a powerful generic descriptor based command line wallet tool. 33 | And yes, it can do Taproot!! 34 | 35 | This crate can be used for the following purposes: 36 | - Instantly create a miniscript based wallet and connect to your backend of choice (Electrum, Esplora, Core RPC, Kyoto etc) and quickly play around with your own complex bitcoin scripting workflow. With one or many wallets, connected with one or many backends. 37 | - The `tests/integration.rs` module is used to document high level complex workflows between BDK and different Bitcoin infrastructure systems, like Core, Electrum and Lightning(soon TM). 38 | - Receive and send Async Payjoins. Note that even though Async Payjoin as a protocol allows the receiver and sender to go offline during the payjoin, the BDK CLI implementation currently does not support persisting. 39 | - (Planned) Expose the basic command handler via `wasm` to integrate `bdk-cli` functionality natively into the web platform. See also the [playground](https://bitcoindevkit.org/bdk-cli/playground/) page. 40 | 41 | If you are considering using BDK in your own wallet project bdk-cli is a nice playground to get started with. It allows easy testnet and regtest wallet operations, to try out what's possible with descriptors, miniscript, and BDK APIs. For more information on BDK refer to the [website](https://bitcoindevkit.org/) and the [rust docs](https://docs.rs/bdk_wallet/1.0.0/bdk_wallet/index.html) 42 | 43 | bdk-cli can be compiled with different features to suit your experimental needs. 44 | - Database Options 45 | - `sqlite` : Sets the wallet database to a `sqlite3` db. 46 | - Blockchain Client Options 47 | - `esplora` : Connects the wallet to an esplora server. 48 | - `electrum` : Connects the wallet to an electrum server. 49 | - `kyoto`: Connects the wallet to a kyoto client and server. 50 | - `rpc`: Connects the wallet to Bitcoind server. 51 | - Extra Utility Tools 52 | - `repl` : use bdk-cli as a [REPL](https://codewith.mu/en/tutorials/1.0/repl) shell (useful for quick manual testing of wallet operations). 53 | - `compiler` : opens up bdk-cli policy compiler commands. 54 | 55 | The `default` feature set is `repl` and `sqlite`. With the `default` features, `bdk-cli` can be used as an **air-gapped** wallet, and can do everything that doesn't require a network connection. 56 | 57 | 58 | ## Install bdk-cli 59 | 60 | ### From source 61 | 62 | To install a dev version of `bdk-cli` from a local git repo with the `electrum` blockchain client enabled: 63 | 64 | ```shell 65 | cd 66 | cargo install --path . --features electrum 67 | bdk-cli help # to verify it worked 68 | ``` 69 | 70 | If no blockchain client feature is enabled online wallet commands `sync` and `broadcast` will be 71 | disabled. To enable these commands a blockchain client feature such as `electrum` or another 72 | blockchain client feature must be enabled. Below is an example of how to run the `bdk-cli` binary with 73 | the `electrum` blockchain client feature. 74 | 75 | ```shell 76 | RUST_LOG=debug cargo run --features electrum -- --network testnet4 wallet --wallet testnetwallet --ext-descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" --client-type electrum --database-type sqlite --url "ssl://mempool.space:40002" sync 77 | ``` 78 | 79 | Available blockchain client features are: 80 | `electrum`, `esplora`, `kyoto`, `rpc`. 81 | 82 | ### From crates.io 83 | You can install the binary for the latest tag of `bdk-cli` with online wallet features 84 | directly from [crates.io](https://crates.io/crates/bdk-cli) with a command as below: 85 | ```sh 86 | cargo install bdk-cli --features electrum 87 | ``` 88 | 89 | ### bdk-cli bin usage examples 90 | 91 | To get usage information for the `bdk-cli` binary use the below command which returns a list of 92 | available wallet options and commands: 93 | 94 | ```shell 95 | cargo run 96 | ``` 97 | 98 | To sync a wallet to the default electrum server: 99 | 100 | ```shell 101 | cargo run --features electrum -- --network testnet4 wallet --wallet sample_wallet --ext-descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" --database-type sqlite --client-type electrum --url "ssl://mempool.space:40002" sync 102 | ``` 103 | 104 | To get a wallet balance with customized logging: 105 | 106 | ```shell 107 | RUST_LOG=debug,rusqlite=info,rustls=info cargo run -- wallet --external-descriptor "wpkh(tpubEBr4i6yk5nf5DAaJpsi9N2pPYBeJ7fZ5Z9rmN4977iYLCGco1VyjB9tvvuvYtfZzjD5A8igzgw3HeWeeKFmanHYqksqZXYXGsw5zjnj7KM9/*)" balance 108 | ``` 109 | 110 | To generate a new extended master key, suitable for use in a descriptor: 111 | 112 | ```shell 113 | cargo run -- key generate 114 | ``` 115 | 116 | To start a Payjoin session as the receiver with regtest RPC and example OHTTP relays: 117 | 118 | ``` 119 | cargo run --features rpc -- wallet --wallet sample_wallet --url="127.0.0.1:18443" --ext-descriptor "wpkh(tprv8ZgxMBicQKsPd2PoUEcGNDHPZmVWgtPYERAwMG6qHheX6LN4oaazp3qZU7mykiaAZga1ZB2SJJR6Mriyq8MocMs7QTe7toaabSwTWu5fRFz/84h/1h/0h/0/*)#8guqp7rn" receive_payjoin --amount 21120000 --max_fee_rate 1000 --directory "https://payjo.in" --ohttp_relay "https://pj.bobspacebkk.com" --ohttp_relay "https://pj.benalleng.com" 120 | ``` 121 | 122 | To send a Payjoin with regtest RPC and example OHTTP relays: 123 | 124 | ``` 125 | cargo run --features rpc -- wallet --wallet sample_wallet --url="127.0.0.1:18443" --ext-descriptor "wpkh(tprv8ZgxMBicQKsPd2PoUEcGNDHPZmVWgtPYERAwMG6qHheX6LN4oaazp3qZU7mykiaAZga1ZB2SJJR6Mriyq8MocMs7QTe7toaabSwTWu5fRFz/84h/1h/0h/0/*)#8guqp7rn" send_payjoin --ohttp_relay "https://pj.bobspacebkk.com" --ohttp_relay "https://pj.benalleng.com" --fee_rate 1 --uri "" 126 | ``` 127 | 128 | ## Justfile 129 | 130 | We have added the `just` command runner to help you with common commands (during development) and running regtest `bitcoind` if you are using the `rpc` feature. 131 | Visit the [just](https://just.systems/man/en/packages.html) page for setup instructions. 132 | 133 | The below are some of the commands included: 134 | 135 | ``` shell 136 | just # list all available recipes 137 | just test # test the project 138 | just build # build the project 139 | ``` 140 | 141 | ### Using `Justfile` to run `bitcoind` as a Client 142 | 143 | If you are testing `bdk-cli` in regtest mode and wants to use your `bitcoind` node as a blockchain client, the `Justfile` can help you to quickly do so. Below are the steps to use your `bitcoind` node in *regtest* mode with `bdk-cli`: 144 | 145 | Note: You can modify the `Justfile` to reflect your nodes' configuration values. These values are the default values used in `bdk-cli` 146 | > * default wallet: The set default wallet name is `regtest_default_wallet` 147 | > * default data directory: The set default data directory is `~/.bdk-bitcoin` 148 | > * RPC username: The set RPC username is `user` 149 | > * RPC password: The set RPC password is `password` 150 | 151 | #### Steps 152 | 153 | 1. Start bitcoind 154 | ```shell 155 | just start 156 | ``` 157 | 158 | 2. Create or load a bitcoind wallet with default wallet name 159 | 160 | ```shell 161 | just create 162 | ``` 163 | or 164 | ```shell 165 | just load 166 | ``` 167 | 168 | 3. Generate a bitcoind wallet address to send regtest bitcoins to. 169 | 170 | ```shell 171 | just address 172 | ``` 173 | 174 | 4. Mine 101 blocks on regtest to bitcoind wallet address 175 | ```shell 176 | just generate 101 $(just address) 177 | ``` 178 | 179 | 5. Check the bitcoind wallet balance 180 | ```shell 181 | just balance 182 | ``` 183 | 184 | 6. Setup your `bdk-cli` wallet config and connect it to your regtest node to perform a `sync` 185 | ```shell 186 | export NETWORK=regtest 187 | export EXT_DESCRIPTOR='wpkh(tprv8ZgxMBicQKsPdMzWj9KHvoExKJDqfZFuT5D8o9XVZ3wfyUcnPNPJKncq5df8kpDWnMxoKbGrpS44VawHG17ZSwTkdhEtVRzSYXd14vDYXKw/0/*)' 188 | export INT_DESCRIPTOR='wpkh(tprv8ZgxMBicQKsPdMzWj9KHvoExKJDqfZFuT5D8o9XVZ3wfyUcnPNPJKncq5df8kpDWnMxoKbGrpS44VawHG17ZSwTkdhEtVRzSYXd14vDYXKw/1/*)' 189 | export DATABASE_TYPE=sqlite 190 | cargo run --features rpc -- wallet -u "127.0.0.1:18443" -c rpc -a user:password sync 191 | ``` 192 | 193 | 7. Generate an address from your `bdk-cli` wallet and fund it with 10 bitcoins from your bitcoind node's wallet 194 | ```shell 195 | export address=$(cargo run --features rpc -- wallet -u "127.0.0.1:18443" -c rpc -a user:password new_address | jq '.address') 196 | just send 10 $address 197 | ``` 198 | 199 | 8. Mine 6 more blocks to the bitcoind wallet 200 | ```shell 201 | just generate 6 $(just address) 202 | ``` 203 | 204 | 9. You can `sync` your `bdk-cli` wallet now and the balance should reflect the regtest bitcoin you received 205 | ```shell 206 | cargo run --features rpc -- wallet -u "127.0.0.1:18443" -c rpc -a user:password sync 207 | cargo run --features rpc -- wallet -u "127.0.0.1:18443" -c rpc -a user:password balance 208 | ``` 209 | 210 | ## Formatting Responses using `--pretty` flag 211 | 212 | You can optionally return outputs of commands in human-readable, tabular format instead of `JSON`. To enable this option, simply add the `--pretty` flag as a top level flag. For instance, you wallet's balance in a pretty format, you can run: 213 | 214 | ```shell 215 | cargo run --pretty -n signet wallet -w {wallet_name} -d sqlite balance 216 | ``` 217 | This is available for wallet, key, repl and compile features. When ommitted, outputs default to `JSON`. 218 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 Bitcoin Dev Kit Developers 2 | // 3 | // This file is licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your option. 6 | // You may not use this file except in accordance with one or both of these 7 | // licenses. 8 | 9 | //! bdk-cli Command structure 10 | //! 11 | //! This module defines all the bdk-cli commands structure. 12 | //! All optional args are defined in the structs below. 13 | //! All subcommands are defined in the below enums. 14 | 15 | #![allow(clippy::large_enum_variant)] 16 | use bdk_wallet::bitcoin::{ 17 | Address, Network, OutPoint, ScriptBuf, 18 | bip32::{DerivationPath, Xpriv}, 19 | }; 20 | use clap::{Args, Parser, Subcommand, ValueEnum, value_parser}; 21 | 22 | #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] 23 | use crate::utils::parse_proxy_auth; 24 | use crate::utils::{parse_address, parse_outpoint, parse_recipient}; 25 | 26 | /// The BDK Command Line Wallet App 27 | /// 28 | /// bdk-cli is a lightweight command line bitcoin wallet, powered by BDK. 29 | /// This app can be used as a playground as well as testing environment to simulate 30 | /// various wallet testing situations. If you are planning to use BDK in your wallet, bdk-cli 31 | /// is also a great intro tool to get familiar with the BDK API. 32 | /// 33 | /// But this is not just any toy. 34 | /// bdk-cli is also a fully functioning Bitcoin wallet with taproot support! 35 | /// 36 | /// For more information checkout 37 | #[derive(PartialEq, Clone, Debug, Parser)] 38 | #[command(version, about, long_about = None)] 39 | pub struct CliOpts { 40 | /// Sets the network. 41 | #[arg( 42 | env = "NETWORK", 43 | short = 'n', 44 | long = "network", 45 | default_value = "testnet", 46 | value_parser = value_parser!(Network) 47 | )] 48 | pub network: Network, 49 | /// Sets the wallet data directory. 50 | /// Default value : ~/.bdk-bitcoin 51 | #[arg(env = "DATADIR", short = 'd', long = "datadir")] 52 | pub datadir: Option, 53 | /// Output results in pretty format (instead of JSON). 54 | #[arg(long = "pretty", global = true)] 55 | pub pretty: bool, 56 | /// Top level cli sub-commands. 57 | #[command(subcommand)] 58 | pub subcommand: CliSubCommand, 59 | } 60 | 61 | /// Top level cli sub-commands. 62 | #[derive(Debug, Subcommand, Clone, PartialEq)] 63 | #[command(rename_all = "snake")] 64 | pub enum CliSubCommand { 65 | /// Wallet operations. 66 | /// 67 | /// bdk-cli wallet operations includes all the basic wallet level tasks. 68 | /// Most commands can be used without connecting to any backend. To use commands that 69 | /// needs backend like `sync` and `broadcast`, compile the binary with specific backend feature 70 | /// and use the configuration options below to configure for that backend. 71 | Wallet { 72 | #[command(flatten)] 73 | wallet_opts: WalletOpts, 74 | #[command(subcommand)] 75 | subcommand: WalletSubCommand, 76 | }, 77 | /// Key management operations. 78 | /// 79 | /// Provides basic key operations that are not related to a specific wallet such as generating a 80 | /// new random master extended key or restoring a master extended key from mnemonic words. 81 | /// 82 | /// These sub-commands are **EXPERIMENTAL** and should only be used for testing. Do not use this 83 | /// feature to create keys that secure actual funds on the Bitcoin mainnet. 84 | Key { 85 | #[clap(subcommand)] 86 | subcommand: KeySubCommand, 87 | }, 88 | /// Compile a miniscript policy to an output descriptor. 89 | #[cfg(feature = "compiler")] 90 | #[clap(long_about = "Miniscript policy compiler")] 91 | Compile { 92 | /// Sets the spending policy to compile. 93 | #[arg(env = "POLICY", required = true, index = 1)] 94 | policy: String, 95 | /// Sets the script type used to embed the compiled policy. 96 | #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"] 97 | )] 98 | script_type: String, 99 | }, 100 | #[cfg(feature = "repl")] 101 | /// REPL command loop mode. 102 | /// 103 | /// REPL command loop can be used to make recurring callbacks to an already loaded wallet. 104 | /// This mode is useful for hands on live testing of wallet operations. 105 | Repl { 106 | #[command(flatten)] 107 | wallet_opts: WalletOpts, 108 | }, 109 | /// Output Descriptors operations. 110 | /// 111 | /// Generate output descriptors from either extended key (Xprv/Xpub) or mnemonic phrase. 112 | /// This feature is intended for development and testing purposes only. 113 | Descriptor { 114 | /// Descriptor type (script type) 115 | #[arg( 116 | long = "type", 117 | short = 't', 118 | value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], 119 | default_value = "wsh" 120 | )] 121 | desc_type: String, 122 | /// Optional key: xprv, xpub, or mnemonic phrase 123 | key: Option, 124 | }, 125 | } 126 | /// Wallet operation subcommands. 127 | #[derive(Debug, Subcommand, Clone, PartialEq)] 128 | pub enum WalletSubCommand { 129 | #[cfg(any( 130 | feature = "electrum", 131 | feature = "esplora", 132 | feature = "cbf", 133 | feature = "rpc" 134 | ))] 135 | #[command(flatten)] 136 | OnlineWalletSubCommand(OnlineWalletSubCommand), 137 | #[command(flatten)] 138 | OfflineWalletSubCommand(OfflineWalletSubCommand), 139 | } 140 | 141 | #[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] 142 | pub enum DatabaseType { 143 | /// Sqlite database 144 | #[cfg(feature = "sqlite")] 145 | Sqlite, 146 | /// Redb database 147 | #[cfg(feature = "redb")] 148 | Redb, 149 | } 150 | 151 | #[cfg(any( 152 | feature = "electrum", 153 | feature = "esplora", 154 | feature = "rpc", 155 | feature = "cbf" 156 | ))] 157 | #[derive(Clone, ValueEnum, Debug, Eq, PartialEq)] 158 | pub enum ClientType { 159 | #[cfg(feature = "electrum")] 160 | Electrum, 161 | #[cfg(feature = "esplora")] 162 | Esplora, 163 | #[cfg(feature = "rpc")] 164 | Rpc, 165 | #[cfg(feature = "cbf")] 166 | Cbf, 167 | } 168 | 169 | /// Config options wallet operations can take. 170 | #[derive(Debug, Args, Clone, PartialEq, Eq)] 171 | pub struct WalletOpts { 172 | /// Selects the wallet to use. 173 | #[arg(env = "WALLET_NAME", short = 'w', long = "wallet")] 174 | pub wallet: Option, 175 | /// Adds verbosity, returns PSBT in JSON format alongside serialized, displays expanded objects. 176 | #[arg(env = "VERBOSE", short = 'v', long = "verbose")] 177 | pub verbose: bool, 178 | /// Sets the descriptor to use for the external addresses. 179 | #[arg(env = "EXT_DESCRIPTOR", short = 'e', long)] 180 | pub ext_descriptor: Option, 181 | /// Sets the descriptor to use for internal/change addresses. 182 | #[arg(env = "INT_DESCRIPTOR", short = 'i', long)] 183 | pub int_descriptor: Option, 184 | #[cfg(any( 185 | feature = "electrum", 186 | feature = "esplora", 187 | feature = "rpc", 188 | feature = "cbf" 189 | ))] 190 | #[arg(env = "CLIENT_TYPE", short = 'c', long, value_enum, required = true)] 191 | pub client_type: ClientType, 192 | #[cfg(any(feature = "sqlite", feature = "redb"))] 193 | #[arg(env = "DATABASE_TYPE", short = 'd', long, value_enum, required = true)] 194 | pub database_type: DatabaseType, 195 | /// Sets the server url. 196 | #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] 197 | #[arg(env = "SERVER_URL", short = 'u', long, required = true)] 198 | pub url: String, 199 | /// Electrum batch size. 200 | #[cfg(feature = "electrum")] 201 | #[arg(env = "ELECTRUM_BATCH_SIZE", short = 'b', long, default_value = "10")] 202 | pub batch_size: usize, 203 | /// Esplora parallel requests. 204 | #[cfg(feature = "esplora")] 205 | #[arg( 206 | env = "ESPLORA_PARALLEL_REQUESTS", 207 | short = 'p', 208 | long, 209 | default_value = "5" 210 | )] 211 | pub parallel_requests: usize, 212 | #[cfg(feature = "rpc")] 213 | /// Sets the rpc basic authentication. 214 | #[arg( 215 | env = "USER:PASSWD", 216 | short = 'a', 217 | long, 218 | value_parser = parse_proxy_auth, 219 | default_value = "user:password", 220 | )] 221 | pub basic_auth: (String, String), 222 | #[cfg(feature = "rpc")] 223 | /// Sets an optional cookie authentication. 224 | #[arg(env = "COOKIE")] 225 | pub cookie: Option, 226 | #[cfg(feature = "cbf")] 227 | #[clap(flatten)] 228 | pub compactfilter_opts: CompactFilterOpts, 229 | } 230 | 231 | /// Options to configure a SOCKS5 proxy for a blockchain client connection. 232 | #[cfg(any(feature = "electrum", feature = "esplora"))] 233 | #[derive(Debug, Args, Clone, PartialEq, Eq)] 234 | pub struct ProxyOpts { 235 | /// Sets the SOCKS5 proxy for a blockchain client. 236 | #[arg(env = "PROXY_ADDRS:PORT", long = "proxy", short = 'p')] 237 | pub proxy: Option, 238 | 239 | /// Sets the SOCKS5 proxy credential. 240 | #[arg(env = "PROXY_USER:PASSWD", long="proxy_auth", short='a', value_parser = parse_proxy_auth)] 241 | pub proxy_auth: Option<(String, String)>, 242 | 243 | /// Sets the SOCKS5 proxy retries for the blockchain client. 244 | #[arg( 245 | env = "PROXY_RETRIES", 246 | short = 'r', 247 | long = "retries", 248 | default_value = "5" 249 | )] 250 | pub retries: u8, 251 | 252 | /// Sets the SOCKS5 proxy timeout for the blockchain client. 253 | #[arg(env = "PROXY_TIMEOUT", short = 't', long = "timeout")] 254 | pub timeout: Option, 255 | } 256 | 257 | /// Options to configure a BIP157 Compact Filter backend. 258 | #[cfg(feature = "cbf")] 259 | #[derive(Debug, Args, Clone, PartialEq, Eq)] 260 | pub struct CompactFilterOpts { 261 | /// Sets the number of parallel node connections. 262 | #[clap(name = "CONNECTIONS", long = "cbf-conn-count", default_value = "2", value_parser = value_parser!(u8).range(1..=15))] 263 | pub conn_count: u8, 264 | } 265 | 266 | /// Wallet subcommands that can be issued without a blockchain backend. 267 | #[derive(Debug, Subcommand, Clone, PartialEq)] 268 | #[command(rename_all = "snake")] 269 | pub enum OfflineWalletSubCommand { 270 | /// Get a new external address. 271 | NewAddress, 272 | /// Get the first unused external address. 273 | UnusedAddress, 274 | /// Lists the available spendable UTXOs. 275 | Unspent, 276 | /// Lists all the incoming and outgoing transactions of the wallet. 277 | Transactions, 278 | /// Returns the current wallet balance. 279 | Balance, 280 | /// Creates a new unsigned transaction. 281 | CreateTx { 282 | /// Adds a recipient to the transaction. 283 | // Clap Doesn't support complex vector parsing https://github.com/clap-rs/clap/issues/1704. 284 | // Address and amount parsing is done at run time in handler function. 285 | #[arg(env = "ADDRESS:SAT", long = "to", required = true, value_parser = parse_recipient)] 286 | recipients: Vec<(ScriptBuf, u64)>, 287 | /// Sends all the funds (or all the selected utxos). Requires only one recipient with value 0. 288 | #[arg(long = "send_all", short = 'a')] 289 | send_all: bool, 290 | /// Enables Replace-By-Fee (BIP125). 291 | #[arg(long = "enable_rbf", short = 'r', default_value_t = true)] 292 | enable_rbf: bool, 293 | /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. 294 | #[arg(long = "offline_signer")] 295 | offline_signer: bool, 296 | /// Selects which utxos *must* be spent. 297 | #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] 298 | utxos: Option>, 299 | /// Marks a utxo as unspendable. 300 | #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] 301 | unspendable: Option>, 302 | /// Fee rate to use in sat/vbyte. 303 | #[arg(env = "SATS_VBYTE", short = 'f', long = "fee_rate")] 304 | fee_rate: Option, 305 | /// Selects which policy should be used to satisfy the external descriptor. 306 | #[arg(env = "EXT_POLICY", long = "external_policy")] 307 | external_policy: Option, 308 | /// Selects which policy should be used to satisfy the internal descriptor. 309 | #[arg(env = "INT_POLICY", long = "internal_policy")] 310 | internal_policy: Option, 311 | /// Optionally create an OP_RETURN output containing given String in utf8 encoding (max 80 bytes) 312 | #[arg( 313 | env = "ADD_STRING", 314 | long = "add_string", 315 | short = 's', 316 | conflicts_with = "add_data" 317 | )] 318 | add_string: Option, 319 | /// Optionally create an OP_RETURN output containing given base64 encoded String. (max 80 bytes) 320 | #[arg( 321 | env = "ADD_DATA", 322 | long = "add_data", 323 | short = 'o', 324 | conflicts_with = "add_string" 325 | )] 326 | add_data: Option, //base 64 econding 327 | }, 328 | /// Bumps the fees of an RBF transaction. 329 | BumpFee { 330 | /// TXID of the transaction to update. 331 | #[arg(env = "TXID", long = "txid")] 332 | txid: String, 333 | /// Allows the wallet to reduce the amount to the specified address in order to increase fees. 334 | #[arg(env = "SHRINK_ADDRESS", long = "shrink", value_parser = parse_address)] 335 | shrink_address: Option
, 336 | /// Make a PSBT that can be signed by offline signers and hardware wallets. Forces the addition of `non_witness_utxo` and more details to let the signer identify the change output. 337 | #[arg(long = "offline_signer")] 338 | offline_signer: bool, 339 | /// Selects which utxos *must* be added to the tx. Unconfirmed utxos cannot be used. 340 | #[arg(env = "MUST_SPEND_TXID:VOUT", long = "utxos", value_parser = parse_outpoint)] 341 | utxos: Option>, 342 | /// Marks an utxo as unspendable, in case more inputs are needed to cover the extra fees. 343 | #[arg(env = "CANT_SPEND_TXID:VOUT", long = "unspendable", value_parser = parse_outpoint)] 344 | unspendable: Option>, 345 | /// The new targeted fee rate in sat/vbyte. 346 | #[arg( 347 | env = "SATS_VBYTE", 348 | short = 'f', 349 | long = "fee_rate", 350 | default_value = "1.0" 351 | )] 352 | fee_rate: f32, 353 | }, 354 | /// Returns the available spending policies for the descriptor. 355 | Policies, 356 | /// Returns the public version of the wallet's descriptor(s). 357 | PublicDescriptor, 358 | /// Signs and tries to finalize a PSBT. 359 | Sign { 360 | /// Sets the PSBT to sign. 361 | #[arg(env = "BASE64_PSBT")] 362 | psbt: String, 363 | /// Assume the blockchain has reached a specific height. This affects the transaction finalization, if there are timelocks in the descriptor. 364 | #[arg(env = "HEIGHT", long = "assume_height")] 365 | assume_height: Option, 366 | /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. 367 | #[arg(env = "WITNESS", long = "trust_witness_utxo")] 368 | trust_witness_utxo: Option, 369 | }, 370 | /// Extracts a raw transaction from a PSBT. 371 | ExtractPsbt { 372 | /// Sets the PSBT to extract 373 | #[arg(env = "BASE64_PSBT")] 374 | psbt: String, 375 | }, 376 | /// Finalizes a PSBT. 377 | FinalizePsbt { 378 | /// Sets the PSBT to finalize. 379 | #[arg(env = "BASE64_PSBT")] 380 | psbt: String, 381 | /// Assume the blockchain has reached a specific height. 382 | #[arg(env = "HEIGHT", long = "assume_height")] 383 | assume_height: Option, 384 | /// Whether the signer should trust the witness_utxo, if the non_witness_utxo hasn’t been provided. 385 | #[arg(env = "WITNESS", long = "trust_witness_utxo")] 386 | trust_witness_utxo: Option, 387 | }, 388 | /// Combines multiple PSBTs into one. 389 | CombinePsbt { 390 | /// Add one PSBT to combine. This option can be repeated multiple times, one for each PSBT. 391 | #[arg(env = "BASE64_PSBT", required = true)] 392 | psbt: Vec, 393 | }, 394 | } 395 | 396 | /// Wallet subcommands that needs a blockchain backend. 397 | #[derive(Debug, Subcommand, Clone, PartialEq, Eq)] 398 | #[command(rename_all = "snake")] 399 | #[cfg(any( 400 | feature = "electrum", 401 | feature = "esplora", 402 | feature = "cbf", 403 | feature = "rpc" 404 | ))] 405 | pub enum OnlineWalletSubCommand { 406 | /// Full Scan with the chosen blockchain server. 407 | FullScan { 408 | /// Stop searching addresses for transactions after finding an unused gap of this length. 409 | #[arg(env = "STOP_GAP", long = "scan-stop-gap", default_value = "20")] 410 | stop_gap: usize, 411 | }, 412 | /// Syncs with the chosen blockchain server. 413 | Sync, 414 | /// Broadcasts a transaction to the network. Takes either a raw transaction or a PSBT to extract. 415 | Broadcast { 416 | /// Sets the PSBT to sign. 417 | #[arg( 418 | env = "BASE64_PSBT", 419 | long = "psbt", 420 | required_unless_present = "tx", 421 | conflicts_with = "tx" 422 | )] 423 | psbt: Option, 424 | /// Sets the raw transaction to broadcast. 425 | #[arg( 426 | env = "RAWTX", 427 | long = "tx", 428 | required_unless_present = "psbt", 429 | conflicts_with = "psbt" 430 | )] 431 | tx: Option, 432 | }, 433 | // Generates a Payjoin receive URI and processes the sender's Payjoin proposal. 434 | ReceivePayjoin { 435 | /// Amount to be received in sats. 436 | #[arg(env = "PAYJOIN_AMOUNT", long = "amount", required = true)] 437 | amount: u64, 438 | /// Payjoin directory which will be used to store the PSBTs which are pending action 439 | /// from one of the parties. 440 | #[arg(env = "PAYJOIN_DIRECTORY", long = "directory", required = true)] 441 | directory: String, 442 | /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the 443 | /// operation with multiple relays for redundancy. 444 | #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] 445 | ohttp_relay: Vec, 446 | /// Maximum effective fee rate the receiver is willing to pay for their own input/output contributions. 447 | #[arg(env = "PAYJOIN_RECEIVER_MAX_FEE_RATE", long = "max_fee_rate")] 448 | max_fee_rate: Option, 449 | }, 450 | /// Sends an original PSBT to a BIP 21 URI and broadcasts the returned Payjoin PSBT. 451 | SendPayjoin { 452 | /// BIP 21 URI for the Payjoin. 453 | #[arg(env = "PAYJOIN_URI", long = "uri", required = true)] 454 | uri: String, 455 | /// URL of the Payjoin OHTTP relay. Can be repeated multiple times to attempt the 456 | /// operation with multiple relays for redundancy. 457 | #[arg(env = "PAYJOIN_OHTTP_RELAY", long = "ohttp_relay", required = true)] 458 | ohttp_relay: Vec, 459 | /// Fee rate to use in sat/vbyte. 460 | #[arg( 461 | env = "PAYJOIN_SENDER_FEE_RATE", 462 | short = 'f', 463 | long = "fee_rate", 464 | required = true 465 | )] 466 | fee_rate: u64, 467 | }, 468 | } 469 | 470 | /// Subcommands for Key operations. 471 | #[derive(Debug, Subcommand, Clone, PartialEq, Eq)] 472 | pub enum KeySubCommand { 473 | /// Generates new random seed mnemonic phrase and corresponding master extended key. 474 | Generate { 475 | /// Entropy level based on number of random seed mnemonic words. 476 | #[arg( 477 | env = "WORD_COUNT", 478 | short = 'e', 479 | long = "entropy", 480 | default_value = "12" 481 | )] 482 | word_count: usize, 483 | /// Seed password. 484 | #[arg(env = "PASSWORD", short = 'p', long = "password")] 485 | password: Option, 486 | }, 487 | /// Restore a master extended key from seed backup mnemonic words. 488 | Restore { 489 | /// Seed mnemonic words, must be quoted (eg. "word1 word2 ..."). 490 | #[arg(env = "MNEMONIC", short = 'm', long = "mnemonic")] 491 | mnemonic: String, 492 | /// Seed password. 493 | #[arg(env = "PASSWORD", short = 'p', long = "password")] 494 | password: Option, 495 | }, 496 | /// Derive a child key pair from a master extended key and a derivation path string (eg. "m/84'/1'/0'/0" or "m/84h/1h/0h/0"). 497 | Derive { 498 | /// Extended private key to derive from. 499 | #[arg(env = "XPRV", short = 'x', long = "xprv")] 500 | xprv: Xpriv, 501 | /// Path to use to derive extended public key from extended private key. 502 | #[arg(env = "PATH", short = 'p', long = "path")] 503 | path: DerivationPath, 504 | }, 505 | } 506 | 507 | /// Subcommands available in REPL mode. 508 | #[cfg(any(feature = "repl", target_arch = "wasm32"))] 509 | #[derive(Debug, Parser)] 510 | #[command(rename_all = "lower", multicall = true)] 511 | pub enum ReplSubCommand { 512 | /// Execute wallet commands. 513 | Wallet { 514 | #[command(subcommand)] 515 | subcommand: WalletSubCommand, 516 | }, 517 | /// Execute key commands. 518 | Key { 519 | #[command(subcommand)] 520 | subcommand: KeySubCommand, 521 | }, 522 | /// Generate descriptors 523 | Descriptor { 524 | /// Descriptor type (script type). 525 | #[arg( 526 | long = "type", 527 | short = 't', 528 | value_parser = ["pkh", "wpkh", "sh", "wsh", "tr"], 529 | default_value = "wsh" 530 | )] 531 | desc_type: String, 532 | /// Optional key: xprv, xpub, or mnemonic phrase 533 | key: Option, 534 | }, 535 | /// Exit REPL loop. 536 | Exit, 537 | } 538 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 Bitcoin Dev Kit Developers 2 | // 3 | // This file is licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your option. 6 | // You may not use this file except in accordance with one or both of these 7 | // licenses. 8 | 9 | //! Utility Tools 10 | //! 11 | //! This module includes all the utility tools used by the App. 12 | use crate::error::BDKCliError as Error; 13 | use std::{ 14 | fmt::Display, 15 | path::{Path, PathBuf}, 16 | str::FromStr, 17 | sync::Arc, 18 | }; 19 | 20 | use crate::commands::WalletOpts; 21 | #[cfg(feature = "cbf")] 22 | use bdk_kyoto::{ 23 | BuilderExt, Info, LightClient, Receiver, ScanType::Sync, UnboundedReceiver, Warning, 24 | builder::Builder, 25 | }; 26 | use bdk_wallet::{ 27 | KeychainKind, 28 | bitcoin::bip32::{DerivationPath, Xpub}, 29 | keys::DescriptorPublicKey, 30 | miniscript::{ 31 | Descriptor, Miniscript, Terminal, 32 | descriptor::{DescriptorXKey, Wildcard}, 33 | }, 34 | template::DescriptorTemplate, 35 | }; 36 | use cli_table::{Cell, CellStruct, Style, Table}; 37 | 38 | #[cfg(any( 39 | feature = "electrum", 40 | feature = "esplora", 41 | feature = "rpc", 42 | feature = "cbf" 43 | ))] 44 | use crate::commands::ClientType; 45 | 46 | use bdk_wallet::Wallet; 47 | #[cfg(any(feature = "sqlite", feature = "redb"))] 48 | use bdk_wallet::{PersistedWallet, WalletPersister}; 49 | 50 | use bdk_wallet::bip39::{Language, Mnemonic}; 51 | use bdk_wallet::bitcoin::{ 52 | Address, Network, OutPoint, ScriptBuf, bip32::Xpriv, secp256k1::Secp256k1, 53 | }; 54 | use bdk_wallet::descriptor::Segwitv0; 55 | use bdk_wallet::keys::{GeneratableKey, GeneratedKey, bip39::WordCount}; 56 | use serde_json::{Value, json}; 57 | 58 | /// Parse the recipient (Address,Amount) argument from cli input. 59 | pub(crate) fn parse_recipient(s: &str) -> Result<(ScriptBuf, u64), String> { 60 | let parts: Vec<_> = s.split(':').collect(); 61 | if parts.len() != 2 { 62 | return Err("Invalid format".to_string()); 63 | } 64 | let addr = Address::from_str(parts[0]) 65 | .map_err(|e| e.to_string())? 66 | .assume_checked(); 67 | let val = u64::from_str(parts[1]).map_err(|e| e.to_string())?; 68 | 69 | Ok((addr.script_pubkey(), val)) 70 | } 71 | 72 | #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] 73 | /// Parse the proxy (Socket:Port) argument from the cli input. 74 | pub(crate) fn parse_proxy_auth(s: &str) -> Result<(String, String), Error> { 75 | let parts: Vec<_> = s.split(':').collect(); 76 | if parts.len() != 2 { 77 | return Err(Error::Generic("Invalid format".to_string())); 78 | } 79 | 80 | let user = parts[0].to_string(); 81 | let passwd = parts[1].to_string(); 82 | 83 | Ok((user, passwd)) 84 | } 85 | 86 | /// Parse a outpoint (Txid:Vout) argument from cli input. 87 | pub(crate) fn parse_outpoint(s: &str) -> Result { 88 | Ok(OutPoint::from_str(s)?) 89 | } 90 | 91 | /// Parse an address string into `Address`. 92 | pub(crate) fn parse_address(address_str: &str) -> Result { 93 | let unchecked_address = Address::from_str(address_str)?; 94 | Ok(unchecked_address.assume_checked()) 95 | } 96 | 97 | /// Prepare bdk-cli home directory 98 | /// 99 | /// This function is called to check if [`crate::CliOpts`] datadir is set. 100 | /// If not the default home directory is created at `~/.bdk-bitcoin`. 101 | #[allow(dead_code)] 102 | pub(crate) fn prepare_home_dir(home_path: Option) -> Result { 103 | let dir = home_path.unwrap_or_else(|| { 104 | let mut dir = PathBuf::new(); 105 | dir.push( 106 | dirs::home_dir() 107 | .ok_or_else(|| Error::Generic("home dir not found".to_string())) 108 | .unwrap(), 109 | ); 110 | dir.push(".bdk-bitcoin"); 111 | dir 112 | }); 113 | 114 | if !dir.exists() { 115 | std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; 116 | } 117 | 118 | Ok(dir) 119 | } 120 | 121 | /// Prepare wallet database directory. 122 | #[allow(dead_code)] 123 | pub(crate) fn prepare_wallet_db_dir( 124 | wallet_name: &Option, 125 | home_path: &Path, 126 | ) -> Result { 127 | let mut dir = home_path.to_owned(); 128 | if let Some(wallet_name) = wallet_name { 129 | dir.push(wallet_name); 130 | } 131 | 132 | if !dir.exists() { 133 | std::fs::create_dir(&dir).map_err(|e| Error::Generic(e.to_string()))?; 134 | } 135 | 136 | Ok(dir) 137 | } 138 | 139 | #[cfg(any( 140 | feature = "electrum", 141 | feature = "esplora", 142 | feature = "rpc", 143 | feature = "cbf", 144 | ))] 145 | pub(crate) enum BlockchainClient { 146 | #[cfg(feature = "electrum")] 147 | Electrum { 148 | client: Box>, 149 | batch_size: usize, 150 | }, 151 | #[cfg(feature = "esplora")] 152 | Esplora { 153 | client: Box, 154 | parallel_requests: usize, 155 | }, 156 | #[cfg(feature = "rpc")] 157 | RpcClient { 158 | client: Box, 159 | }, 160 | 161 | #[cfg(feature = "cbf")] 162 | KyotoClient { client: Box }, 163 | } 164 | 165 | #[cfg(any( 166 | feature = "electrum", 167 | feature = "esplora", 168 | feature = "rpc", 169 | feature = "cbf", 170 | ))] 171 | /// Create a new blockchain from the wallet configuration options. 172 | pub(crate) fn new_blockchain_client( 173 | wallet_opts: &WalletOpts, 174 | _wallet: &Wallet, 175 | _datadir: PathBuf, 176 | ) -> Result { 177 | #[cfg(any(feature = "electrum", feature = "esplora", feature = "rpc"))] 178 | let url = wallet_opts.url.as_str(); 179 | let client = match wallet_opts.client_type { 180 | #[cfg(feature = "electrum")] 181 | ClientType::Electrum => { 182 | let client = bdk_electrum::electrum_client::Client::new(url) 183 | .map(bdk_electrum::BdkElectrumClient::new)?; 184 | BlockchainClient::Electrum { 185 | client: Box::new(client), 186 | batch_size: wallet_opts.batch_size, 187 | } 188 | } 189 | #[cfg(feature = "esplora")] 190 | ClientType::Esplora => { 191 | let client = bdk_esplora::esplora_client::Builder::new(url).build_async()?; 192 | BlockchainClient::Esplora { 193 | client: Box::new(client), 194 | parallel_requests: wallet_opts.parallel_requests, 195 | } 196 | } 197 | 198 | #[cfg(feature = "rpc")] 199 | ClientType::Rpc => { 200 | let auth = match &wallet_opts.cookie { 201 | Some(cookie) => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::CookieFile(cookie.into()), 202 | None => bdk_bitcoind_rpc::bitcoincore_rpc::Auth::UserPass( 203 | wallet_opts.basic_auth.0.clone(), 204 | wallet_opts.basic_auth.1.clone(), 205 | ), 206 | }; 207 | let client = bdk_bitcoind_rpc::bitcoincore_rpc::Client::new(url, auth) 208 | .map_err(|e| Error::Generic(e.to_string()))?; 209 | BlockchainClient::RpcClient { 210 | client: Box::new(client), 211 | } 212 | } 213 | 214 | #[cfg(feature = "cbf")] 215 | ClientType::Cbf => { 216 | let scan_type = Sync; 217 | let builder = Builder::new(_wallet.network()); 218 | 219 | let client = builder 220 | .required_peers(wallet_opts.compactfilter_opts.conn_count) 221 | .data_dir(&_datadir) 222 | .build_with_wallet(_wallet, scan_type)?; 223 | 224 | BlockchainClient::KyotoClient { 225 | client: Box::new(client), 226 | } 227 | } 228 | }; 229 | Ok(client) 230 | } 231 | 232 | #[cfg(any(feature = "sqlite", feature = "redb"))] 233 | /// Create a new persisted wallet from given wallet configuration options. 234 | pub(crate) fn new_persisted_wallet( 235 | network: Network, 236 | persister: &mut P, 237 | wallet_opts: &WalletOpts, 238 | ) -> Result, Error> 239 | where 240 | P::Error: std::fmt::Display, 241 | { 242 | let ext_descriptor = wallet_opts.ext_descriptor.clone(); 243 | let int_descriptor = wallet_opts.int_descriptor.clone(); 244 | 245 | let mut wallet_load_params = Wallet::load(); 246 | if ext_descriptor.is_some() { 247 | wallet_load_params = 248 | wallet_load_params.descriptor(KeychainKind::External, ext_descriptor.clone()); 249 | } 250 | if int_descriptor.is_some() { 251 | wallet_load_params = 252 | wallet_load_params.descriptor(KeychainKind::Internal, int_descriptor.clone()); 253 | } 254 | if ext_descriptor.is_some() || int_descriptor.is_some() { 255 | wallet_load_params = wallet_load_params.extract_keys(); 256 | } 257 | 258 | let wallet_opt = wallet_load_params 259 | .check_network(network) 260 | .load_wallet(persister) 261 | .map_err(|e| Error::Generic(e.to_string()))?; 262 | 263 | let wallet = match wallet_opt { 264 | Some(wallet) => wallet, 265 | None => match (ext_descriptor, int_descriptor) { 266 | (Some(ext_descriptor), Some(int_descriptor)) => { 267 | let wallet = Wallet::create(ext_descriptor, int_descriptor) 268 | .network(network) 269 | .create_wallet(persister) 270 | .map_err(|e| Error::Generic(e.to_string()))?; 271 | Ok(wallet) 272 | } 273 | (Some(ext_descriptor), None) => { 274 | let wallet = Wallet::create_single(ext_descriptor) 275 | .network(network) 276 | .create_wallet(persister) 277 | .map_err(|e| Error::Generic(e.to_string()))?; 278 | Ok(wallet) 279 | } 280 | _ => Err(Error::Generic( 281 | "An external descriptor is required.".to_string(), 282 | )), 283 | }?, 284 | }; 285 | 286 | Ok(wallet) 287 | } 288 | 289 | #[cfg(not(any(feature = "sqlite", feature = "redb")))] 290 | /// Create a new non-persisted wallet from given wallet configuration options. 291 | pub(crate) fn new_wallet(network: Network, wallet_opts: &WalletOpts) -> Result { 292 | let ext_descriptor = wallet_opts.ext_descriptor.clone(); 293 | let int_descriptor = wallet_opts.int_descriptor.clone(); 294 | 295 | match (ext_descriptor, int_descriptor) { 296 | (Some(ext_descriptor), Some(int_descriptor)) => { 297 | let wallet = Wallet::create(ext_descriptor, int_descriptor) 298 | .network(network) 299 | .create_wallet_no_persist()?; 300 | Ok(wallet) 301 | } 302 | (Some(ext_descriptor), None) => { 303 | let wallet = Wallet::create_single(ext_descriptor) 304 | .network(network) 305 | .create_wallet_no_persist()?; 306 | Ok(wallet) 307 | } 308 | _ => Err(Error::Generic( 309 | "An external descriptor is required.".to_string(), 310 | )), 311 | } 312 | } 313 | 314 | #[cfg(feature = "cbf")] 315 | pub async fn trace_logger( 316 | mut info_subcriber: Receiver, 317 | mut warning_subscriber: UnboundedReceiver, 318 | ) { 319 | loop { 320 | tokio::select! { 321 | info = info_subcriber.recv() => { 322 | if let Some(info) = info { 323 | tracing::info!("{info}") 324 | } 325 | } 326 | warn = warning_subscriber.recv() => { 327 | if let Some(warn) = warn { 328 | tracing::warn!("{warn}") 329 | } 330 | } 331 | } 332 | } 333 | } 334 | 335 | // Handle Kyoto Client sync 336 | #[cfg(feature = "cbf")] 337 | pub async fn sync_kyoto_client(wallet: &mut Wallet, client: Box) -> Result<(), Error> { 338 | let LightClient { 339 | requester, 340 | info_subscriber, 341 | warning_subscriber, 342 | mut update_subscriber, 343 | node, 344 | } = *client; 345 | 346 | let subscriber = tracing_subscriber::FmtSubscriber::new(); 347 | tracing::subscriber::set_global_default(subscriber) 348 | .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {e}")))?; 349 | 350 | tokio::task::spawn(async move { node.run().await }); 351 | tokio::task::spawn(async move { trace_logger(info_subscriber, warning_subscriber).await }); 352 | 353 | if !requester.is_running() { 354 | tracing::error!("Kyoto node is not running"); 355 | return Err(Error::Generic("Kyoto node failed to start".to_string())); 356 | } 357 | tracing::info!("Kyoto node is running"); 358 | 359 | let update = update_subscriber.update().await?; 360 | tracing::info!("Received update: applying to wallet"); 361 | wallet 362 | .apply_update(update) 363 | .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?; 364 | 365 | tracing::info!( 366 | "Chain tip: {}, Transactions: {}, Balance: {}", 367 | wallet.local_chain().tip().height(), 368 | wallet.transactions().count(), 369 | wallet.balance().total().to_sat() 370 | ); 371 | 372 | tracing::info!( 373 | "Sync completed: tx_count={}, balance={}", 374 | wallet.transactions().count(), 375 | wallet.balance().total().to_sat() 376 | ); 377 | 378 | Ok(()) 379 | } 380 | 381 | pub(crate) fn shorten(displayable: impl Display, start: u8, end: u8) -> String { 382 | let displayable = displayable.to_string(); 383 | let start_str: &str = &displayable[0..start as usize]; 384 | let end_str: &str = &displayable[displayable.len() - end as usize..]; 385 | format!("{start_str}...{end_str}") 386 | } 387 | 388 | pub fn is_mnemonic(s: &str) -> bool { 389 | let word_count = s.split_whitespace().count(); 390 | (12..=24).contains(&word_count) && s.chars().all(|c| c.is_alphanumeric() || c.is_whitespace()) 391 | } 392 | 393 | pub fn generate_descriptors(desc_type: &str, key: &str, network: Network) -> Result { 394 | let is_private = key.starts_with("xprv") || key.starts_with("tprv"); 395 | 396 | if is_private { 397 | generate_private_descriptors(desc_type, key, network) 398 | } else { 399 | let purpose = match desc_type.to_lowercase().as_str() { 400 | "pkh" => 44u32, 401 | "sh" => 49u32, 402 | "wpkh" | "wsh" => 84u32, 403 | "tr" => 86u32, 404 | _ => 84u32, 405 | }; 406 | let coin_type = match network { 407 | Network::Bitcoin => 0u32, 408 | _ => 1u32, 409 | }; 410 | let derivation_path = DerivationPath::from_str(&format!("m/{purpose}h/{coin_type}h/0h"))?; 411 | generate_public_descriptors(desc_type, key, &derivation_path) 412 | } 413 | } 414 | 415 | /// Generate descriptors from private key using BIP templates 416 | fn generate_private_descriptors( 417 | desc_type: &str, 418 | key: &str, 419 | network: Network, 420 | ) -> Result { 421 | use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; 422 | 423 | let secp = Secp256k1::new(); 424 | let xprv: Xpriv = key.parse()?; 425 | let fingerprint = xprv.fingerprint(&secp); 426 | 427 | let (external_desc, external_keymap, _) = match desc_type.to_lowercase().as_str() { 428 | "pkh" => Bip44(xprv, KeychainKind::External).build(network)?, 429 | "sh" => Bip49(xprv, KeychainKind::External).build(network)?, 430 | "wpkh" | "wsh" => Bip84(xprv, KeychainKind::External).build(network)?, 431 | "tr" => Bip86(xprv, KeychainKind::External).build(network)?, 432 | _ => { 433 | return Err(Error::Generic(format!( 434 | "Unsupported descriptor type: {desc_type}" 435 | ))); 436 | } 437 | }; 438 | 439 | let (internal_desc, internal_keymap, _) = match desc_type.to_lowercase().as_str() { 440 | "pkh" => Bip44(xprv, KeychainKind::Internal).build(network)?, 441 | "sh" => Bip49(xprv, KeychainKind::Internal).build(network)?, 442 | "wpkh" | "wsh" => Bip84(xprv, KeychainKind::Internal).build(network)?, 443 | "tr" => Bip86(xprv, KeychainKind::Internal).build(network)?, 444 | _ => { 445 | return Err(Error::Generic(format!( 446 | "Unsupported descriptor type: {desc_type}" 447 | ))); 448 | } 449 | }; 450 | 451 | let external_priv = external_desc.to_string_with_secret(&external_keymap); 452 | let external_pub = external_desc.to_string(); 453 | let internal_priv = internal_desc.to_string_with_secret(&internal_keymap); 454 | let internal_pub = internal_desc.to_string(); 455 | 456 | Ok(json!({ 457 | "public_descriptors": { 458 | "external": external_pub, 459 | "internal": internal_pub 460 | }, 461 | "private_descriptors": { 462 | "external": external_priv, 463 | "internal": internal_priv 464 | }, 465 | "fingerprint": fingerprint.to_string() 466 | })) 467 | } 468 | 469 | /// Generate descriptors from public key (xpub/tpub) 470 | pub fn generate_public_descriptors( 471 | desc_type: &str, 472 | key: &str, 473 | derivation_path: &DerivationPath, 474 | ) -> Result { 475 | let xpub: Xpub = key.parse()?; 476 | let fingerprint = xpub.fingerprint(); 477 | 478 | let build_descriptor = |branch: &str| -> Result { 479 | let branch_path = DerivationPath::from_str(branch)?; 480 | let desc_xpub = DescriptorXKey { 481 | origin: Some((fingerprint, derivation_path.clone())), 482 | xkey: xpub, 483 | derivation_path: branch_path, 484 | wildcard: Wildcard::Unhardened, 485 | }; 486 | let desc_pub = DescriptorPublicKey::XPub(desc_xpub); 487 | let descriptor = build_public_descriptor(desc_type, desc_pub)?; 488 | Ok(descriptor.to_string()) 489 | }; 490 | 491 | let external_pub = build_descriptor("0")?; 492 | let internal_pub = build_descriptor("1")?; 493 | 494 | Ok(json!({ 495 | "public_descriptors": { 496 | "external": external_pub, 497 | "internal": internal_pub 498 | }, 499 | "fingerprint": fingerprint.to_string() 500 | })) 501 | } 502 | 503 | /// Build a descriptor from a public key 504 | pub fn build_public_descriptor( 505 | desc_type: &str, 506 | key: DescriptorPublicKey, 507 | ) -> Result, Error> { 508 | match desc_type.to_lowercase().as_str() { 509 | "pkh" => Descriptor::new_pkh(key).map_err(Error::from), 510 | "wpkh" => Descriptor::new_wpkh(key).map_err(Error::from), 511 | "sh" => Descriptor::new_sh_wpkh(key).map_err(Error::from), 512 | "wsh" => { 513 | let pk_k = Miniscript::from_ast(Terminal::PkK(key)).map_err(Error::from)?; 514 | let pk_ms: Miniscript = 515 | Miniscript::from_ast(Terminal::Check(Arc::new(pk_k))).map_err(Error::from)?; 516 | Descriptor::new_wsh(pk_ms).map_err(Error::from) 517 | } 518 | "tr" => Descriptor::new_tr(key, None).map_err(Error::from), 519 | _ => Err(Error::Generic(format!( 520 | "Unsupported descriptor type: {desc_type}" 521 | ))), 522 | } 523 | } 524 | 525 | /// Generate new mnemonic and descriptors 526 | pub fn generate_descriptor_with_mnemonic( 527 | network: Network, 528 | desc_type: &str, 529 | ) -> Result { 530 | let mnemonic: GeneratedKey = 531 | Mnemonic::generate((WordCount::Words12, Language::English)).map_err(Error::BIP39Error)?; 532 | 533 | let seed = mnemonic.to_seed(""); 534 | let xprv = Xpriv::new_master(network, &seed)?; 535 | 536 | let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; 537 | result["mnemonic"] = json!(mnemonic.to_string()); 538 | Ok(result) 539 | } 540 | 541 | /// Generate descriptors from existing mnemonic 542 | pub fn generate_descriptor_from_mnemonic( 543 | mnemonic_str: &str, 544 | network: Network, 545 | desc_type: &str, 546 | ) -> Result { 547 | let mnemonic = Mnemonic::parse_in(Language::English, mnemonic_str)?; 548 | let seed = mnemonic.to_seed(""); 549 | let xprv = Xpriv::new_master(network, &seed)?; 550 | 551 | let mut result = generate_descriptors(desc_type, &xprv.to_string(), network)?; 552 | result["mnemonic"] = json!(mnemonic_str); 553 | Ok(result) 554 | } 555 | 556 | pub fn format_descriptor_output(result: &Value, pretty: bool) -> Result { 557 | if !pretty { 558 | return Ok(serde_json::to_string_pretty(result)?); 559 | } 560 | 561 | let mut rows: Vec> = vec![]; 562 | 563 | if let Some(desc_type) = result.get("type") { 564 | rows.push(vec![ 565 | "Type".cell().bold(true), 566 | desc_type.as_str().unwrap_or("N/A").cell(), 567 | ]); 568 | } 569 | 570 | if let Some(finger_print) = result.get("fingerprint") { 571 | rows.push(vec![ 572 | "Fingerprint".cell().bold(true), 573 | finger_print.as_str().unwrap_or("N/A").cell(), 574 | ]); 575 | } 576 | 577 | if let Some(network) = result.get("network") { 578 | rows.push(vec![ 579 | "Network".cell().bold(true), 580 | network.as_str().unwrap_or("N/A").cell(), 581 | ]); 582 | } 583 | if let Some(multipath_desc) = result.get("multipath_descriptor") { 584 | rows.push(vec![ 585 | "Multipart Descriptor".cell().bold(true), 586 | multipath_desc.as_str().unwrap_or("N/A").cell(), 587 | ]); 588 | } 589 | if let Some(pub_descs) = result.get("public_descriptors").and_then(|v| v.as_object()) { 590 | if let Some(ext) = pub_descs.get("external") { 591 | rows.push(vec![ 592 | "External Public".cell().bold(true), 593 | ext.as_str().unwrap_or("N/A").cell(), 594 | ]); 595 | } 596 | if let Some(int) = pub_descs.get("internal") { 597 | rows.push(vec![ 598 | "Internal Public".cell().bold(true), 599 | int.as_str().unwrap_or("N/A").cell(), 600 | ]); 601 | } 602 | } 603 | if let Some(priv_descs) = result 604 | .get("private_descriptors") 605 | .and_then(|v| v.as_object()) 606 | { 607 | if let Some(ext) = priv_descs.get("external") { 608 | rows.push(vec![ 609 | "External Private".cell().bold(true), 610 | ext.as_str().unwrap_or("N/A").cell(), 611 | ]); 612 | } 613 | if let Some(int) = priv_descs.get("internal") { 614 | rows.push(vec![ 615 | "Internal Private".cell().bold(true), 616 | int.as_str().unwrap_or("N/A").cell(), 617 | ]); 618 | } 619 | } 620 | if let Some(mnemonic) = result.get("mnemonic") { 621 | rows.push(vec![ 622 | "Mnemonic".cell().bold(true), 623 | mnemonic.as_str().unwrap_or("N/A").cell(), 624 | ]); 625 | } 626 | 627 | let table = rows 628 | .table() 629 | .display() 630 | .map_err(|e| Error::Generic(e.to_string()))?; 631 | 632 | Ok(format!("{table}")) 633 | } 634 | -------------------------------------------------------------------------------- /src/payjoin/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::BDKCliError as Error; 2 | use crate::handlers::{broadcast_transaction, sync_wallet}; 3 | use crate::utils::BlockchainClient; 4 | use bdk_wallet::{ 5 | SignOptions, Wallet, 6 | bitcoin::{FeeRate, Psbt, Txid, consensus::encode::serialize_hex}, 7 | }; 8 | use payjoin::bitcoin::TxIn; 9 | use payjoin::persist::{OptionalTransitionOutcome, SessionPersister}; 10 | use payjoin::receive::InputPair; 11 | use payjoin::receive::v2::{ 12 | HasReplyableError, Initialized, MaybeInputsOwned, MaybeInputsSeen, Monitor, OutputsUnknown, 13 | PayjoinProposal, ProvisionalProposal, ReceiveSession, Receiver, 14 | SessionEvent as ReceiverSessionEvent, UncheckedOriginalPayload, WantsFeeRange, WantsInputs, 15 | WantsOutputs, 16 | }; 17 | use payjoin::send::v2::{ 18 | PollingForProposal, SendSession, Sender, SessionEvent as SenderSessionEvent, 19 | SessionOutcome as SenderSessionOutcome, WithReplyKey, 20 | }; 21 | use payjoin::{ImplementationError, UriExt}; 22 | use serde_json::{json, to_string_pretty}; 23 | use std::sync::{Arc, Mutex}; 24 | 25 | use crate::payjoin::ohttp::{RelayManager, fetch_ohttp_keys}; 26 | 27 | pub mod ohttp; 28 | 29 | /// Implements all of the functions required to go through the Payjoin receive and send processes. 30 | /// 31 | /// TODO: At the time of writing, this struct is written to make a Persister implementation easier 32 | /// but the persister is not implemented yet! For instance [`PayjoinManager::proceed_sender_session`] and 33 | /// [`PayjoinManager::proceed_receiver_session`] are designed such that the manager can enable 34 | /// resuming ongoing payjoins are well. So... this is a TODO for implementing persister. 35 | pub(crate) struct PayjoinManager<'a> { 36 | wallet: &'a mut Wallet, 37 | relay_manager: Arc>, 38 | } 39 | 40 | impl<'a> PayjoinManager<'a> { 41 | pub fn new(wallet: &'a mut Wallet, relay_manager: Arc>) -> Self { 42 | Self { 43 | wallet, 44 | relay_manager, 45 | } 46 | } 47 | 48 | pub async fn receive_payjoin( 49 | &mut self, 50 | amount: u64, 51 | directory: String, 52 | max_fee_rate: Option, 53 | ohttp_relays: Vec, 54 | blockchain_client: BlockchainClient, 55 | ) -> Result { 56 | let address = self 57 | .wallet 58 | .next_unused_address(bdk_wallet::KeychainKind::External); 59 | 60 | let ohttp_relays: Vec = ohttp_relays 61 | .into_iter() 62 | .map(|s| url::Url::parse(&s)) 63 | .collect::>() 64 | .map_err(|e| Error::Generic(format!("Failed to parse one or more OHTTP URLs: {e}")))?; 65 | 66 | if ohttp_relays.is_empty() { 67 | return Err(Error::Generic( 68 | "At least one valid OHTTP relay must be provided.".into(), 69 | )); 70 | } 71 | 72 | let ohttp_keys = 73 | fetch_ohttp_keys(ohttp_relays, &directory, self.relay_manager.clone()).await?; 74 | // TODO: Implement proper persister. 75 | let persister = payjoin::persist::NoopSessionPersister::::default(); 76 | 77 | let checked_max_fee_rate = max_fee_rate 78 | .map(|rate| FeeRate::from_sat_per_kwu(rate)) 79 | .unwrap_or(FeeRate::BROADCAST_MIN); 80 | 81 | let receiver = payjoin::receive::v2::ReceiverBuilder::new( 82 | address.address, 83 | directory, 84 | ohttp_keys.ohttp_keys, 85 | ) 86 | .map_err(|e| { 87 | Error::Generic(format!( 88 | "Failed to initialize a Payjoin ReceiverBuilder: {e}" 89 | )) 90 | })? 91 | .with_amount(payjoin::bitcoin::Amount::from_sat(amount)) 92 | .with_max_fee_rate(checked_max_fee_rate) 93 | .build() 94 | .save(&persister) 95 | .map_err(|e| { 96 | Error::Generic(format!( 97 | "Failed to persister the receiver after initialization: {e}" 98 | )) 99 | })?; 100 | 101 | let pj_uri = receiver.pj_uri(); 102 | println!("Request Payjoin by sharing this Payjoin Uri:"); 103 | println!("{pj_uri}"); 104 | 105 | self.proceed_receiver_session( 106 | ReceiveSession::Initialized(receiver.clone()), 107 | &persister, 108 | ohttp_keys.relay_url.to_string(), 109 | checked_max_fee_rate, 110 | blockchain_client, 111 | ) 112 | .await?; 113 | 114 | Ok(to_string_pretty(&json!({}))?) 115 | } 116 | 117 | pub async fn send_payjoin( 118 | &mut self, 119 | uri: String, 120 | fee_rate: u64, 121 | ohttp_relays: Vec, 122 | blockchain_client: BlockchainClient, 123 | ) -> Result { 124 | let uri = payjoin::Uri::try_from(uri) 125 | .map_err(|e| Error::Generic(format!("Failed parsing to Payjoin URI: {}", e)))?; 126 | let uri = uri.require_network(self.wallet.network()).map_err(|e| { 127 | Error::Generic(format!("Failed setting the right network for the URI: {e}")) 128 | })?; 129 | let uri = uri 130 | .check_pj_supported() 131 | .map_err(|e| Error::Generic(format!("URI does not support Payjoin: {}", e)))?; 132 | 133 | let sats = uri 134 | .amount 135 | .ok_or_else(|| Error::Generic("Amount is not specified in the URI.".to_string()))?; 136 | 137 | let fee_rate = FeeRate::from_sat_per_vb(fee_rate).expect("Provided fee rate is not valid."); 138 | 139 | // Build and sign the original PSBT which pays to the receiver. 140 | let mut original_psbt = { 141 | let mut tx_builder = self.wallet.build_tx(); 142 | tx_builder 143 | .add_recipient(uri.address.script_pubkey(), sats) 144 | .fee_rate(fee_rate); 145 | 146 | tx_builder.finish().map_err(|e| { 147 | Error::Generic(format!( 148 | "Error occurred when building original Payjoin transaction: {e}" 149 | )) 150 | })? 151 | }; 152 | if !self 153 | .wallet 154 | .sign(&mut original_psbt, SignOptions::default())? 155 | { 156 | return Err(Error::Generic( 157 | "Failed to sign and finalize the original PSBT.".to_string(), 158 | )); 159 | } 160 | 161 | let txid = match uri.extras.pj_param() { 162 | payjoin::PjParam::V1(_) => { 163 | let (req, ctx) = payjoin::send::v1::SenderBuilder::new(original_psbt.clone(), uri) 164 | .build_recommended(fee_rate) 165 | .map_err(|e| { 166 | Error::Generic(format!("Failed to build a Payjoin v1 sender: {e}")) 167 | })? 168 | .create_v1_post_request(); 169 | 170 | let response = self 171 | .send_payjoin_post_request(req) 172 | .await 173 | .map_err(|e| Error::Generic(format!("Failed to send request: {e}")))?; 174 | 175 | let psbt = ctx 176 | .process_response(&response.bytes().await?) 177 | .map_err(|e| Error::Generic(format!("Failed to send a Payjoin v1: {e}")))?; 178 | 179 | self.process_payjoin_proposal(psbt, blockchain_client) 180 | .await? 181 | } 182 | payjoin::PjParam::V2(_) => { 183 | let ohttp_relays: Vec = ohttp_relays 184 | .into_iter() 185 | .map(|s| url::Url::parse(&s)) 186 | .collect::>() 187 | .map_err(|e| { 188 | Error::Generic(format!("Failed to parse one or more OHTTP URLs: {e}")) 189 | })?; 190 | 191 | if ohttp_relays.is_empty() { 192 | return Err(Error::Generic( 193 | "At least one valid OHTTP relay must be provided.".into(), 194 | )); 195 | } 196 | 197 | // TODO: Implement proper persister. 198 | let persister = 199 | payjoin::persist::NoopSessionPersister::::default(); 200 | 201 | let sender = payjoin::send::v2::SenderBuilder::new(original_psbt.clone(), uri) 202 | .build_recommended(fee_rate) 203 | .map_err(|e| { 204 | Error::Generic(format!("Failed to build a Payjoin v2 sender: {e}")) 205 | })? 206 | .save(&persister) 207 | .map_err(|e| { 208 | Error::Generic(format!( 209 | "Failed to save the Payjoin v2 sender in the persister: {e}" 210 | )) 211 | })?; 212 | 213 | let selected_relay = 214 | fetch_ohttp_keys(ohttp_relays, &sender.endpoint(), self.relay_manager.clone()) 215 | .await? 216 | .relay_url; 217 | 218 | self.proceed_sender_session( 219 | SendSession::WithReplyKey(sender), 220 | &persister, 221 | selected_relay.to_string(), 222 | blockchain_client, 223 | ) 224 | .await? 225 | } 226 | _ => { 227 | unimplemented!("Payjoin version not recognized."); 228 | } 229 | }; 230 | 231 | Ok(to_string_pretty(&json!({ "txid": txid }))?) 232 | } 233 | 234 | async fn proceed_receiver_session( 235 | &mut self, 236 | session: ReceiveSession, 237 | persister: &impl SessionPersister, 238 | relay: impl payjoin::IntoUrl, 239 | max_fee_rate: FeeRate, 240 | blockchain_client: BlockchainClient, 241 | ) -> Result<(), Error> { 242 | match session { 243 | ReceiveSession::Initialized(proposal) => { 244 | self.read_from_directory( 245 | proposal, 246 | persister, 247 | relay, 248 | max_fee_rate, 249 | blockchain_client, 250 | ) 251 | .await 252 | } 253 | ReceiveSession::UncheckedOriginalPayload(proposal) => { 254 | self.check_proposal(proposal, persister, max_fee_rate, blockchain_client) 255 | .await 256 | } 257 | ReceiveSession::MaybeInputsOwned(proposal) => { 258 | self.check_inputs_not_owned(proposal, persister, max_fee_rate, blockchain_client) 259 | .await 260 | } 261 | ReceiveSession::MaybeInputsSeen(proposal) => { 262 | self.check_no_inputs_seen_before( 263 | proposal, 264 | persister, 265 | max_fee_rate, 266 | blockchain_client, 267 | ) 268 | .await 269 | } 270 | ReceiveSession::OutputsUnknown(proposal) => { 271 | self.identify_receiver_outputs(proposal, persister, max_fee_rate, blockchain_client) 272 | .await 273 | } 274 | ReceiveSession::WantsOutputs(proposal) => { 275 | self.commit_outputs(proposal, persister, max_fee_rate, blockchain_client) 276 | .await 277 | } 278 | ReceiveSession::WantsInputs(proposal) => { 279 | self.contribute_inputs(proposal, persister, max_fee_rate, blockchain_client) 280 | .await 281 | } 282 | ReceiveSession::WantsFeeRange(proposal) => { 283 | self.apply_fee_range(proposal, persister, max_fee_rate, blockchain_client) 284 | .await 285 | } 286 | ReceiveSession::ProvisionalProposal(proposal) => { 287 | self.finalize_proposal(proposal, persister, blockchain_client) 288 | .await 289 | } 290 | ReceiveSession::PayjoinProposal(proposal) => { 291 | self.send_payjoin_proposal(proposal, persister, blockchain_client) 292 | .await 293 | } 294 | ReceiveSession::Monitor(proposal) => { 295 | self.monitor_payjoin_proposal(proposal, persister, blockchain_client) 296 | .await 297 | } 298 | ReceiveSession::HasReplyableError(error) => self.handle_error(error, persister).await, 299 | ReceiveSession::Closed(_) => return Err(Error::Generic("Session closed".to_string())), 300 | } 301 | } 302 | 303 | async fn read_from_directory( 304 | &mut self, 305 | receiver: Receiver, 306 | persister: &impl SessionPersister, 307 | relay: impl payjoin::IntoUrl, 308 | max_fee_rate: FeeRate, 309 | blockchain_client: BlockchainClient, 310 | ) -> Result<(), Error> { 311 | let mut current_receiver_typestate = receiver; 312 | let next_receiver_typestate = loop { 313 | let (req, context) = current_receiver_typestate 314 | .create_poll_request(relay.as_str()) 315 | .map_err(|e| { 316 | Error::Generic(format!( 317 | "Failed to create a poll request to read from the Payjoin directory: {e}" 318 | )) 319 | })?; 320 | println!("Polling receive request..."); 321 | let response = self.send_payjoin_post_request(req).await?; 322 | let state_transition = current_receiver_typestate 323 | .process_response(response.bytes().await?.to_vec().as_slice(), context) 324 | .save(persister); 325 | match state_transition { 326 | Ok(OptionalTransitionOutcome::Progress(next_state)) => { 327 | println!("Got a request from the sender. Responding with a Payjoin proposal."); 328 | break next_state; 329 | } 330 | Ok(OptionalTransitionOutcome::Stasis(current_state)) => { 331 | current_receiver_typestate = current_state; 332 | continue; 333 | } 334 | Err(e) => { 335 | return Err(Error::Generic(format!( 336 | "Error occurred when polling for Payjoin proposal from the directory: {}", 337 | e.to_string() 338 | ))); 339 | } 340 | } 341 | }; 342 | self.check_proposal( 343 | next_receiver_typestate, 344 | persister, 345 | max_fee_rate, 346 | blockchain_client, 347 | ) 348 | .await 349 | } 350 | 351 | async fn check_proposal( 352 | &mut self, 353 | receiver: Receiver, 354 | persister: &impl SessionPersister, 355 | max_fee_rate: FeeRate, 356 | blockchain_client: BlockchainClient, 357 | ) -> Result<(), Error> { 358 | let next_receiver_typestate = receiver 359 | .assume_interactive_receiver() 360 | .save(persister) 361 | .map_err(|e| { 362 | Error::Generic(format!( 363 | "Error occurred when saving after assuming interactive receiver and not checking proposal broadcastability: {e}" 364 | )) 365 | })?; 366 | 367 | println!( 368 | "Checking whether the original proposal can be broadcasted itself is not supported. If the Payjoin fails, manually fall back to the transaction below." 369 | ); 370 | println!( 371 | "{}", 372 | serialize_hex(&next_receiver_typestate.extract_tx_to_schedule_broadcast()) 373 | ); 374 | 375 | self.check_inputs_not_owned( 376 | next_receiver_typestate, 377 | persister, 378 | max_fee_rate, 379 | blockchain_client, 380 | ) 381 | .await 382 | } 383 | 384 | async fn check_inputs_not_owned( 385 | &mut self, 386 | receiver: Receiver, 387 | persister: &impl SessionPersister, 388 | max_fee_rate: FeeRate, 389 | blockchain_client: BlockchainClient, 390 | ) -> Result<(), Error> { 391 | let next_receiver_typestate = receiver 392 | .check_inputs_not_owned(&mut |input| { 393 | Ok(self.wallet.is_mine(input.to_owned())) 394 | }) 395 | .save(persister) 396 | .map_err(|e| { 397 | Error::Generic(format!("Error occurred when saving after checking if inputs in the original proposal are not owned: {e}")) 398 | })?; 399 | 400 | self.check_no_inputs_seen_before( 401 | next_receiver_typestate, 402 | persister, 403 | max_fee_rate, 404 | blockchain_client, 405 | ) 406 | .await 407 | } 408 | 409 | async fn check_no_inputs_seen_before( 410 | &mut self, 411 | receiver: Receiver, 412 | persister: &impl SessionPersister, 413 | max_fee_rate: FeeRate, 414 | blockchain_client: BlockchainClient, 415 | ) -> Result<(), Error> { 416 | // This is not supported as there is no persistence of previous Payjoin attempts in BDK CLI 417 | // yet. If there is support either in the BDK persister or Payjoin persister, this can be 418 | // implemented, but it is not a concern as the use cases of the CLI does not warrant 419 | // protection against probing attacks. 420 | println!( 421 | "Checking whether the inputs in the proposal were seen before to protect from probing attacks is not supported. Skipping the check..." 422 | ); 423 | let next_receiver_typestate = receiver.check_no_inputs_seen_before(&mut |_| Ok(false)).save(persister).map_err(|e| { 424 | Error::Generic(format!("Error occurred when saving after checking if the inputs in the proposal were seen before: {e}")) 425 | })?; 426 | self.identify_receiver_outputs( 427 | next_receiver_typestate, 428 | persister, 429 | max_fee_rate, 430 | blockchain_client, 431 | ) 432 | .await 433 | } 434 | 435 | async fn identify_receiver_outputs( 436 | &mut self, 437 | receiver: Receiver, 438 | persister: &impl SessionPersister, 439 | max_fee_rate: FeeRate, 440 | blockchain_client: BlockchainClient, 441 | ) -> Result<(), Error> { 442 | let next_receiver_typestate = receiver.identify_receiver_outputs(&mut |output_script| { 443 | Ok(self.wallet.is_mine(output_script.to_owned())) 444 | }).save(persister).map_err(|e| { 445 | Error::Generic(format!("Error occurred when saving after checking if the outputs in the original proposal are owned by the receiver: {e}")) 446 | })?; 447 | 448 | self.commit_outputs( 449 | next_receiver_typestate, 450 | persister, 451 | max_fee_rate, 452 | blockchain_client, 453 | ) 454 | .await 455 | } 456 | 457 | async fn commit_outputs( 458 | &mut self, 459 | receiver: Receiver, 460 | persister: &impl SessionPersister, 461 | max_fee_rate: FeeRate, 462 | blockchain_client: BlockchainClient, 463 | ) -> Result<(), Error> { 464 | // This is a typestate to modify existing receiver-owned outputs in case the receiver wants 465 | // to do that. This is a very simple implementation of Payjoin so we are just going 466 | // to commit to the existing outputs which the sender included in the original proposal. 467 | let next_receiver_typestate = receiver.commit_outputs().save(persister).map_err(|e| { 468 | Error::Generic(format!( 469 | "Error occurred when saving after committing to the outputs in the proposal: {e}" 470 | )) 471 | })?; 472 | self.contribute_inputs( 473 | next_receiver_typestate, 474 | persister, 475 | max_fee_rate, 476 | blockchain_client, 477 | ) 478 | .await 479 | } 480 | 481 | async fn contribute_inputs( 482 | &mut self, 483 | receiver: Receiver, 484 | persister: &impl SessionPersister, 485 | max_fee_rate: FeeRate, 486 | blockchain_client: BlockchainClient, 487 | ) -> Result<(), Error> { 488 | let candidate_inputs: Vec = self 489 | .wallet 490 | .list_unspent() 491 | .map(|output| { 492 | let psbtin = self 493 | .wallet 494 | .get_psbt_input(output.clone(), None, false) 495 | .expect( 496 | "Failed to get the PSBT Input using the output of the unspent transaction", 497 | ); 498 | let txin = TxIn { 499 | previous_output: output.outpoint, 500 | ..Default::default() 501 | }; 502 | InputPair::new(txin, psbtin, None) 503 | .expect("Failed to create InputPair when contributing outputs to the proposal") 504 | }) 505 | .collect(); 506 | let selected_input = receiver 507 | .try_preserving_privacy(candidate_inputs) 508 | .map_err(|e| { 509 | Error::Generic(format!( 510 | "Error occurred when trying to pick an unspent UTXO for input contribution: {e}" 511 | )) 512 | })?; 513 | 514 | let next_receiver_typestate = receiver.contribute_inputs(vec![selected_input]) 515 | .map_err(|e| { 516 | Error::Generic(format!("Error occurred when contributing the selected input to the proposal: {e}")) 517 | })?.commit_inputs().save(persister) 518 | .map_err(|e| { 519 | Error::Generic(format!("Error occurred when saving after committing to the inputs after receiver contribution: {e}")) 520 | })?; 521 | 522 | self.apply_fee_range( 523 | next_receiver_typestate, 524 | persister, 525 | max_fee_rate, 526 | blockchain_client, 527 | ) 528 | .await 529 | } 530 | 531 | async fn apply_fee_range( 532 | &mut self, 533 | receiver: Receiver, 534 | persister: &impl SessionPersister, 535 | max_fee_rate: FeeRate, 536 | blockchain_client: BlockchainClient, 537 | ) -> Result<(), Error> { 538 | let next_receiver_typestate = receiver.apply_fee_range(None, Some(max_fee_rate)).save(persister).map_err(|e| { 539 | Error::Generic(format!("Error occurred when saving after applying the receiver fee range to the transaction: {e}")) 540 | })?; 541 | self.finalize_proposal(next_receiver_typestate, persister, blockchain_client) 542 | .await 543 | } 544 | 545 | async fn finalize_proposal( 546 | &mut self, 547 | receiver: Receiver, 548 | persister: &impl SessionPersister, 549 | blockchain_client: BlockchainClient, 550 | ) -> Result<(), Error> { 551 | let next_receiver_typestate = receiver 552 | .finalize_proposal(|psbt| { 553 | let mut psbt_clone = psbt.clone(); 554 | 555 | // We cannot finalize the transaction for broadcasting as it does not have the 556 | // sender signatures yet. Hence, we only care about whether this returns an Err. 557 | let _ = !self 558 | .wallet 559 | .sign(&mut psbt_clone, SignOptions::default()) 560 | .map_err(|e| { 561 | ImplementationError::from( 562 | format!("Error occurred when signing the Payjoin PSBT: {e}").as_str(), 563 | ) 564 | })?; 565 | 566 | Ok(psbt_clone) 567 | }) 568 | .save(persister) 569 | .map_err(|e| { 570 | Error::Generic(format!( 571 | "Error occurred when saving after signing the Payjoin proposal: {e}" 572 | )) 573 | })?; 574 | 575 | self.send_payjoin_proposal(next_receiver_typestate, persister, blockchain_client) 576 | .await 577 | } 578 | 579 | async fn send_payjoin_proposal( 580 | &mut self, 581 | receiver: Receiver, 582 | persister: &impl SessionPersister, 583 | blockchain_client: BlockchainClient, 584 | ) -> Result<(), Error> { 585 | let (req, ctx) = receiver.create_post_request( 586 | self.relay_manager 587 | .lock() 588 | .expect("Lock should not be poisoned") 589 | .get_selected_relay() 590 | .expect("A relay should already be selected") 591 | .as_str(), 592 | ).map_err(|e| { 593 | Error::Generic(format!("Error occurred when creating a post request for sending final Payjoin proposal: {e}")) 594 | })?; 595 | 596 | let res = self.send_payjoin_post_request(req).await?; 597 | let payjoin_psbt = receiver.psbt().clone(); 598 | let next_receiver_typestate = receiver.process_response(&res.bytes().await?, ctx).save(persister).map_err(|e| { 599 | Error::Generic(format!("Error occurred when saving after processing the response to the Payjoin proposal send: {e}")) 600 | })?; 601 | println!( 602 | "Response successful. TXID: {}", 603 | payjoin_psbt.extract_tx_unchecked_fee_rate().compute_txid() 604 | ); 605 | return self 606 | .monitor_payjoin_proposal(next_receiver_typestate, persister, blockchain_client) 607 | .await; 608 | } 609 | 610 | /// Syncs the blockchain once and then checks whether the Payjoin was broadcasted by the 611 | /// sender. 612 | /// 613 | /// The currenty implementation does not support checking for the Payjoin broadcast in a loop 614 | /// and returning only when it is detected or if a timeout is reached because the [`sync_wallet`] 615 | /// function consumes the BlockchainClient. BDK CLI supports multiple blockchain clients, and 616 | /// at the time of writing, Kyoto consumes the client since BDK CLI is not designed for long-running 617 | /// tasks. 618 | async fn monitor_payjoin_proposal( 619 | &mut self, 620 | receiver: Receiver, 621 | persister: &impl SessionPersister, 622 | blockchain_client: BlockchainClient, 623 | ) -> Result<(), Error> { 624 | let wait_time_for_sync = 3; 625 | let poll_internal = tokio::time::Duration::from_secs(wait_time_for_sync); 626 | 627 | println!( 628 | "Waiting for {wait_time_for_sync} seconds before syncing the blockchain and checking if the transaction has been broadcast..." 629 | ); 630 | tokio::time::sleep(poll_internal).await; 631 | sync_wallet(blockchain_client, self.wallet).await?; 632 | 633 | let check_result = receiver 634 | .check_payment( 635 | |txid| { 636 | let Some(tx_details) = self.wallet.tx_details(txid) else { 637 | return Err(ImplementationError::from("Cannot find the transaction in the mempool or the blockchain")); 638 | }; 639 | 640 | let is_seen = match tx_details.chain_position { 641 | bdk_wallet::chain::ChainPosition::Confirmed { .. } => true, 642 | bdk_wallet::chain::ChainPosition::Unconfirmed { first_seen: Some(_), .. } => true, 643 | _ => false 644 | }; 645 | 646 | if is_seen { 647 | return Ok(Some(tx_details.tx.as_ref().clone())); 648 | } 649 | return Err(ImplementationError::from("Cannot find the transaction in the mempool or the blockchain")); 650 | }, 651 | |outpoint| { 652 | let utxo = self.wallet.get_utxo(outpoint); 653 | match utxo { 654 | Some(_) => Ok(false), 655 | None => Ok(true), 656 | } 657 | } 658 | ) 659 | .save(persister) 660 | .map_err(|e| { 661 | Error::Generic(format!("Error occurred when saving after checking that sender has broadcasted the Payjoin transaction: {e}")) 662 | }); 663 | 664 | match check_result { 665 | Ok(_) => { 666 | println!("Payjoin transaction detected in the mempool!"); 667 | } 668 | Err(_) => { 669 | println!( 670 | "Transaction was not found in the mempool after {wait_time_for_sync}. Check the state of the transaction manually after running the sync command." 671 | ); 672 | } 673 | } 674 | 675 | Ok(()) 676 | } 677 | 678 | async fn handle_error( 679 | &self, 680 | receiver: Receiver, 681 | persister: &impl SessionPersister, 682 | ) -> Result<(), Error> { 683 | let (err_req, err_ctx) = receiver 684 | .create_error_request( 685 | self.relay_manager 686 | .lock() 687 | .expect("Lock should not be poisoned") 688 | .get_selected_relay() 689 | .expect("A relay should already be selected") 690 | .as_str(), 691 | ) 692 | .map_err(|e| { 693 | Error::Generic(format!( 694 | "Error occurred when creating a receiver error request: {}", 695 | e 696 | )) 697 | })?; 698 | 699 | let err_response = match self.send_payjoin_post_request(err_req).await { 700 | Ok(response) => response, 701 | Err(e) => { 702 | return Err(Error::Generic(format!( 703 | "Failed to post error request: {}", 704 | e 705 | ))); 706 | } 707 | }; 708 | 709 | let err_bytes = match err_response.bytes().await { 710 | Ok(bytes) => bytes, 711 | Err(e) => { 712 | return Err(Error::Generic(format!( 713 | "Failed to get error response bytes: {}", 714 | e 715 | ))); 716 | } 717 | }; 718 | 719 | if let Err(e) = receiver 720 | .process_error_response(&err_bytes, err_ctx) 721 | .save(persister) 722 | { 723 | return Err(Error::Generic(format!( 724 | "Failed to process error response: {}", 725 | e 726 | ))); 727 | } 728 | 729 | Ok(()) 730 | } 731 | 732 | async fn proceed_sender_session( 733 | &self, 734 | session: SendSession, 735 | persister: &impl SessionPersister, 736 | relay: impl payjoin::IntoUrl, 737 | blockchain_client: BlockchainClient, 738 | ) -> Result { 739 | match session { 740 | SendSession::WithReplyKey(context) => { 741 | self.post_original_proposal(context, relay, persister, blockchain_client) 742 | .await 743 | } 744 | SendSession::PollingForProposal(context) => { 745 | self.get_proposed_payjoin_proposal(context, relay, persister, blockchain_client) 746 | .await 747 | } 748 | SendSession::Closed(SenderSessionOutcome::Success(psbt)) => { 749 | self.process_payjoin_proposal(psbt, blockchain_client).await 750 | } 751 | _ => Err(Error::Generic("Unexpected SendSession state!".to_string())), 752 | } 753 | } 754 | 755 | async fn post_original_proposal( 756 | &self, 757 | sender: Sender, 758 | relay: impl payjoin::IntoUrl, 759 | persister: &impl SessionPersister, 760 | blockchain_client: BlockchainClient, 761 | ) -> Result { 762 | let (req, ctx) = sender.create_v2_post_request(relay.as_str()).map_err(|e| { 763 | Error::Generic(format!( 764 | "Failed to create a post request for a Payjoin send: {e}" 765 | )) 766 | })?; 767 | let response = self.send_payjoin_post_request(req).await?; 768 | let sender = sender 769 | .process_response(&response.bytes().await?, ctx) 770 | .save(persister) 771 | .map_err(|e| { 772 | Error::Generic(format!("Failed to persist the Payjoin send after successfully sending original proposal: {e}")) 773 | })?; 774 | self.get_proposed_payjoin_proposal(sender, relay, persister, blockchain_client) 775 | .await 776 | } 777 | 778 | async fn get_proposed_payjoin_proposal( 779 | &self, 780 | sender: Sender, 781 | relay: impl payjoin::IntoUrl, 782 | persister: &impl SessionPersister, 783 | blockchain_client: BlockchainClient, 784 | ) -> Result { 785 | let mut sender = sender.clone(); 786 | loop { 787 | let (req, ctx) = sender.create_poll_request(relay.as_str()).map_err(|e| { 788 | Error::Generic(format!( 789 | "Failed to create a poll request during a Payjoin send: {e}" 790 | )) 791 | })?; 792 | let response = self.send_payjoin_post_request(req).await?; 793 | let processed_response = sender 794 | .process_response(&response.bytes().await?, ctx) 795 | .save(persister); 796 | match processed_response { 797 | Ok(OptionalTransitionOutcome::Progress(psbt)) => { 798 | println!("Proposal received. Processing..."); 799 | return self.process_payjoin_proposal(psbt, blockchain_client).await; 800 | } 801 | Ok(OptionalTransitionOutcome::Stasis(current_state)) => { 802 | println!("No response yet. Continuing polling..."); 803 | sender = current_state; 804 | continue; 805 | } 806 | Err(e) => { 807 | break Err(Error::Generic(format!( 808 | "Error occurred when polling for Payjoin v2 proposal: {e}" 809 | ))); 810 | } 811 | } 812 | } 813 | } 814 | 815 | async fn process_payjoin_proposal( 816 | &self, 817 | mut psbt: Psbt, 818 | blockchain_client: BlockchainClient, 819 | ) -> Result { 820 | if !self.wallet.sign(&mut psbt, SignOptions::default())? { 821 | return Err(Error::Generic( 822 | "Failed to sign and finalize the Payjoin proposal PSBT.".to_string(), 823 | )); 824 | } 825 | 826 | broadcast_transaction(blockchain_client, psbt.extract_tx_fee_rate_limit()?).await 827 | } 828 | 829 | async fn send_payjoin_post_request( 830 | &self, 831 | req: payjoin::Request, 832 | ) -> reqwest::Result { 833 | let client = reqwest::Client::new(); 834 | client 835 | .post(req.url) 836 | .header("Content-Type", req.content_type) 837 | .body(req.body) 838 | .send() 839 | .await 840 | } 841 | } 842 | -------------------------------------------------------------------------------- /src/handlers.rs: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 Bitcoin Dev Kit Developers 2 | // 3 | // This file is licensed under the Apache License, Version 2.0 or the MIT license 5 | // , at your option. 6 | // You may not use this file except in accordance with one or both of these 7 | // licenses. 8 | 9 | //! Command Handlers 10 | //! 11 | //! This module describes all the command handling logic used by bdk-cli. 12 | use crate::commands::OfflineWalletSubCommand::*; 13 | use crate::commands::*; 14 | use crate::error::BDKCliError as Error; 15 | #[cfg(any(feature = "sqlite", feature = "redb"))] 16 | use crate::persister::Persister; 17 | use crate::utils::*; 18 | #[cfg(feature = "redb")] 19 | use bdk_redb::Store as RedbStore; 20 | use bdk_wallet::bip39::{Language, Mnemonic}; 21 | use bdk_wallet::bitcoin::base64::Engine; 22 | use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD; 23 | use bdk_wallet::bitcoin::{ 24 | Address, Amount, FeeRate, Network, Psbt, Sequence, Txid, 25 | bip32::{DerivationPath, KeySource}, 26 | consensus::encode::serialize_hex, 27 | script::PushBytesBuf, 28 | secp256k1::Secp256k1, 29 | }; 30 | use bdk_wallet::chain::ChainPosition; 31 | use bdk_wallet::descriptor::Segwitv0; 32 | use bdk_wallet::keys::{ 33 | DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey, 34 | bip39::WordCount, 35 | }; 36 | use bdk_wallet::miniscript::miniscript; 37 | #[cfg(feature = "sqlite")] 38 | use bdk_wallet::rusqlite::Connection; 39 | use bdk_wallet::{KeychainKind, SignOptions, Wallet}; 40 | #[cfg(feature = "compiler")] 41 | use bdk_wallet::{ 42 | bitcoin::XOnlyPublicKey, 43 | descriptor::{Descriptor, Legacy, Miniscript}, 44 | miniscript::{Tap, descriptor::TapTree, policy::Concrete}, 45 | }; 46 | use cli_table::{Cell, CellStruct, Style, Table, format::Justify}; 47 | use serde_json::json; 48 | #[cfg(feature = "cbf")] 49 | use {crate::utils::BlockchainClient::KyotoClient, bdk_kyoto::LightClient, tokio::select}; 50 | 51 | #[cfg(feature = "electrum")] 52 | use crate::utils::BlockchainClient::Electrum; 53 | use std::collections::BTreeMap; 54 | #[cfg(any(feature = "electrum", feature = "esplora"))] 55 | use std::collections::HashSet; 56 | use std::convert::TryFrom; 57 | #[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))] 58 | use std::io::Write; 59 | use std::str::FromStr; 60 | #[cfg(any( 61 | feature = "redb", 62 | feature = "compiler", 63 | feature = "electrum", 64 | feature = "esplora", 65 | feature = "cbf", 66 | feature = "rpc" 67 | ))] 68 | use std::sync::Arc; 69 | #[cfg(any( 70 | feature = "electrum", 71 | feature = "esplora", 72 | feature = "cbf", 73 | feature = "rpc" 74 | ))] 75 | use { 76 | crate::commands::OnlineWalletSubCommand::*, 77 | crate::payjoin::{PayjoinManager, ohttp::RelayManager}, 78 | bdk_wallet::bitcoin::{Transaction, consensus::Decodable, hex::FromHex}, 79 | std::sync::Mutex, 80 | }; 81 | #[cfg(feature = "esplora")] 82 | use {crate::utils::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; 83 | #[cfg(feature = "rpc")] 84 | use { 85 | crate::utils::BlockchainClient::RpcClient, 86 | bdk_bitcoind_rpc::{Emitter, NO_EXPECTED_MEMPOOL_TXS, bitcoincore_rpc::RpcApi}, 87 | bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint}, 88 | }; 89 | 90 | #[cfg(feature = "compiler")] 91 | const NUMS_UNSPENDABLE_KEY_HEX: &str = 92 | "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"; 93 | 94 | /// Execute an offline wallet sub-command 95 | /// 96 | /// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`]. 97 | pub fn handle_offline_wallet_subcommand( 98 | wallet: &mut Wallet, 99 | wallet_opts: &WalletOpts, 100 | cli_opts: &CliOpts, 101 | offline_subcommand: OfflineWalletSubCommand, 102 | ) -> Result { 103 | match offline_subcommand { 104 | NewAddress => { 105 | let addr = wallet.reveal_next_address(KeychainKind::External); 106 | if cli_opts.pretty { 107 | let table = vec![ 108 | vec!["Address".cell().bold(true), addr.address.to_string().cell()], 109 | vec![ 110 | "Index".cell().bold(true), 111 | addr.index.to_string().cell().justify(Justify::Right), 112 | ], 113 | ] 114 | .table() 115 | .display() 116 | .map_err(|e| Error::Generic(e.to_string()))?; 117 | Ok(format!("{table}")) 118 | } else if wallet_opts.verbose { 119 | Ok(serde_json::to_string_pretty(&json!({ 120 | "address": addr.address, 121 | "index": addr.index 122 | }))?) 123 | } else { 124 | Ok(serde_json::to_string_pretty(&json!({ 125 | "address": addr.address, 126 | }))?) 127 | } 128 | } 129 | UnusedAddress => { 130 | let addr = wallet.next_unused_address(KeychainKind::External); 131 | 132 | if cli_opts.pretty { 133 | let table = vec![ 134 | vec!["Address".cell().bold(true), addr.address.to_string().cell()], 135 | vec![ 136 | "Index".cell().bold(true), 137 | addr.index.to_string().cell().justify(Justify::Right), 138 | ], 139 | ] 140 | .table() 141 | .display() 142 | .map_err(|e| Error::Generic(e.to_string()))?; 143 | Ok(format!("{table}")) 144 | } else if wallet_opts.verbose { 145 | Ok(serde_json::to_string_pretty(&json!({ 146 | "address": addr.address, 147 | "index": addr.index 148 | }))?) 149 | } else { 150 | Ok(serde_json::to_string_pretty(&json!({ 151 | "address": addr.address, 152 | }))?) 153 | } 154 | } 155 | Unspent => { 156 | let utxos = wallet.list_unspent().collect::>(); 157 | if cli_opts.pretty { 158 | let mut rows: Vec> = vec![]; 159 | for utxo in &utxos { 160 | let height = utxo 161 | .chain_position 162 | .confirmation_height_upper_bound() 163 | .map(|h| h.to_string()) 164 | .unwrap_or("Pending".to_string()); 165 | 166 | let block_hash = match &utxo.chain_position { 167 | ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(), 168 | ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(), 169 | }; 170 | 171 | rows.push(vec![ 172 | shorten(utxo.outpoint, 8, 10).cell(), 173 | utxo.txout 174 | .value 175 | .to_sat() 176 | .to_string() 177 | .cell() 178 | .justify(Justify::Right), 179 | Address::from_script(&utxo.txout.script_pubkey, cli_opts.network) 180 | .unwrap() 181 | .cell(), 182 | utxo.keychain.cell(), 183 | utxo.is_spent.cell(), 184 | utxo.derivation_index.cell(), 185 | height.to_string().cell().justify(Justify::Right), 186 | shorten(block_hash, 8, 8).cell().justify(Justify::Right), 187 | ]); 188 | } 189 | let table = rows 190 | .table() 191 | .title(vec![ 192 | "Outpoint".cell().bold(true), 193 | "Output (sat)".cell().bold(true), 194 | "Output Address".cell().bold(true), 195 | "Keychain".cell().bold(true), 196 | "Is Spent".cell().bold(true), 197 | "Index".cell().bold(true), 198 | "Block Height".cell().bold(true), 199 | "Block Hash".cell().bold(true), 200 | ]) 201 | .display() 202 | .map_err(|e| Error::Generic(e.to_string()))?; 203 | Ok(format!("{table}")) 204 | } else { 205 | Ok(serde_json::to_string_pretty(&utxos)?) 206 | } 207 | } 208 | Transactions => { 209 | let transactions = wallet.transactions(); 210 | 211 | if cli_opts.pretty { 212 | let txns = transactions 213 | .map(|tx| { 214 | let total_value = tx 215 | .tx_node 216 | .output 217 | .iter() 218 | .map(|output| output.value.to_sat()) 219 | .sum::(); 220 | ( 221 | tx.tx_node.txid.to_string(), 222 | tx.tx_node.version, 223 | tx.tx_node.is_explicitly_rbf(), 224 | tx.tx_node.input.len(), 225 | tx.tx_node.output.len(), 226 | total_value, 227 | ) 228 | }) 229 | .collect::>(); 230 | let mut rows: Vec> = vec![]; 231 | for (txid, version, is_rbf, input_count, output_count, total_value) in txns { 232 | rows.push(vec![ 233 | txid.cell(), 234 | version.to_string().cell().justify(Justify::Right), 235 | is_rbf.to_string().cell().justify(Justify::Center), 236 | input_count.to_string().cell().justify(Justify::Right), 237 | output_count.to_string().cell().justify(Justify::Right), 238 | total_value.to_string().cell().justify(Justify::Right), 239 | ]); 240 | } 241 | let table = rows 242 | .table() 243 | .title(vec![ 244 | "Txid".cell().bold(true), 245 | "Version".cell().bold(true), 246 | "Is RBF".cell().bold(true), 247 | "Input Count".cell().bold(true), 248 | "Output Count".cell().bold(true), 249 | "Total Value (sat)".cell().bold(true), 250 | ]) 251 | .display() 252 | .map_err(|e| Error::Generic(e.to_string()))?; 253 | Ok(format!("{table}")) 254 | } else { 255 | let txns: Vec<_> = transactions 256 | .map(|tx| { 257 | json!({ 258 | "txid": tx.tx_node.txid, 259 | "is_coinbase": tx.tx_node.is_coinbase(), 260 | "wtxid": tx.tx_node.compute_wtxid(), 261 | "version": tx.tx_node.version, 262 | "is_rbf": tx.tx_node.is_explicitly_rbf(), 263 | "inputs": tx.tx_node.input, 264 | "outputs": tx.tx_node.output, 265 | }) 266 | }) 267 | .collect(); 268 | Ok(serde_json::to_string_pretty(&txns)?) 269 | } 270 | } 271 | Balance => { 272 | let balance = wallet.balance(); 273 | if cli_opts.pretty { 274 | let table = vec![ 275 | vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)], 276 | vec![ 277 | "Total".cell(), 278 | balance 279 | .total() 280 | .to_sat() 281 | .to_string() 282 | .cell() 283 | .justify(Justify::Right), 284 | ], 285 | vec![ 286 | "Confirmed".cell(), 287 | balance 288 | .confirmed 289 | .to_sat() 290 | .to_string() 291 | .cell() 292 | .justify(Justify::Right), 293 | ], 294 | vec![ 295 | "Unconfirmed".cell(), 296 | balance 297 | .immature 298 | .to_sat() 299 | .to_string() 300 | .cell() 301 | .justify(Justify::Right), 302 | ], 303 | vec![ 304 | "Trusted Pending".cell(), 305 | balance 306 | .trusted_pending 307 | .to_sat() 308 | .cell() 309 | .justify(Justify::Right), 310 | ], 311 | vec![ 312 | "Untrusted Pending".cell(), 313 | balance 314 | .untrusted_pending 315 | .to_sat() 316 | .cell() 317 | .justify(Justify::Right), 318 | ], 319 | ] 320 | .table() 321 | .display() 322 | .map_err(|e| Error::Generic(e.to_string()))?; 323 | Ok(format!("{table}")) 324 | } else { 325 | Ok(serde_json::to_string_pretty( 326 | &json!({"satoshi": wallet.balance()}), 327 | )?) 328 | } 329 | } 330 | 331 | CreateTx { 332 | recipients, 333 | send_all, 334 | enable_rbf, 335 | offline_signer, 336 | utxos, 337 | unspendable, 338 | fee_rate, 339 | external_policy, 340 | internal_policy, 341 | add_data, 342 | add_string, 343 | } => { 344 | let mut tx_builder = wallet.build_tx(); 345 | 346 | if send_all { 347 | tx_builder.drain_wallet().drain_to(recipients[0].0.clone()); 348 | } else { 349 | let recipients = recipients 350 | .into_iter() 351 | .map(|(script, amount)| (script, Amount::from_sat(amount))) 352 | .collect(); 353 | tx_builder.set_recipients(recipients); 354 | } 355 | 356 | if !enable_rbf { 357 | tx_builder.set_exact_sequence(Sequence::MAX); 358 | } 359 | 360 | if offline_signer { 361 | tx_builder.include_output_redeem_witness_script(); 362 | } 363 | 364 | if let Some(fee_rate) = fee_rate { 365 | if let Some(fee_rate) = FeeRate::from_sat_per_vb(fee_rate as u64) { 366 | tx_builder.fee_rate(fee_rate); 367 | } 368 | } 369 | 370 | if let Some(utxos) = utxos { 371 | tx_builder.add_utxos(&utxos[..]).unwrap(); 372 | } 373 | 374 | if let Some(unspendable) = unspendable { 375 | tx_builder.unspendable(unspendable); 376 | } 377 | 378 | if let Some(base64_data) = add_data { 379 | let op_return_data = BASE64_STANDARD.decode(base64_data).unwrap(); 380 | tx_builder.add_data(&PushBytesBuf::try_from(op_return_data).unwrap()); 381 | } else if let Some(string_data) = add_string { 382 | let data = PushBytesBuf::try_from(string_data.as_bytes().to_vec()).unwrap(); 383 | tx_builder.add_data(&data); 384 | } 385 | 386 | let policies = vec![ 387 | external_policy.map(|p| (p, KeychainKind::External)), 388 | internal_policy.map(|p| (p, KeychainKind::Internal)), 389 | ]; 390 | 391 | for (policy, keychain) in policies.into_iter().flatten() { 392 | let policy = serde_json::from_str::>>(&policy)?; 393 | tx_builder.policy_path(policy, keychain); 394 | } 395 | 396 | let psbt = tx_builder.finish()?; 397 | 398 | let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); 399 | 400 | if wallet_opts.verbose { 401 | Ok(serde_json::to_string_pretty( 402 | &json!({"psbt": psbt_base64, "details": psbt}), 403 | )?) 404 | } else { 405 | Ok(serde_json::to_string_pretty( 406 | &json!({"psbt": psbt_base64 }), 407 | )?) 408 | } 409 | } 410 | BumpFee { 411 | txid, 412 | shrink_address, 413 | offline_signer, 414 | utxos, 415 | unspendable, 416 | fee_rate, 417 | } => { 418 | let txid = Txid::from_str(txid.as_str())?; 419 | 420 | let mut tx_builder = wallet.build_fee_bump(txid)?; 421 | let fee_rate = 422 | FeeRate::from_sat_per_vb(fee_rate as u64).unwrap_or(FeeRate::BROADCAST_MIN); 423 | tx_builder.fee_rate(fee_rate); 424 | 425 | if let Some(address) = shrink_address { 426 | let script_pubkey = address.script_pubkey(); 427 | tx_builder.drain_to(script_pubkey); 428 | } 429 | 430 | if offline_signer { 431 | tx_builder.include_output_redeem_witness_script(); 432 | } 433 | 434 | if let Some(utxos) = utxos { 435 | tx_builder.add_utxos(&utxos[..]).unwrap(); 436 | } 437 | 438 | if let Some(unspendable) = unspendable { 439 | tx_builder.unspendable(unspendable); 440 | } 441 | 442 | let psbt = tx_builder.finish()?; 443 | 444 | let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); 445 | 446 | Ok(serde_json::to_string_pretty( 447 | &json!({"psbt": psbt_base64 }), 448 | )?) 449 | } 450 | Policies => { 451 | let external_policy = wallet.policies(KeychainKind::External)?; 452 | let internal_policy = wallet.policies(KeychainKind::Internal)?; 453 | if cli_opts.pretty { 454 | let table = vec![ 455 | vec![ 456 | "External".cell().bold(true), 457 | serde_json::to_string_pretty(&external_policy)?.cell(), 458 | ], 459 | vec![ 460 | "Internal".cell().bold(true), 461 | serde_json::to_string_pretty(&internal_policy)?.cell(), 462 | ], 463 | ] 464 | .table() 465 | .display() 466 | .map_err(|e| Error::Generic(e.to_string()))?; 467 | Ok(format!("{table}")) 468 | } else { 469 | Ok(serde_json::to_string_pretty(&json!({ 470 | "external": external_policy, 471 | "internal": internal_policy, 472 | }))?) 473 | } 474 | } 475 | PublicDescriptor => { 476 | let external = wallet.public_descriptor(KeychainKind::External).to_string(); 477 | let internal = wallet.public_descriptor(KeychainKind::Internal).to_string(); 478 | 479 | if cli_opts.pretty { 480 | let table = vec![ 481 | vec![ 482 | "External Descriptor".cell().bold(true), 483 | external.to_string().cell(), 484 | ], 485 | vec![ 486 | "Internal Descriptor".cell().bold(true), 487 | internal.to_string().cell(), 488 | ], 489 | ] 490 | .table() 491 | .display() 492 | .map_err(|e| Error::Generic(e.to_string()))?; 493 | Ok(format!("{table}")) 494 | } else { 495 | Ok(serde_json::to_string_pretty(&json!({ 496 | "external": external.to_string(), 497 | "internal": internal.to_string(), 498 | }))?) 499 | } 500 | } 501 | Sign { 502 | psbt, 503 | assume_height, 504 | trust_witness_utxo, 505 | } => { 506 | let psbt_bytes = BASE64_STANDARD.decode(psbt)?; 507 | let mut psbt = Psbt::deserialize(&psbt_bytes)?; 508 | let signopt = SignOptions { 509 | assume_height, 510 | trust_witness_utxo: trust_witness_utxo.unwrap_or(false), 511 | ..Default::default() 512 | }; 513 | let finalized = wallet.sign(&mut psbt, signopt)?; 514 | let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize()); 515 | if wallet_opts.verbose { 516 | Ok(serde_json::to_string_pretty( 517 | &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}), 518 | )?) 519 | } else { 520 | Ok(serde_json::to_string_pretty( 521 | &json!({"psbt": &psbt_base64, "is_finalized": finalized}), 522 | )?) 523 | } 524 | } 525 | ExtractPsbt { psbt } => { 526 | let psbt_serialized = BASE64_STANDARD.decode(psbt)?; 527 | let psbt = Psbt::deserialize(&psbt_serialized)?; 528 | let raw_tx = psbt.extract_tx()?; 529 | if cli_opts.pretty { 530 | let table = vec![vec![ 531 | "Raw Transaction".cell().bold(true), 532 | serialize_hex(&raw_tx).cell(), 533 | ]] 534 | .table() 535 | .display() 536 | .map_err(|e| Error::Generic(e.to_string()))?; 537 | Ok(format!("{table}")) 538 | } else { 539 | Ok(serde_json::to_string_pretty( 540 | &json!({"raw_tx": serialize_hex(&raw_tx)}), 541 | )?) 542 | } 543 | } 544 | FinalizePsbt { 545 | psbt, 546 | assume_height, 547 | trust_witness_utxo, 548 | } => { 549 | let psbt_bytes = BASE64_STANDARD.decode(psbt)?; 550 | let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes)?; 551 | 552 | let signopt = SignOptions { 553 | assume_height, 554 | trust_witness_utxo: trust_witness_utxo.unwrap_or(false), 555 | ..Default::default() 556 | }; 557 | let finalized = wallet.finalize_psbt(&mut psbt, signopt)?; 558 | if wallet_opts.verbose { 559 | Ok(serde_json::to_string_pretty( 560 | &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}), 561 | )?) 562 | } else { 563 | Ok(serde_json::to_string_pretty( 564 | &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}), 565 | )?) 566 | } 567 | } 568 | CombinePsbt { psbt } => { 569 | let mut psbts = psbt 570 | .iter() 571 | .map(|s| { 572 | let psbt = BASE64_STANDARD.decode(s)?; 573 | Ok(Psbt::deserialize(&psbt)?) 574 | }) 575 | .collect::, Error>>()?; 576 | 577 | let init_psbt = psbts 578 | .pop() 579 | .ok_or_else(|| Error::Generic("Invalid PSBT input".to_string()))?; 580 | let final_psbt = psbts.into_iter().try_fold::<_, _, Result>( 581 | init_psbt, 582 | |mut acc, x| { 583 | let _ = acc.combine(x); 584 | Ok(acc) 585 | }, 586 | )?; 587 | Ok(serde_json::to_string_pretty( 588 | &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }), 589 | )?) 590 | } 591 | } 592 | } 593 | 594 | /// Execute an online wallet sub-command 595 | /// 596 | /// Online wallet sub-commands are described in [`OnlineWalletSubCommand`]. 597 | #[cfg(any( 598 | feature = "electrum", 599 | feature = "esplora", 600 | feature = "cbf", 601 | feature = "rpc" 602 | ))] 603 | pub(crate) async fn handle_online_wallet_subcommand( 604 | wallet: &mut Wallet, 605 | client: BlockchainClient, 606 | online_subcommand: OnlineWalletSubCommand, 607 | ) -> Result { 608 | match online_subcommand { 609 | FullScan { 610 | stop_gap: _stop_gap, 611 | } => { 612 | #[cfg(any(feature = "electrum", feature = "esplora"))] 613 | let request = wallet.start_full_scan().inspect({ 614 | let mut stdout = std::io::stdout(); 615 | let mut once = HashSet::::new(); 616 | move |k, spk_i, _| { 617 | if once.insert(k) { 618 | print!("\nScanning keychain [{k:?}]"); 619 | } 620 | print!(" {spk_i:<3}"); 621 | stdout.flush().expect("must flush"); 622 | } 623 | }); 624 | match client { 625 | #[cfg(feature = "electrum")] 626 | Electrum { client, batch_size } => { 627 | // Populate the electrum client's transaction cache so it doesn't re-download transaction we 628 | // already have. 629 | client 630 | .populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); 631 | 632 | let update = client.full_scan(request, _stop_gap, batch_size, false)?; 633 | wallet.apply_update(update)?; 634 | } 635 | #[cfg(feature = "esplora")] 636 | Esplora { 637 | client, 638 | parallel_requests, 639 | } => { 640 | let update = client 641 | .full_scan(request, _stop_gap, parallel_requests) 642 | .await 643 | .map_err(|e| *e)?; 644 | wallet.apply_update(update)?; 645 | } 646 | 647 | #[cfg(feature = "rpc")] 648 | RpcClient { client } => { 649 | let blockchain_info = client.get_blockchain_info()?; 650 | 651 | let genesis_block = 652 | bdk_wallet::bitcoin::constants::genesis_block(wallet.network()); 653 | let genesis_cp = CheckPoint::new(BlockId { 654 | height: 0, 655 | hash: genesis_block.block_hash(), 656 | }); 657 | let mut emitter = Emitter::new( 658 | &*client, 659 | genesis_cp.clone(), 660 | genesis_cp.height(), 661 | NO_EXPECTED_MEMPOOL_TXS, 662 | ); 663 | 664 | while let Some(block_event) = emitter.next_block()? { 665 | if block_event.block_height() % 10_000 == 0 { 666 | let percent_done = f64::from(block_event.block_height()) 667 | / f64::from(blockchain_info.headers as u32) 668 | * 100f64; 669 | println!( 670 | "Applying block at height: {}, {:.2}% done.", 671 | block_event.block_height(), 672 | percent_done 673 | ); 674 | } 675 | 676 | wallet.apply_block_connected_to( 677 | &block_event.block, 678 | block_event.block_height(), 679 | block_event.connected_to(), 680 | )?; 681 | } 682 | 683 | let mempool_txs = emitter.mempool()?; 684 | wallet.apply_unconfirmed_txs(mempool_txs.update); 685 | } 686 | #[cfg(feature = "cbf")] 687 | KyotoClient { client } => { 688 | sync_kyoto_client(wallet, client).await?; 689 | } 690 | } 691 | Ok(serde_json::to_string_pretty(&json!({}))?) 692 | } 693 | Sync => { 694 | sync_wallet(client, wallet).await?; 695 | Ok(serde_json::to_string_pretty(&json!({}))?) 696 | } 697 | Broadcast { psbt, tx } => { 698 | let tx = match (psbt, tx) { 699 | (Some(psbt), None) => { 700 | let psbt = BASE64_STANDARD 701 | .decode(psbt) 702 | .map_err(|e| Error::Generic(e.to_string()))?; 703 | let psbt: Psbt = Psbt::deserialize(&psbt)?; 704 | is_final(&psbt)?; 705 | psbt.extract_tx()? 706 | } 707 | (None, Some(tx)) => { 708 | let tx_bytes = Vec::::from_hex(&tx)?; 709 | Transaction::consensus_decode(&mut tx_bytes.as_slice())? 710 | } 711 | (Some(_), Some(_)) => panic!("Both `psbt` and `tx` options not allowed"), 712 | (None, None) => panic!("Missing `psbt` and `tx` option"), 713 | }; 714 | let txid = broadcast_transaction(client, tx).await?; 715 | Ok(serde_json::to_string_pretty(&json!({ "txid": txid }))?) 716 | } 717 | ReceivePayjoin { 718 | amount, 719 | directory, 720 | ohttp_relay, 721 | max_fee_rate, 722 | } => { 723 | let relay_manager = Arc::new(Mutex::new(RelayManager::new())); 724 | let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); 725 | return payjoin_manager 726 | .receive_payjoin(amount, directory, max_fee_rate, ohttp_relay, client) 727 | .await; 728 | } 729 | SendPayjoin { 730 | uri, 731 | ohttp_relay, 732 | fee_rate, 733 | } => { 734 | let relay_manager = Arc::new(Mutex::new(RelayManager::new())); 735 | let mut payjoin_manager = PayjoinManager::new(wallet, relay_manager); 736 | return payjoin_manager 737 | .send_payjoin(uri, fee_rate, ohttp_relay, client) 738 | .await; 739 | } 740 | } 741 | } 742 | 743 | /// Determine if PSBT has final script sigs or witnesses for all unsigned tx inputs. 744 | #[cfg(any( 745 | feature = "electrum", 746 | feature = "esplora", 747 | feature = "cbf", 748 | feature = "rpc" 749 | ))] 750 | pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> { 751 | let unsigned_tx_inputs = psbt.unsigned_tx.input.len(); 752 | let psbt_inputs = psbt.inputs.len(); 753 | if unsigned_tx_inputs != psbt_inputs { 754 | return Err(Error::Generic(format!( 755 | "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs." 756 | ))); 757 | } 758 | let sig_count = psbt.inputs.iter().fold(0, |count, input| { 759 | if input.final_script_sig.is_some() || input.final_script_witness.is_some() { 760 | count + 1 761 | } else { 762 | count 763 | } 764 | }); 765 | if unsigned_tx_inputs > sig_count { 766 | return Err(Error::Generic( 767 | "The PSBT is not finalized, inputs are are not fully signed.".to_string(), 768 | )); 769 | } 770 | Ok(()) 771 | } 772 | 773 | /// Handle a key sub-command 774 | /// 775 | /// Key sub-commands are described in [`KeySubCommand`]. 776 | pub(crate) fn handle_key_subcommand( 777 | network: Network, 778 | subcommand: KeySubCommand, 779 | pretty: bool, 780 | ) -> Result { 781 | let secp = Secp256k1::new(); 782 | 783 | match subcommand { 784 | KeySubCommand::Generate { 785 | word_count, 786 | password, 787 | } => { 788 | let mnemonic_type = match word_count { 789 | 12 => WordCount::Words12, 790 | _ => WordCount::Words24, 791 | }; 792 | let mnemonic: GeneratedKey<_, miniscript::BareCtx> = 793 | Mnemonic::generate((mnemonic_type, Language::English)) 794 | .map_err(|_| Error::Generic("Mnemonic generation error".to_string()))?; 795 | let mnemonic = mnemonic.into_key(); 796 | let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; 797 | let xprv = xkey.into_xprv(network).ok_or_else(|| { 798 | Error::Generic("Privatekey info not found (should not happen)".to_string()) 799 | })?; 800 | let fingerprint = xprv.fingerprint(&secp); 801 | let phrase = mnemonic 802 | .words() 803 | .fold("".to_string(), |phrase, w| phrase + w + " ") 804 | .trim() 805 | .to_string(); 806 | if pretty { 807 | let table = vec![ 808 | vec![ 809 | "Fingerprint".cell().bold(true), 810 | fingerprint.to_string().cell(), 811 | ], 812 | vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], 813 | vec!["Xprv".cell().bold(true), xprv.to_string().cell()], 814 | ] 815 | .table() 816 | .display() 817 | .map_err(|e| Error::Generic(e.to_string()))?; 818 | Ok(format!("{table}")) 819 | } else { 820 | Ok(serde_json::to_string_pretty( 821 | &json!({ "mnemonic": phrase, "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), 822 | )?) 823 | } 824 | } 825 | KeySubCommand::Restore { mnemonic, password } => { 826 | let mnemonic = Mnemonic::parse_in(Language::English, mnemonic)?; 827 | let xkey: ExtendedKey = (mnemonic.clone(), password).into_extended_key()?; 828 | let xprv = xkey.into_xprv(network).ok_or_else(|| { 829 | Error::Generic("Privatekey info not found (should not happen)".to_string()) 830 | })?; 831 | let fingerprint = xprv.fingerprint(&secp); 832 | if pretty { 833 | let table = vec![ 834 | vec![ 835 | "Fingerprint".cell().bold(true), 836 | fingerprint.to_string().cell(), 837 | ], 838 | vec!["Mnemonic".cell().bold(true), mnemonic.to_string().cell()], 839 | vec!["Xprv".cell().bold(true), xprv.to_string().cell()], 840 | ] 841 | .table() 842 | .display() 843 | .map_err(|e| Error::Generic(e.to_string()))?; 844 | Ok(format!("{table}")) 845 | } else { 846 | Ok(serde_json::to_string_pretty( 847 | &json!({ "xprv": xprv.to_string(), "fingerprint": fingerprint.to_string() }), 848 | )?) 849 | } 850 | } 851 | KeySubCommand::Derive { xprv, path } => { 852 | if xprv.network != network.into() { 853 | return Err(Error::Generic("Invalid network".to_string())); 854 | } 855 | let derived_xprv = &xprv.derive_priv(&secp, &path)?; 856 | 857 | let origin: KeySource = (xprv.fingerprint(&secp), path); 858 | 859 | let derived_xprv_desc_key: DescriptorKey = 860 | derived_xprv.into_descriptor_key(Some(origin), DerivationPath::default())?; 861 | 862 | if let Secret(desc_seckey, _, _) = derived_xprv_desc_key { 863 | let desc_pubkey = desc_seckey.to_public(&secp)?; 864 | if pretty { 865 | let table = vec![ 866 | vec!["Xpub".cell().bold(true), desc_pubkey.to_string().cell()], 867 | vec!["Xprv".cell().bold(true), xprv.to_string().cell()], 868 | ] 869 | .table() 870 | .display() 871 | .map_err(|e| Error::Generic(e.to_string()))?; 872 | Ok(format!("{table}")) 873 | } else { 874 | Ok(serde_json::to_string_pretty( 875 | &json!({"xpub": desc_pubkey.to_string(), "xprv": desc_seckey.to_string()}), 876 | )?) 877 | } 878 | } else { 879 | Err(Error::Generic("Invalid key variant".to_string())) 880 | } 881 | } 882 | } 883 | } 884 | 885 | /// Handle the miniscript compiler sub-command 886 | /// 887 | /// Compiler options are described in [`CliSubCommand::Compile`]. 888 | #[cfg(feature = "compiler")] 889 | pub(crate) fn handle_compile_subcommand( 890 | _network: Network, 891 | policy: String, 892 | script_type: String, 893 | pretty: bool, 894 | ) -> Result { 895 | let policy = Concrete::::from_str(policy.as_str())?; 896 | let legacy_policy: Miniscript = policy 897 | .compile() 898 | .map_err(|e| Error::Generic(e.to_string()))?; 899 | let segwit_policy: Miniscript = policy 900 | .compile() 901 | .map_err(|e| Error::Generic(e.to_string()))?; 902 | let taproot_policy: Miniscript = policy 903 | .compile() 904 | .map_err(|e| Error::Generic(e.to_string()))?; 905 | 906 | let descriptor = match script_type.as_str() { 907 | "sh" => Descriptor::new_sh(legacy_policy), 908 | "wsh" => Descriptor::new_wsh(segwit_policy), 909 | "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy), 910 | "tr" => { 911 | // For tr descriptors, we use a well-known unspendable key (NUMS point). 912 | // This ensures the key path is effectively disabled and only script path can be used. 913 | // See https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs 914 | 915 | let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX) 916 | .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?; 917 | 918 | let tree = TapTree::Leaf(Arc::new(taproot_policy)); 919 | Descriptor::new_tr(xonly_public_key.to_string(), Some(tree)) 920 | } 921 | _ => { 922 | return Err(Error::Generic( 923 | "Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(), 924 | )); 925 | } 926 | }?; 927 | if pretty { 928 | let table = vec![vec![ 929 | "Descriptor".cell().bold(true), 930 | descriptor.to_string().cell(), 931 | ]] 932 | .table() 933 | .display() 934 | .map_err(|e| Error::Generic(e.to_string()))?; 935 | Ok(format!("{table}")) 936 | } else { 937 | Ok(serde_json::to_string_pretty( 938 | &json!({"descriptor": descriptor.to_string()}), 939 | )?) 940 | } 941 | } 942 | 943 | /// The global top level handler. 944 | pub(crate) async fn handle_command(cli_opts: CliOpts) -> Result { 945 | let network = cli_opts.network; 946 | let pretty = cli_opts.pretty; 947 | 948 | let result: Result = match cli_opts.subcommand { 949 | #[cfg(any( 950 | feature = "electrum", 951 | feature = "esplora", 952 | feature = "cbf", 953 | feature = "rpc" 954 | ))] 955 | CliSubCommand::Wallet { 956 | ref wallet_opts, 957 | subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), 958 | } => { 959 | // let network = cli_opts.network; 960 | let home_dir = prepare_home_dir(cli_opts.datadir)?; 961 | let wallet_name = &wallet_opts.wallet; 962 | let database_path = prepare_wallet_db_dir(wallet_name, &home_dir)?; 963 | 964 | #[cfg(any(feature = "sqlite", feature = "redb"))] 965 | let result = { 966 | let mut persister: Persister = match &wallet_opts.database_type { 967 | #[cfg(feature = "sqlite")] 968 | DatabaseType::Sqlite => { 969 | let db_file = database_path.join("wallet.sqlite"); 970 | let connection = Connection::open(db_file)?; 971 | log::debug!("Sqlite database opened successfully"); 972 | Persister::Connection(connection) 973 | } 974 | #[cfg(feature = "redb")] 975 | DatabaseType::Redb => { 976 | let db = Arc::new(bdk_redb::redb::Database::create( 977 | home_dir.join("wallet.redb"), 978 | )?); 979 | let store = RedbStore::new( 980 | db, 981 | wallet_name.as_deref().unwrap_or("wallet").to_string(), 982 | )?; 983 | log::debug!("Redb database opened successfully"); 984 | Persister::RedbStore(store) 985 | } 986 | }; 987 | 988 | let mut wallet = new_persisted_wallet(network, &mut persister, wallet_opts)?; 989 | let blockchain_client = new_blockchain_client(wallet_opts, &wallet, database_path)?; 990 | 991 | let result = handle_online_wallet_subcommand( 992 | &mut wallet, 993 | blockchain_client, 994 | online_subcommand, 995 | ) 996 | .await?; 997 | wallet.persist(&mut persister)?; 998 | result 999 | }; 1000 | #[cfg(not(any(feature = "sqlite", feature = "redb")))] 1001 | let result = { 1002 | let wallet = new_wallet(network, wallet_opts)?; 1003 | let blockchain_client = 1004 | crate::utils::new_blockchain_client(wallet_opts, &wallet, database_path)?; 1005 | let mut wallet = new_wallet(network, wallet_opts)?; 1006 | handle_online_wallet_subcommand(&mut wallet, blockchain_client, online_subcommand) 1007 | .await? 1008 | }; 1009 | Ok(result) 1010 | } 1011 | CliSubCommand::Wallet { 1012 | ref wallet_opts, 1013 | subcommand: WalletSubCommand::OfflineWalletSubCommand(ref offline_subcommand), 1014 | } => { 1015 | let network = cli_opts.network; 1016 | #[cfg(any(feature = "sqlite", feature = "redb"))] 1017 | let result = { 1018 | let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; 1019 | let wallet_name = &wallet_opts.wallet; 1020 | let mut persister: Persister = match &wallet_opts.database_type { 1021 | #[cfg(feature = "sqlite")] 1022 | DatabaseType::Sqlite => { 1023 | let database_path = prepare_wallet_db_dir(wallet_name, &home_dir)?; 1024 | let db_file = database_path.join("wallet.sqlite"); 1025 | let connection = Connection::open(db_file)?; 1026 | log::debug!("Sqlite database opened successfully"); 1027 | Persister::Connection(connection) 1028 | } 1029 | #[cfg(feature = "redb")] 1030 | DatabaseType::Redb => { 1031 | let db = Arc::new(bdk_redb::redb::Database::create( 1032 | home_dir.join("wallet.redb"), 1033 | )?); 1034 | let store = RedbStore::new( 1035 | db, 1036 | wallet_name.as_deref().unwrap_or("wallet").to_string(), 1037 | )?; 1038 | log::debug!("Redb database opened successfully"); 1039 | Persister::RedbStore(store) 1040 | } 1041 | }; 1042 | 1043 | let mut wallet = new_persisted_wallet(network, &mut persister, wallet_opts)?; 1044 | 1045 | let result = handle_offline_wallet_subcommand( 1046 | &mut wallet, 1047 | wallet_opts, 1048 | &cli_opts, 1049 | offline_subcommand.clone(), 1050 | )?; 1051 | wallet.persist(&mut persister)?; 1052 | result 1053 | }; 1054 | #[cfg(not(any(feature = "sqlite", feature = "redb")))] 1055 | let result = { 1056 | let mut wallet = new_wallet(network, wallet_opts)?; 1057 | handle_offline_wallet_subcommand( 1058 | &mut wallet, 1059 | wallet_opts, 1060 | &cli_opts, 1061 | offline_subcommand.clone(), 1062 | )? 1063 | }; 1064 | Ok(result) 1065 | } 1066 | CliSubCommand::Key { 1067 | subcommand: key_subcommand, 1068 | } => { 1069 | let result = handle_key_subcommand(network, key_subcommand, pretty)?; 1070 | Ok(result) 1071 | } 1072 | #[cfg(feature = "compiler")] 1073 | CliSubCommand::Compile { 1074 | policy, 1075 | script_type, 1076 | } => { 1077 | let result = handle_compile_subcommand(network, policy, script_type, pretty)?; 1078 | Ok(result) 1079 | } 1080 | #[cfg(feature = "repl")] 1081 | CliSubCommand::Repl { ref wallet_opts } => { 1082 | let network = cli_opts.network; 1083 | #[cfg(any(feature = "sqlite", feature = "redb"))] 1084 | let (mut wallet, mut persister) = { 1085 | let wallet_name = &wallet_opts.wallet; 1086 | 1087 | let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; 1088 | 1089 | let mut persister: Persister = match &wallet_opts.database_type { 1090 | #[cfg(feature = "sqlite")] 1091 | DatabaseType::Sqlite => { 1092 | let database_path = prepare_wallet_db_dir(wallet_name, &home_dir)?; 1093 | let db_file = database_path.join("wallet.sqlite"); 1094 | let connection = Connection::open(db_file)?; 1095 | log::debug!("Sqlite database opened successfully"); 1096 | Persister::Connection(connection) 1097 | } 1098 | #[cfg(feature = "redb")] 1099 | DatabaseType::Redb => { 1100 | let db = Arc::new(bdk_redb::redb::Database::create( 1101 | home_dir.join("wallet.redb"), 1102 | )?); 1103 | let store = RedbStore::new( 1104 | db, 1105 | wallet_name.as_deref().unwrap_or("wallet").to_string(), 1106 | )?; 1107 | log::debug!("Redb database opened successfully"); 1108 | Persister::RedbStore(store) 1109 | } 1110 | }; 1111 | let wallet = new_persisted_wallet(network, &mut persister, wallet_opts)?; 1112 | (wallet, persister) 1113 | }; 1114 | #[cfg(not(any(feature = "sqlite", feature = "redb")))] 1115 | let mut wallet = new_wallet(network, &wallet_opts)?; 1116 | let home_dir = prepare_home_dir(cli_opts.datadir.clone())?; 1117 | let database_path = prepare_wallet_db_dir(&wallet_opts.wallet, &home_dir)?; 1118 | 1119 | loop { 1120 | let line = readline()?; 1121 | let line = line.trim(); 1122 | if line.is_empty() { 1123 | continue; 1124 | } 1125 | 1126 | let result = respond( 1127 | network, 1128 | &mut wallet, 1129 | wallet_opts, 1130 | line, 1131 | database_path.clone(), 1132 | &cli_opts, 1133 | ) 1134 | .await; 1135 | #[cfg(any(feature = "sqlite", feature = "redb"))] 1136 | wallet.persist(&mut persister)?; 1137 | 1138 | match result { 1139 | Ok(quit) => { 1140 | if quit { 1141 | break; 1142 | } 1143 | } 1144 | Err(err) => { 1145 | writeln!(std::io::stdout(), "{err}") 1146 | .map_err(|e| Error::Generic(e.to_string()))?; 1147 | std::io::stdout() 1148 | .flush() 1149 | .map_err(|e| Error::Generic(e.to_string()))?; 1150 | } 1151 | } 1152 | } 1153 | Ok("".to_string()) 1154 | } 1155 | CliSubCommand::Descriptor { desc_type, key } => { 1156 | let descriptor = handle_descriptor_command(cli_opts.network, desc_type, key, pretty)?; 1157 | Ok(descriptor) 1158 | } 1159 | }; 1160 | result 1161 | } 1162 | 1163 | #[cfg(feature = "repl")] 1164 | async fn respond( 1165 | network: Network, 1166 | wallet: &mut Wallet, 1167 | wallet_opts: &WalletOpts, 1168 | line: &str, 1169 | _datadir: std::path::PathBuf, 1170 | cli_opts: &CliOpts, 1171 | ) -> Result { 1172 | use clap::Parser; 1173 | 1174 | let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; 1175 | let repl_subcommand = ReplSubCommand::try_parse_from(args).map_err(|e| e.to_string())?; 1176 | let response = match repl_subcommand { 1177 | #[cfg(any( 1178 | feature = "electrum", 1179 | feature = "esplora", 1180 | feature = "cbf", 1181 | feature = "rpc" 1182 | ))] 1183 | ReplSubCommand::Wallet { 1184 | subcommand: WalletSubCommand::OnlineWalletSubCommand(online_subcommand), 1185 | } => { 1186 | let blockchain = 1187 | new_blockchain_client(wallet_opts, wallet, _datadir).map_err(|e| e.to_string())?; 1188 | let value = handle_online_wallet_subcommand(wallet, blockchain, online_subcommand) 1189 | .await 1190 | .map_err(|e| e.to_string())?; 1191 | Some(value) 1192 | } 1193 | ReplSubCommand::Wallet { 1194 | subcommand: WalletSubCommand::OfflineWalletSubCommand(offline_subcommand), 1195 | } => { 1196 | let value = 1197 | handle_offline_wallet_subcommand(wallet, wallet_opts, cli_opts, offline_subcommand) 1198 | .map_err(|e| e.to_string())?; 1199 | Some(value) 1200 | } 1201 | ReplSubCommand::Key { subcommand } => { 1202 | let value = handle_key_subcommand(network, subcommand, cli_opts.pretty) 1203 | .map_err(|e| e.to_string())?; 1204 | Some(value) 1205 | } 1206 | ReplSubCommand::Descriptor { desc_type, key } => { 1207 | let value = handle_descriptor_command(network, desc_type, key, cli_opts.pretty) 1208 | .map_err(|e| e.to_string())?; 1209 | Some(value) 1210 | } 1211 | ReplSubCommand::Exit => None, 1212 | }; 1213 | if let Some(value) = response { 1214 | writeln!(std::io::stdout(), "{value}").map_err(|e| e.to_string())?; 1215 | std::io::stdout().flush().map_err(|e| e.to_string())?; 1216 | Ok(false) 1217 | } else { 1218 | writeln!(std::io::stdout(), "Exiting...").map_err(|e| e.to_string())?; 1219 | std::io::stdout().flush().map_err(|e| e.to_string())?; 1220 | Ok(true) 1221 | } 1222 | } 1223 | 1224 | #[cfg(any( 1225 | feature = "electrum", 1226 | feature = "esplora", 1227 | feature = "cbf", 1228 | feature = "rpc" 1229 | ))] 1230 | /// Syncs a given wallet using the blockchain client. 1231 | pub async fn sync_wallet(client: BlockchainClient, wallet: &mut Wallet) -> Result<(), Error> { 1232 | #[cfg(any(feature = "electrum", feature = "esplora"))] 1233 | let request = wallet 1234 | .start_sync_with_revealed_spks() 1235 | .inspect(|item, progress| { 1236 | let pc = (100 * progress.consumed()) as f32 / progress.total() as f32; 1237 | eprintln!("[ SCANNING {pc:03.0}% ] {item}"); 1238 | }); 1239 | match client { 1240 | #[cfg(feature = "electrum")] 1241 | Electrum { client, batch_size } => { 1242 | // Populate the electrum client's transaction cache so it doesn't re-download transaction we 1243 | // already have. 1244 | client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx)); 1245 | 1246 | let update = client.sync(request, batch_size, false)?; 1247 | wallet 1248 | .apply_update(update) 1249 | .map_err(|e| Error::Generic(e.to_string())) 1250 | } 1251 | #[cfg(feature = "esplora")] 1252 | Esplora { 1253 | client, 1254 | parallel_requests, 1255 | } => { 1256 | let update = client 1257 | .sync(request, parallel_requests) 1258 | .await 1259 | .map_err(|e| *e)?; 1260 | wallet 1261 | .apply_update(update) 1262 | .map_err(|e| Error::Generic(e.to_string())) 1263 | } 1264 | #[cfg(feature = "rpc")] 1265 | RpcClient { client } => { 1266 | let blockchain_info = client.get_blockchain_info()?; 1267 | let wallet_cp = wallet.latest_checkpoint(); 1268 | 1269 | // reload the last 200 blocks in case of a reorg 1270 | let emitter_height = wallet_cp.height().saturating_sub(200); 1271 | let mut emitter = Emitter::new( 1272 | &*client, 1273 | wallet_cp, 1274 | emitter_height, 1275 | wallet 1276 | .tx_graph() 1277 | .list_canonical_txs( 1278 | wallet.local_chain(), 1279 | wallet.local_chain().tip().block_id(), 1280 | CanonicalizationParams::default(), 1281 | ) 1282 | .filter(|tx| tx.chain_position.is_unconfirmed()), 1283 | ); 1284 | 1285 | while let Some(block_event) = emitter.next_block()? { 1286 | if block_event.block_height() % 10_000 == 0 { 1287 | let percent_done = f64::from(block_event.block_height()) 1288 | / f64::from(blockchain_info.headers as u32) 1289 | * 100f64; 1290 | println!( 1291 | "Applying block at height: {}, {:.2}% done.", 1292 | block_event.block_height(), 1293 | percent_done 1294 | ); 1295 | } 1296 | 1297 | wallet.apply_block_connected_to( 1298 | &block_event.block, 1299 | block_event.block_height(), 1300 | block_event.connected_to(), 1301 | )?; 1302 | } 1303 | 1304 | let mempool_txs = emitter.mempool()?; 1305 | wallet.apply_unconfirmed_txs(mempool_txs.update); 1306 | Ok(()) 1307 | } 1308 | #[cfg(feature = "cbf")] 1309 | KyotoClient { client } => sync_kyoto_client(wallet, client) 1310 | .await 1311 | .map_err(|e| Error::Generic(e.to_string())), 1312 | } 1313 | } 1314 | 1315 | #[cfg(any( 1316 | feature = "electrum", 1317 | feature = "esplora", 1318 | feature = "cbf", 1319 | feature = "rpc" 1320 | ))] 1321 | /// Broadcasts a given transaction using the blockchain client. 1322 | pub async fn broadcast_transaction( 1323 | client: BlockchainClient, 1324 | tx: Transaction, 1325 | ) -> Result { 1326 | match client { 1327 | #[cfg(feature = "electrum")] 1328 | Electrum { 1329 | client, 1330 | batch_size: _, 1331 | } => client 1332 | .transaction_broadcast(&tx) 1333 | .map_err(|e| Error::Generic(e.to_string())), 1334 | #[cfg(feature = "esplora")] 1335 | Esplora { 1336 | client, 1337 | parallel_requests: _, 1338 | } => client 1339 | .broadcast(&tx) 1340 | .await 1341 | .map(|()| tx.compute_txid()) 1342 | .map_err(|e| Error::Generic(e.to_string())), 1343 | #[cfg(feature = "rpc")] 1344 | RpcClient { client } => client 1345 | .send_raw_transaction(&tx) 1346 | .map_err(|e| Error::Generic(e.to_string())), 1347 | 1348 | #[cfg(feature = "cbf")] 1349 | KyotoClient { client } => { 1350 | let LightClient { 1351 | requester, 1352 | mut info_subscriber, 1353 | mut warning_subscriber, 1354 | update_subscriber: _, 1355 | node, 1356 | } = *client; 1357 | 1358 | let subscriber = tracing_subscriber::FmtSubscriber::new(); 1359 | tracing::subscriber::set_global_default(subscriber) 1360 | .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {e}")))?; 1361 | 1362 | tokio::task::spawn(async move { node.run().await }); 1363 | tokio::task::spawn(async move { 1364 | select! { 1365 | info = info_subscriber.recv() => { 1366 | if let Some(info) = info { 1367 | tracing::info!("{info}"); 1368 | } 1369 | }, 1370 | warn = warning_subscriber.recv() => { 1371 | if let Some(warn) = warn { 1372 | tracing::warn!("{warn}"); 1373 | } 1374 | } 1375 | } 1376 | }); 1377 | let txid = tx.compute_txid(); 1378 | let wtxid = requester.broadcast_random(tx.clone()).await.map_err(|_| { 1379 | tracing::warn!("Broadcast was unsuccessful"); 1380 | Error::Generic("Transaction broadcast timed out after 30 seconds".into()) 1381 | })?; 1382 | tracing::info!("Successfully broadcast WTXID: {wtxid}"); 1383 | Ok(txid) 1384 | } 1385 | } 1386 | } 1387 | 1388 | #[cfg(feature = "repl")] 1389 | fn readline() -> Result { 1390 | write!(std::io::stdout(), "> ").map_err(|e| Error::Generic(e.to_string()))?; 1391 | std::io::stdout() 1392 | .flush() 1393 | .map_err(|e| Error::Generic(e.to_string()))?; 1394 | let mut buffer = String::new(); 1395 | std::io::stdin() 1396 | .read_line(&mut buffer) 1397 | .map_err(|e| Error::Generic(e.to_string()))?; 1398 | Ok(buffer) 1399 | } 1400 | 1401 | /// Handle the descriptor command 1402 | pub fn handle_descriptor_command( 1403 | network: Network, 1404 | desc_type: String, 1405 | key: Option, 1406 | pretty: bool, 1407 | ) -> Result { 1408 | let result = match key { 1409 | Some(key) => { 1410 | if is_mnemonic(&key) { 1411 | // User provided mnemonic 1412 | generate_descriptor_from_mnemonic(&key, network, &desc_type) 1413 | } else { 1414 | // User provided xprv/xpub 1415 | generate_descriptors(&desc_type, &key, network) 1416 | } 1417 | } 1418 | // Generate new mnemonic and descriptors 1419 | None => generate_descriptor_with_mnemonic(network, &desc_type), 1420 | }?; 1421 | format_descriptor_output(&result, pretty) 1422 | } 1423 | 1424 | #[cfg(any( 1425 | feature = "electrum", 1426 | feature = "esplora", 1427 | feature = "cbf", 1428 | feature = "rpc" 1429 | ))] 1430 | #[cfg(test)] 1431 | mod test { 1432 | #[cfg(any( 1433 | feature = "electrum", 1434 | feature = "esplora", 1435 | feature = "cbf", 1436 | feature = "rpc" 1437 | ))] 1438 | #[test] 1439 | fn test_psbt_is_final() { 1440 | use super::is_final; 1441 | use bdk_wallet::bitcoin::Psbt; 1442 | use std::str::FromStr; 1443 | 1444 | let unsigned_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); 1445 | assert!(is_final(&unsigned_psbt).is_err()); 1446 | 1447 | let part_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOyICA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDSDBFAiEAnNPpu6wNX2HXYz8s2q5nXug4cWfvCGD3SSH2CNKm+yECIEQO7/URhUPsGoknMTE+GrYJf9Wxqn9QsuN9FGj32cQpAQEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); 1448 | assert!(is_final(&part_signed_psbt).is_err()); 1449 | 1450 | let full_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEBBwABCNsEAEgwRQIhAJzT6busDV9h12M/LNquZ17oOHFn7whg90kh9gjSpvshAiBEDu/1EYVD7BqJJzExPhq2CX/Vsap/ULLjfRRo99nEKQFHMEQCIGoFCvJ2zPB7PCpznh4+1jsY03kMie49KPoPDdr7/T9TAiB3jV7wzR9BH11FSbi+8U8gSX95PrBlnp1lOBgTUIUw3QFHUiED8lXZT/Sldb6I/j1ByxiKUS+RkR3imGYMzydXzAL4x4MhAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJUq4AACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap(); 1451 | assert!(is_final(&full_signed_psbt).is_ok()); 1452 | } 1453 | 1454 | #[cfg(feature = "compiler")] 1455 | #[test] 1456 | fn test_compile_taproot() { 1457 | use super::{NUMS_UNSPENDABLE_KEY_HEX, handle_compile_subcommand}; 1458 | use bdk_wallet::bitcoin::Network; 1459 | 1460 | // Expected taproot descriptors with checksums (using NUMS key from constant) 1461 | let expected_pk_a = format!("tr({},pk(A))#a2mlskt0", NUMS_UNSPENDABLE_KEY_HEX); 1462 | let expected_and_ab = format!( 1463 | "tr({},and_v(v:pk(A),pk(B)))#sfplm6kv", 1464 | NUMS_UNSPENDABLE_KEY_HEX 1465 | ); 1466 | 1467 | // Test simple pk policy compilation to taproot 1468 | let result = handle_compile_subcommand( 1469 | Network::Testnet, 1470 | "pk(A)".to_string(), 1471 | "tr".to_string(), 1472 | false, 1473 | ); 1474 | assert!(result.is_ok()); 1475 | let json_string = result.unwrap(); 1476 | let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); 1477 | let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); 1478 | assert_eq!(descriptor, expected_pk_a); 1479 | 1480 | // Test more complex policy 1481 | let result = handle_compile_subcommand( 1482 | Network::Testnet, 1483 | "and(pk(A),pk(B))".to_string(), 1484 | "tr".to_string(), 1485 | false, 1486 | ); 1487 | assert!(result.is_ok()); 1488 | let json_string = result.unwrap(); 1489 | let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap(); 1490 | let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap(); 1491 | assert_eq!(descriptor, expected_and_ab); 1492 | } 1493 | 1494 | #[cfg(feature = "compiler")] 1495 | #[test] 1496 | fn test_compile_invalid_cases() { 1497 | use super::handle_compile_subcommand; 1498 | use bdk_wallet::bitcoin::Network; 1499 | 1500 | // Test invalid policy syntax 1501 | let result = handle_compile_subcommand( 1502 | Network::Testnet, 1503 | "invalid_policy".to_string(), 1504 | "tr".to_string(), 1505 | false, 1506 | ); 1507 | assert!(result.is_err()); 1508 | 1509 | // Test invalid script type 1510 | let result = handle_compile_subcommand( 1511 | Network::Testnet, 1512 | "pk(A)".to_string(), 1513 | "invalid_type".to_string(), 1514 | false, 1515 | ); 1516 | assert!(result.is_err()); 1517 | 1518 | // Test empty policy 1519 | let result = 1520 | handle_compile_subcommand(Network::Testnet, "".to_string(), "tr".to_string(), false); 1521 | assert!(result.is_err()); 1522 | 1523 | // Test malformed policy with unmatched parentheses 1524 | let result = handle_compile_subcommand( 1525 | Network::Testnet, 1526 | "pk(A".to_string(), 1527 | "tr".to_string(), 1528 | false, 1529 | ); 1530 | assert!(result.is_err()); 1531 | 1532 | // Test policy with unknown function 1533 | let result = handle_compile_subcommand( 1534 | Network::Testnet, 1535 | "unknown_func(A)".to_string(), 1536 | "tr".to_string(), 1537 | false, 1538 | ); 1539 | assert!(result.is_err()); 1540 | } 1541 | } 1542 | --------------------------------------------------------------------------------