├── .cargo └── mutants.toml ├── .codespellrc ├── .dockerignore ├── .envrc ├── .github ├── ISSUE_TEMPLATE │ └── minor-release.md └── workflows │ ├── cron-weekly-mutants.yml │ ├── python.yml │ └── rust.yml ├── .gitignore ├── Cargo-minimal.lock ├── Cargo-recent.lock ├── Cargo.toml ├── README.md ├── contrib ├── coverage.sh ├── lint.sh ├── test.sh ├── test_local.sh └── update-lock-files.sh ├── flake.lock ├── flake.nix ├── payjoin-cli ├── CHANGELOG.md ├── Cargo.toml ├── Dockerfile ├── README.md ├── contrib │ ├── lint.sh │ └── test.sh ├── example.config.toml ├── src │ ├── app │ │ ├── config.rs │ │ ├── mod.rs │ │ ├── v1.rs │ │ ├── v2 │ │ │ ├── mod.rs │ │ │ └── ohttp.rs │ │ └── wallet.rs │ ├── cli │ │ └── mod.rs │ ├── db │ │ ├── error.rs │ │ ├── mod.rs │ │ └── v2.rs │ └── main.rs └── tests │ └── e2e.rs ├── payjoin-directory ├── CHANGELOG.md ├── Cargo.toml ├── Dockerfile ├── README.md ├── contrib │ └── test.sh ├── docker-compose.yml └── src │ ├── db.rs │ ├── key_config.rs │ ├── lib.rs │ └── main.rs ├── payjoin-ffi ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── LICENSE.md ├── README.md ├── build.rs ├── contrib │ └── test.sh ├── python │ ├── .gitignore │ ├── CHANGELOG.md │ ├── MANIFEST.in │ ├── README.md │ ├── pyproject.toml │ ├── requirements-dev.txt │ ├── requirements.txt │ ├── scripts │ │ ├── bindgen_generate.sh │ │ ├── generate_linux.sh │ │ └── generate_macos.sh │ ├── setup.py │ ├── src │ │ └── payjoin │ │ │ └── __init__.py │ └── test │ │ ├── __init__.py │ │ ├── test_payjoin_integration_test.py │ │ └── test_payjoin_unit_test.py ├── src │ ├── bitcoin_ffi.rs │ ├── error.rs │ ├── io.rs │ ├── lib.rs │ ├── ohttp.rs │ ├── output_substitution.rs │ ├── payjoin_ffi.udl │ ├── receive │ │ ├── error.rs │ │ ├── mod.rs │ │ └── uni.rs │ ├── request.rs │ ├── send │ │ ├── error.rs │ │ ├── mod.rs │ │ └── uni.rs │ ├── test_utils.rs │ └── uri │ │ ├── error.rs │ │ └── mod.rs ├── tests │ └── bdk_integration_test.rs ├── uniffi-bindgen.rs └── uniffi.toml ├── payjoin-test-utils ├── CHANGELOG.md ├── Cargo.toml ├── README.md └── src │ └── lib.rs ├── payjoin ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── contrib │ ├── lint.sh │ └── test.sh ├── src │ ├── bech32.rs │ ├── core │ │ ├── error.rs │ │ ├── mod.rs │ │ └── version.rs │ ├── directory.rs │ ├── error_codes.rs │ ├── hpke.rs │ ├── into_url.rs │ ├── io.rs │ ├── lib.rs │ ├── ohttp.rs │ ├── output_substitution.rs │ ├── persist.rs │ ├── psbt │ │ ├── merge.rs │ │ └── mod.rs │ ├── receive │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── multiparty │ │ │ ├── error.rs │ │ │ └── mod.rs │ │ ├── optional_parameters.rs │ │ ├── v1 │ │ │ ├── exclusive │ │ │ │ ├── error.rs │ │ │ │ └── mod.rs │ │ │ └── mod.rs │ │ └── v2 │ │ │ ├── error.rs │ │ │ ├── mod.rs │ │ │ └── persist.rs │ ├── request.rs │ ├── send │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── multiparty │ │ │ ├── error.rs │ │ │ ├── mod.rs │ │ │ └── persist.rs │ │ ├── v1.rs │ │ └── v2 │ │ │ ├── error.rs │ │ │ ├── mod.rs │ │ │ └── persist.rs │ └── uri │ │ ├── error.rs │ │ ├── mod.rs │ │ └── url_ext.rs └── tests │ └── integration.rs ├── rustfmt.toml └── static └── monad.svg /.cargo/mutants.toml: -------------------------------------------------------------------------------- 1 | additional_cargo_args = ["--all-features"] 2 | examine_globs = ["payjoin/src/uri/*.rs", "payjoin/src/receive/**/*.rs", "payjoin/src/send/mod.rs"] 3 | exclude_globs = [] 4 | exclude_re = [ 5 | "impl Debug", 6 | "impl Display", 7 | "deserialize", 8 | "Iterator", 9 | ".*Error", 10 | 11 | # ---------------------Crate-specific exculsions--------------------- 12 | # Receive 13 | # src/receive/v1/mod.rs 14 | "interleave_shuffle", # Replacing index += 1 with index *= 1 in a loop causes a timeout due to an infinite loop 15 | ] 16 | -------------------------------------------------------------------------------- /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = .git,target,Cargo.toml,Cargo.lock,Cargo-minimal.lock,Cargo-recent.lock 3 | ignore-words-list = crate,ser 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Target directory (compiled binaries) 2 | target/ 3 | 4 | # Rust's incremental compilation and debug info 5 | **/incremental/ 6 | **/debug/ 7 | **/release/ 8 | **/deps/ 9 | **/examples/ 10 | **/bench/ 11 | 12 | # Hidden system files 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # IDE & Editor settings 17 | .idea/ 18 | .vscode/ 19 | *.iml 20 | 21 | # Logs and temporary files 22 | logs/ 23 | *.log 24 | *.tmp 25 | *.swp 26 | *.swo 27 | *.swn 28 | 29 | # Git files 30 | .git/ 31 | .gitignore 32 | 33 | # Docker files (optional) 34 | .dockerignore 35 | Dockerfile 36 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake . 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/minor-release.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Minor Release 3 | about: Checklist for releasing a new minor version bump 4 | title: Release MAJOR.MINOR+1.0 5 | labels: '' 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-CRATE-MAJOR-MINOR+1`, eg. `bump-CRATE-0-22`. 35 | - [ ] Bump the `bump-CRATE-MAJOR-MINOR+1` branch to the next development MINOR+1 version. 36 | - Change the `Cargo.toml` version value to `MAJOR.MINOR+1.0` for all crates in the workspace. 37 | - Run `contrib/update-lock-files.sh ` to apply upgrades to the Cargo lock files. 38 | - Update the `CHANGELOG.md` file. 39 | - The commit message should be "Bump CRATE version to MAJOR.MINOR+1.0". 40 | - [ ] Create PR for the `bump-CRATE-MAJOR-MINOR+1` branch to `master`. 41 | - Title PR "Bump CRATE version to MAJOR.MINOR+1.0". 42 | - [ ] Merge the `bump-CRATE-MAJOR-MINOR+1` branch to `master`. 43 | 44 | If any issues need to be fixed before the *MAJOR.MINOR+1.0* version is released: 45 | 46 | - [ ] Merge fix PRs to the `master` branch. 47 | - [ ] Git cherry-pick fix commits to the `bump-CRATE-MAJOR.MINOR+1` branch. 48 | - [ ] Verify fixes in `bump-CRATE-MAJOR.MINOR+1` branch. 49 | 50 | #### On the day of the release 51 | 52 | Tag and publish new release: 53 | 54 | - [ ] Add a tag to the `HEAD` commit in the `master` branch. 55 | - The tag name should be `CRATE-MAJOR.MINOR+1.0` 56 | - The first line of the tag message should be "Release CRATE-MAJOR.MINOR+1.0". 57 | - In the body of the tag message put a copy of the **Summary** and **Changelog** for the release. 58 | - Make sure the tag is signed, for extra safety use the explicit `--sign` flag. 59 | - [ ] Wait for the CI to finish one last time. 60 | - [ ] Build the docs locally to ensure they are building correctly. 61 | - [ ] Push the new tag to the `payjoin/rust-payjoin` repo. 62 | - [ ] Publish the crate in question crates to crates.io. 63 | - [ ] Create the release on GitHub. 64 | - Go to "tags", click on the dots on the right and select "Create Release". 65 | - Set the title to `Release CRATE-MAJOR.MINOR+1.0`. 66 | - In the release notes body put the **Summary** and **Changelog**. 67 | - Use the "+ Auto-generate release notes" button to add details from included PRs. 68 | - Until we reach a `1.0.0` release check the "Pre-release" box. 69 | - [ ] Make sure the new release shows up on [crates.io] and that the docs are built correctly on [docs.rs]. 70 | - [ ] Announce the release, using the **Summary**, on Discord, Twitter, Nostr, and stacker.news. 71 | - [ ] Celebrate 🎉 72 | 73 | [Semantic Versioning]: https://semver.org/ 74 | ["keep a changelog"]: https://keepachangelog.com/en/1.0.0/ 75 | -------------------------------------------------------------------------------- /.github/workflows/cron-weekly-mutants.yml: -------------------------------------------------------------------------------- 1 | name: Weekly cargo-mutants 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # runs weekly on Sunday at 00:00 5 | workflow_dispatch: # allows manual triggering 6 | jobs: 7 | cargo-mutants: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: taiki-e/install-action@v2 14 | with: 15 | tool: cargo-mutants 16 | - run: cargo mutants --in-place --no-shuffle 17 | - uses: actions/upload-artifact@v4 18 | if: always() 19 | with: 20 | name: mutants.out 21 | path: mutants.out 22 | - name: Check for new mutants 23 | if: always() 24 | run: | 25 | if [ -s mutants.out/missed.txt ]; then 26 | echo "New missed mutants found" 27 | gh issue create \ 28 | --title "New Mutants Found" \ 29 | --body "$(cat <> $GITHUB_ENV 36 | else 37 | echo "No new mutants found" 38 | echo "create_issue=false" >> $GITHUB_ENV 39 | fi 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # Copied from [bdk-ffi](https://github.com/bitcoindevkit/bdk-ffi/blob/master/.github/workflows/test-python.yaml) 2 | name: Build and Test Python 3 | on: 4 | pull_request: 5 | paths: 6 | - payjoin-ffi/** 7 | 8 | jobs: 9 | build-wheels-and-test: 10 | name: "Build and test wheels with Redis" 11 | runs-on: ubuntu-latest 12 | services: 13 | redis: 14 | image: redis:7-alpine 15 | defaults: 16 | run: 17 | working-directory: payjoin-ffi/python 18 | strategy: 19 | matrix: 20 | include: 21 | - python: "3.9" 22 | - python: "3.10" 23 | - python: "3.11" 24 | - python: "3.12" 25 | - python: "3.13" 26 | steps: 27 | - name: "Checkout" 28 | uses: actions/checkout@v4 29 | 30 | - name: "Install Rust 1.78.0" 31 | uses: actions-rs/toolchain@v1 32 | with: 33 | toolchain: 1.78.0 34 | 35 | - name: "Install Python" 36 | uses: actions/setup-python@v4 37 | with: 38 | python-version: ${{ matrix.python }} 39 | 40 | - name: "Install build dependencies" 41 | run: | 42 | sudo apt update 43 | sudo apt install -y build-essential python3-dev 44 | 45 | - name: "Use cache" 46 | uses: Swatinem/rust-cache@v2 47 | 48 | - name: "Generate payjoin-ffi.py and binaries" 49 | run: | 50 | PYBIN=$(dirname $(which python)) 51 | PYBIN="$PYBIN" bash ./scripts/generate_linux.sh 52 | 53 | - name: "Build wheel" 54 | run: python setup.py bdist_wheel --verbose 55 | 56 | - name: "Install wheel" 57 | run: pip install ./dist/*.whl 58 | 59 | - name: "Run tests" 60 | env: 61 | REDIS_URL: redis://localhost:6379 62 | run: python -m unittest -v 63 | 64 | build-macos: 65 | name: "Build and test macOS" 66 | runs-on: macos-13 67 | defaults: 68 | run: 69 | working-directory: payjoin-ffi/python 70 | strategy: 71 | matrix: 72 | python: 73 | - "3.12" 74 | steps: 75 | - name: "Checkout" 76 | uses: actions/checkout@v4 77 | with: 78 | submodules: true 79 | 80 | - name: "Install Rust 1.78.0" 81 | uses: actions-rs/toolchain@v1 82 | with: 83 | toolchain: 1.78.0 84 | 85 | - name: "Install Python" 86 | uses: actions/setup-python@v4 87 | with: 88 | python-version: ${{ matrix.python }} 89 | 90 | - name: Setup Docker on macOS 91 | uses: douglascamata/setup-docker-macos-action@v1.0.0 92 | 93 | - name: "Use cache" 94 | uses: Swatinem/rust-cache@v2 95 | 96 | - name: "Generate payjoin-ffi.py and binaries" 97 | run: bash ./scripts/generate_macos.sh 98 | 99 | - name: "Build wheel" 100 | run: python3 setup.py bdist_wheel --verbose 101 | 102 | - name: "Install wheel" 103 | run: pip3 install ./dist/*.whl 104 | 105 | - name: "Run tests" 106 | env: 107 | REDIS_URL: redis://localhost:6379 108 | run: python3 -m unittest -v 109 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | Test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | rust: 12 | - 1.63.0 # MSRV 13 | - stable 14 | - nightly 15 | steps: 16 | - name: "Checkout repo" 17 | uses: actions/checkout@v4 18 | - name: "Install ${{ matrix.rust }} toolchain" 19 | # https://github.com/dtolnay/rust-toolchain?tab=readme-ov-file#inputs 20 | uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: ${{ matrix.rust }} 23 | - name: "Use cache" 24 | uses: Swatinem/rust-cache@v2 25 | - name: Run tests 26 | run: RUST_LOG=debug bash contrib/test.sh 27 | 28 | Format: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: "Checkout repo" 32 | uses: actions/checkout@v4 33 | - name: "Install nightly toolchain" 34 | uses: dtolnay/rust-toolchain@nightly 35 | - name: "Use cache" 36 | uses: Swatinem/rust-cache@v2 37 | - run: rustup component add rustfmt --toolchain nightly-x86_64-unknown-linux-gnu 38 | - name: "Run formatting check" 39 | run: cargo fmt --all -- --check 40 | 41 | Lint: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: "Checkout repo" 45 | uses: actions/checkout@v4 46 | - name: "Install nightly toolchain" 47 | uses: dtolnay/rust-toolchain@nightly 48 | - name: "Use cache" 49 | uses: Swatinem/rust-cache@v2 50 | - name: "Install clippy" 51 | run: rustup component add clippy --toolchain nightly-x86_64-unknown-linux-gnu 52 | - name: "Run code linting" 53 | run: bash contrib/lint.sh 54 | - name: "Run documentation linting" 55 | run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features --document-private-items 56 | 57 | Coverage: 58 | name: Code coverage 59 | runs-on: ubuntu-latest 60 | strategy: 61 | fail-fast: false 62 | steps: 63 | - name: "Checkout repo" 64 | uses: actions/checkout@v4 65 | - name: "Install toolchain" 66 | # https://github.com/dtolnay/rust-toolchain 67 | uses: dtolnay/rust-toolchain@stable 68 | - name: "Use cache" 69 | uses: Swatinem/rust-cache@v2 70 | - name: "Install cargo-llvm-cov" 71 | uses: taiki-e/install-action@cargo-llvm-cov 72 | - name: "Generate code coverage for tests" 73 | run: bash contrib/coverage.sh 74 | - name: "Upload report to coveralls" 75 | uses: coverallsapp/github-action@v2 76 | 77 | CodeSpell: 78 | name: Code spell check 79 | runs-on: ubuntu-latest 80 | steps: 81 | - uses: actions/checkout@v4 82 | - uses: codespell-project/actions-codespell@v2 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | *config.toml 3 | !example.config.toml 4 | *payjoin.sled 5 | Cargo.lock 6 | .vscode 7 | mutants.out* 8 | *.ikm 9 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["payjoin", "payjoin-cli", "payjoin-directory", "payjoin-test-utils"] 3 | resolver = "2" 4 | exclude = ["payjoin-ffi"] 5 | 6 | [patch.crates-io.payjoin] 7 | path = "payjoin" 8 | 9 | [patch.crates-io.payjoin-directory] 10 | path = "payjoin-directory" 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Rust-Payjoin

3 | 4 | 5 | 6 |

7 | Supercharged payment batching to save fees and preserve privacy 8 |

9 | 10 |

11 | Crates 12 | Crates 13 | CI Status 14 | 15 | Rustc Version 1.63.0+ 16 |

17 | 18 |

19 | Project Homepage 20 |

21 |
22 | 23 | ## About 24 | 25 | ### `payjoin` 26 | 27 | The Payjoin Dev Kit `payjoin` library implements both [BIP 78 Payjoin V1](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) and [BIP 77 Payjoin V2](https://github.com/bitcoin/bips/blob/master/bip-0077.md). 28 | 29 | ### `payjoin-cli` 30 | 31 | The [`payjoin-cli`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) crate performs no-frills Payjoin as a reference implementation using Bitcoin Core wallet. 32 | 33 | ### `payjoin-directory` 34 | 35 | The [`payjoin-directory`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-directory) crate implements the Payjoin Directory store-and-forward server required for Payjoin V2's asynchronous operation. 36 | 37 | ### `payjoin-test-utils` 38 | 39 | The [`payjoin-test-utils`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-test-utils) crate provides commonly used testing fixtures such as a local OHTTP relay and payjoin directory, bitcoind node and wallets, and official test vectors. 40 | 41 | ### `payjoin-ffi` 42 | 43 | The [`payjoin-ffi`](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-ffi) crate provides language bindings that expose the Rust-based Payjoin implementation to various programming languages. 44 | 45 | ### Disclaimer ⚠️ WIP 46 | 47 | **Use at your own risk. This crate has not yet been reviewed by independent Rust and Bitcoin security professionals.** 48 | 49 | While I don't think there is a _huge_ risk running it, be careful relying on its security for now! 50 | 51 | Seeking review of the code that verifies there is no overpayment. Contributions are welcome! 52 | 53 | ### Development status 54 | 55 | #### Sender (V1 beta, V2 alpha) 56 | 57 | - [x] Basic logic 58 | - [x] Most checks implemented 59 | - [x] Documentation 60 | - [x] Unit test with official test vectors passes 61 | - [ ] Many unit tests 62 | - [x] Fee contribution support 63 | - [x] Example client using bitcoind 64 | - [x] Tested and works with BTCPayServer 65 | - [x] Tested and works with JoinMarket 66 | - [x] Minimum fee rate enforcement 67 | - [ ] Independent review 68 | - [x] Independent testing 69 | 70 | #### Receiver (V1 beta, V2 alpha) 71 | 72 | - [x] Basic logic 73 | - [x] Most checks implemented 74 | - [x] Documentation 75 | - [x] Unit test with official test vectors passes 76 | - [ ] Many unit tests 77 | - [x] Fee contribution support 78 | - [x] Example server using bitcoind 79 | - [x] Tested and works with BTCPayServer 80 | - [x] Tested and works with WasabiWallet 81 | - [x] Tested and works with Blue Wallet 82 | - [x] Tested and works with Sparrow 83 | - [x] Tested and works with JoinMarket 84 | - [x] Minimum fee rate enforcement 85 | - [ ] Discount support 86 | - [ ] Independent review 87 | - [ ] Independent testing 88 | 89 | #### Code quality 90 | 91 | - [x] Idiomatic Rust code 92 | - [x] Newtypes 93 | - [x] Panic-free error handling 94 | - [x] No `unsafe` code or well-tested/analyzed/proven/... `unsafe` code 95 | - [x] Warning-free 96 | - [x] CI 97 | - [x] Integration tests 98 | - [ ] Fuzzing 99 | - [x] Coverage measurement 100 | 101 | ## Minimum Supported Rust Version (MSRV) 102 | 103 | The `payjoin` library and `payjoin-cli` should always compile with any combination of features on Rust **1.63.0**. 104 | 105 | To build and test with the MSRV you will need to pin the below dependency versions: 106 | 107 | ### `payjoin` 108 | 109 | ```shell 110 | cargo update -p cc --precise 1.0.105 111 | cargo update -p regex --precise 1.9.6 112 | cargo update -p reqwest --precise 0.12.4 113 | cargo update -p url --precise 2.5.0 114 | cargo update -p tokio --precise 1.38.1 115 | cargo update -p tokio-util --precise 0.7.11 116 | cargo update -p which --precise 4.4.0 117 | cargo update -p zstd-sys --precise 2.0.8+zstd.1.5.5 118 | ``` 119 | 120 | ### `payjoin-cli` 121 | 122 | ```shell 123 | cargo update -p cc --precise 1.0.105 124 | cargo update -p clap_lex --precise 0.3.0 125 | cargo update -p regex --precise 1.9.6 126 | cargo update -p reqwest --precise 0.12.4 127 | cargo update -p time@0.3.36 --precise 0.3.20 128 | cargo update -p tokio --precise 1.38.1 129 | cargo update -p tokio-util --precise 0.7.11 130 | cargo update -p url --precise 2.5.0 131 | cargo update -p which --precise 4.4.0 132 | cargo update -p zstd-sys --precise 2.0.8+zstd.1.5.5 133 | ``` 134 | 135 | ## Contributing 136 | 137 | ### Commit Messages 138 | 139 | The git repository is our source of truth for development history. Therefore the commit history is the most important communication 140 | artifact we produce. Commit messages must follow [the seven rules in this guide by cbeams](https://cbea.ms/git-commit/#seven-rules). 141 | 142 | ### Nix Development Shells 143 | 144 | Where nix is available (NixOS or 145 | [otherwise](https://determinate.systems/nix-installer/)), development shells are provided. 146 | 147 | The default shell uses rust nightly, and can be activated manually using `nix 148 | develop` in the project root, or automatically with 149 | [direnv](https://determinate.systems/posts/nix-direnv/). 150 | 151 | To use the minimal supported version, use `nix develop .#msrv`. `.#stable` is 152 | also provided. 153 | 154 | ### Testing 155 | 156 | We test a few different features combinations in CI. To run all of the combinations locally, have Docker running and run `contrib/test.sh`. 157 | 158 | If you are adding a new feature please add tests for it. 159 | 160 | ### Upgrading dependencies 161 | 162 | If your change requires a dependency to be upgraded you must please run `contrib/update-lock-files.sh` before submitting any changes. 163 | 164 | ### Code Formatting 165 | 166 | We use the nightly Rust formatter for this project. Please run `rustfmt` using the nightly toolchain before submitting any changes. 167 | 168 | ### Linting 169 | 170 | We use `clippy` for linting. Please run `contrib/lint.sh` using the nightly toolchain before submitting any changes. 171 | 172 | ## License 173 | 174 | MIT 175 | -------------------------------------------------------------------------------- /contrib/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # https://github.com/taiki-e/cargo-llvm-cov?tab=readme-ov-file#merge-coverages-generated-under-different-test-conditions 5 | cargo llvm-cov clean --workspace # remove artifacts that may affect the coverage results 6 | cargo llvm-cov --no-report --all-features 7 | cargo llvm-cov --no-report --package payjoin-cli --no-default-features --features=v1,_danger-local-https # Explicitly run payjoin-cli v1 e2e tests 8 | cargo llvm-cov report --lcov --output-path lcov.info # generate report without tests 9 | -------------------------------------------------------------------------------- /contrib/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Run clippy at top level for crates without feature-specific checks 5 | echo "Running workspace lint..." 6 | cargo clippy --all-targets --keep-going --all-features -- -D warnings 7 | 8 | # Lint independent feature sets 9 | FEATURE_CRATES="payjoin payjoin-cli" 10 | 11 | for crate in $FEATURE_CRATES; do 12 | echo "Running independent feature lints for $crate crate..." 13 | ( 14 | cd "$crate" 15 | ./contrib/lint.sh 16 | ) 17 | done 18 | -------------------------------------------------------------------------------- /contrib/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | DEPS="recent minimal" 5 | CRATES="payjoin payjoin-cli payjoin-directory payjoin-ffi" 6 | 7 | for dep in $DEPS; do 8 | cargo --version 9 | rustc --version 10 | 11 | # Some tests require certain toolchain types. 12 | export NIGHTLY=false 13 | export STABLE=true 14 | if cargo --version | grep nightly; then 15 | STABLE=false 16 | NIGHTLY=true 17 | fi 18 | if cargo --version | grep beta; then 19 | STABLE=false 20 | fi 21 | 22 | cp "Cargo-$dep.lock" Cargo.lock 23 | 24 | for crate in $CRATES; do 25 | ( 26 | cd "$crate" 27 | ./contrib/test.sh 28 | ) 29 | done 30 | done 31 | -------------------------------------------------------------------------------- /contrib/test_local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | CRATES="payjoin payjoin-cli payjoin-directory payjoin-ffi" 5 | 6 | cargo --version 7 | rustc --version 8 | 9 | for crate in $CRATES; do 10 | ( 11 | cd "$crate" 12 | ./contrib/test.sh 13 | ) 14 | done 15 | -------------------------------------------------------------------------------- /contrib/update-lock-files.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Update the minimal/recent lock file 4 | 5 | set -euo pipefail 6 | 7 | for file in Cargo-minimal.lock Cargo-recent.lock; do 8 | cp -f "$file" Cargo.lock 9 | cargo check 10 | cp -f Cargo.lock "$file" 11 | done 12 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1734808813, 6 | "narHash": "sha256-3aH/0Y6ajIlfy7j52FGZ+s4icVX0oHhqBzRdlOeztqg=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "72e2d02dbac80c8c86bf6bf3e785536acf8ee926", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-utils": { 19 | "inputs": { 20 | "systems": "systems" 21 | }, 22 | "locked": { 23 | "lastModified": 1731533236, 24 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 25 | "owner": "numtide", 26 | "repo": "flake-utils", 27 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "numtide", 32 | "repo": "flake-utils", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs": { 37 | "locked": { 38 | "lastModified": 1735471104, 39 | "narHash": "sha256-0q9NGQySwDQc7RhAV2ukfnu7Gxa5/ybJ2ANT8DQrQrs=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "88195a94f390381c6afcdaa933c2f6ff93959cb4", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "NixOS", 47 | "ref": "nixos-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "crane": "crane", 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": [ 63 | "nixpkgs" 64 | ] 65 | }, 66 | "locked": { 67 | "lastModified": 1735784864, 68 | "narHash": "sha256-tIl5p3ueaPw7T5T1UXkLc8ISMk6Y8CI/D/rd0msf73I=", 69 | "owner": "oxalica", 70 | "repo": "rust-overlay", 71 | "rev": "04d5f1836721461b256ec452883362c5edc5288e", 72 | "type": "github" 73 | }, 74 | "original": { 75 | "owner": "oxalica", 76 | "repo": "rust-overlay", 77 | "type": "github" 78 | } 79 | }, 80 | "systems": { 81 | "locked": { 82 | "lastModified": 1681028828, 83 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 84 | "owner": "nix-systems", 85 | "repo": "default", 86 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 87 | "type": "github" 88 | }, 89 | "original": { 90 | "owner": "nix-systems", 91 | "repo": "default", 92 | "type": "github" 93 | } 94 | } 95 | }, 96 | "root": "root", 97 | "version": 7 98 | } 99 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "rust-payjoin"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | rust-overlay = { 8 | url = "github:oxalica/rust-overlay"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | crane.url = "github:ipetkov/crane"; 12 | }; 13 | 14 | outputs = { 15 | self, 16 | nixpkgs, 17 | flake-utils, 18 | rust-overlay, 19 | crane, 20 | }: 21 | flake-utils.lib.eachDefaultSystem ( 22 | system: let 23 | pkgs = import nixpkgs { 24 | inherit system; 25 | overlays = [rust-overlay.overlays.default]; 26 | }; 27 | 28 | msrv = "1.63.0"; 29 | rustVersions = with pkgs.rust-bin; 30 | builtins.mapAttrs (_name: rust-bin: 31 | rust-bin.override { 32 | extensions = ["rust-src" "rustfmt" "llvm-tools-preview"]; 33 | }) 34 | { 35 | msrv = stable.${msrv}.default; 36 | stable = stable.latest.default; 37 | nightly = nightly.latest.default; 38 | }; 39 | 40 | # Use crane to define nix packages for the workspace crate 41 | # based on https://crane.dev/examples/quick-start-workspace.html 42 | # default to nightly rust toolchain in crane, mainly due to rustfmt difference 43 | craneLibVersions = builtins.mapAttrs (name: rust-bin: (crane.mkLib pkgs).overrideToolchain (_: rust-bin)) rustVersions; 44 | craneLib = craneLibVersions.nightly; 45 | src = craneLib.cleanCargoSource ./.; 46 | commonArgs = { 47 | inherit src; 48 | strictDeps = true; 49 | 50 | # provide fallback name & version for workspace related derivations 51 | # this is mainly to silence warnings from crane about providing a stub 52 | # value overridden in per-crate packages with info from Cargo.toml 53 | pname = "workspace"; 54 | version = "no-version"; 55 | 56 | # default to recent dependency versions 57 | # TODO add overrides for minimal lockfile, once #454 is resolved 58 | cargoLock = ./Cargo-recent.lock; 59 | 60 | # tell bitcoind crate not to try to download during build 61 | BITCOIND_SKIP_DOWNLOAD = 1; 62 | }; 63 | 64 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 65 | individualCrateArgs = 66 | commonArgs 67 | // { 68 | inherit cargoArtifacts; 69 | doCheck = false; # skip testing, since that's done in flake check 70 | }; 71 | 72 | fileSetForCrate = subdir: 73 | pkgs.lib.fileset.toSource { 74 | root = ./.; 75 | fileset = pkgs.lib.fileset.unions [ 76 | ./Cargo.toml 77 | (craneLib.fileset.commonCargoSources subdir) 78 | ]; 79 | }; 80 | 81 | packages = 82 | builtins.mapAttrs ( 83 | name: extraArgs: 84 | craneLib.buildPackage (individualCrateArgs 85 | // craneLib.crateNameFromCargoToml {cargoToml = builtins.toPath "${./.}/${name}/Cargo.toml";} 86 | // { 87 | cargoExtraArgs = "--locked -p ${name} ${extraArgs}"; 88 | inherit src; 89 | }) 90 | ) { 91 | "payjoin" = "--features v2"; 92 | "payjoin-cli" = "--features v1,v2"; 93 | "payjoin-directory" = ""; 94 | }; 95 | 96 | devShells = builtins.mapAttrs (_name: craneLib: 97 | craneLib.devShell { 98 | packages = with pkgs; [ 99 | cargo-edit 100 | cargo-nextest 101 | cargo-watch 102 | rust-analyzer 103 | ] ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [ 104 | cargo-llvm-cov 105 | ]; 106 | }) 107 | craneLibVersions; 108 | 109 | simpleCheck = args: 110 | pkgs.stdenvNoCC.mkDerivation ({ 111 | doCheck = true; 112 | dontFixup = true; 113 | installPhase = "mkdir $out"; 114 | } 115 | // args); 116 | in { 117 | packages = packages; 118 | devShells = devShells // {default = devShells.nightly;}; 119 | formatter = pkgs.alejandra; 120 | checks = 121 | packages 122 | // { 123 | payjoin-workspace-nextest = craneLib.cargoNextest (commonArgs 124 | // { 125 | inherit cargoArtifacts; 126 | partitions = 1; 127 | partitionType = "count"; 128 | # TODO also run integration tests 129 | # this needs --all-features to enable io,_danger_local_https features 130 | # unfortunately this can't yet work because running docker inside the nix sandbox is not possible, 131 | # which precludes use of the redis test container 132 | # cargoExtraArgs = "--locked --all-features"; 133 | # buildInputs = [ pkgs.bitcoind ]; # not verified to work 134 | }); 135 | 136 | payjoin-workspace-nextest-msrv = craneLibVersions.msrv.cargoNextest (commonArgs 137 | // { 138 | cargoArtifacts = craneLibVersions.msrv.buildDepsOnly commonArgs; 139 | partitions = 1; 140 | partitionType = "count"; 141 | }); 142 | 143 | payjoin-workspace-clippy = craneLib.cargoClippy (commonArgs 144 | // { 145 | inherit cargoArtifacts; 146 | cargoClippyExtraArgs = "--all-targets --all-features --keep-going -- --deny warnings"; 147 | }); 148 | 149 | payjoin-workspace-doc = craneLib.cargoDoc (commonArgs 150 | // { 151 | inherit cargoArtifacts; 152 | }); 153 | 154 | payjoin-workspace-fmt = craneLib.cargoFmt (commonArgs 155 | // { 156 | inherit src; 157 | }); 158 | 159 | nix-fmt-check = simpleCheck { 160 | name = "nix-fmt-check"; 161 | src = pkgs.lib.sources.sourceFilesBySuffices ./. [".nix"]; 162 | nativeBuildInputs = [pkgs.alejandra]; 163 | checkPhase = '' 164 | alejandra -c . 165 | ''; 166 | }; 167 | 168 | shfmt = simpleCheck rec { 169 | name = "shell-checks"; 170 | src = pkgs.lib.sources.sourceFilesBySuffices ./. [".sh"]; 171 | nativeBuildInputs = [pkgs.shfmt]; 172 | checkPhase = '' 173 | shfmt -d -s -i 4 -ci ${src} 174 | ''; 175 | }; 176 | 177 | shellcheck = simpleCheck rec { 178 | name = "shell-checks"; 179 | src = pkgs.lib.sources.sourceFilesBySuffices ./. [".sh"]; 180 | nativeBuildInputs = [pkgs.shellcheck]; 181 | checkPhase = '' 182 | shellcheck -x ${src} 183 | ''; 184 | }; 185 | }; 186 | } 187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /payjoin-cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # payjoin-cli Changelog 2 | 3 | ## 0.1.0 4 | 5 | - Bump payjoin to 0.23.0 with stable wire protocol 6 | - Allow mixed input scripts [#367](https://github.com/payjoin/rust-payjoin/pull/367) [#505](https://github.com/payjoin/rust-payjoin/pull/505) 7 | - Fix bug to propagate missing config parameter or argument error [#441](https://github.com/payjoin/rust-payjoin/pull/441) 8 | - Don't pause between long polling requests [#463](https://github.com/payjoin/rust-payjoin/pull/463) 9 | - Hide danger-local-https feature with _ prefix [#423](https://github.com/payjoin/rust-payjoin/pull/423) 10 | - Allow specifying a max-feerate for receivers [#332](https://github.com/payjoin/rust-payjoin/pull/332) 11 | - Fix e2e tests and coverage reporting [#443](https://github.com/payjoin/rust-payjoin/pull/443) [#497](https://github.com/payjoin/rust-payjoin/pull/497) [#532](https://github.com/payjoin/rust-payjoin/pull/532) 12 | - Handle recoverable receiver errors by replying to sender with error response [#474](https://github.com/payjoin/rust-payjoin/pull/474) [#526](https://github.com/payjoin/rust-payjoin/pull/526) [#534](https://github.com/payjoin/rust-payjoin/pull/534) 13 | - Make config.toml hierarchical [#538](https://github.com/payjoin/rust-payjoin/pull/538) 14 | - Make v1/v2 features additive [#538](https://github.com/payjoin/rust-payjoin/pull/538) 15 | 16 | ## 0.0.9-alpha 17 | 18 | - Make backwards-compatible v2 to v1 sends possible 19 | - Bump payjoin to v0.20.0 20 | 21 | ## 0.0.8-alpha 22 | 23 | This release attempts to stabilize the Payjoin V2 Bitcoin URI format. That includes placing v2-specific parameters in the URI's pj parameter's fragment and including the exp expiration parameter. 24 | 25 | - Update to `payjoin-0.19.0` 26 | - Error if send or receive session expires with `exp` parameter [#299](https://github.com/payjoin/rust-payjoin/pull/299) 27 | - Encode `&ohttp=` and `&exp=` parameters in the `&pj=` URL as a fragment instead of as URI params [#298](https://github.com/payjoin/rust-payjoin/pull/298) 28 | - Allow receivers to make payjoins out of sweep transactions [#259](https://github.com/payjoin/rust-payjoin/pull/259) 29 | 30 | ## 0.0.7-alpha 31 | 32 | - Resume multiple payjoins easily with the `resume` subcommand. A repeat `send` 33 | subcommand will also resume an existing session ([#283](https://github.com/payjoin/rust-payjoin/pull/283)) 34 | - Normalize dash-separated long args ([#295](https://github.com/payjoin/rust-payjoin/pull/295)) 35 | - Use sled database. Old .json storage files will no longer be read and should be deleted. 36 | - read Network::from_core_arg ([#304](https://github.com/payjoin/rust-payjoin/pull/304)) 37 | - Don't needlessly substitute outputs for v2 receivers ([#277](https://github.com/payjoin/rust-payjoin/pull/277)) 38 | - Print instructions and info on interrupt ([#303](https://github.com/payjoin/rust-payjoin/pull/303)) 39 | 40 | ### Contributors: 41 | 42 | @DanGould, @grizznaut, @thebrandonlucas 43 | 44 | ## 0.0.6-alpha 45 | 46 | - fetch ohttp keys from `payjoin/io` feature 47 | - add example.config.toml 48 | - Rename config.toml & CLI argument field pj_host to port (#253) 49 | - add `--version` & `-V` CLI arguments 50 | - replace dependency on `ureq` with `reqwest` 51 | - Unify `pj_host`, `--host-port` arguments to `port` for v1 receivers 52 | - remove `sub_only` CLI argument and config option 53 | - Include more verbose context when bitcoind fails (#251) 54 | - Use `*rpcpassword` instead of `*rpcpass` config and option to match bitcoind 55 | - Test with JoinMarket 56 | - respect `disableoutputsubtitution` send parameter 57 | - depend on `payjoin-0.16.0` 58 | - separate V1 `pj_endpoint` and V2 `pj_directory` config params / cli arguments 59 | 60 | Contributors: 61 | 62 | @jbesraa, @grizznaut, @thebrandonlucas, @DanGould 63 | 64 | ## 0.0.5-alpha 65 | 66 | - fetch ohttp keys through CONNECT tunnel (#194) instead of manual configuration 67 | - Name payjoin-directory and OHTTP relay according to BIP 77 (#203) 68 | 69 | ## 0.0.4-alpha 70 | 71 | - Remove annoying duplicate code in tests. (#197) 72 | - Refactor payjoin-cli v1, v2 features into modules (#198) 73 | - Parse AppConfig types when they're passed (#195) 74 | - Use spec OHTTP media types (#160) 75 | - Handle ResponseError version-unsupported variant supported field (#165) 76 | 77 | ## 0.0.3-alpha 78 | 79 | - Parse `WellKnownError` `ResponseError` from receivers (#120) 80 | - Show OHTTP Config issue was unclear (#153) 81 | - Better compatibility for `receive` on taproot wallets (#147) 82 | 83 | ## 0.0.2-alpha 84 | 85 | - New `v2` oblivious, asynchronous, serverless payjoin support 86 | 87 | ## 0.0.1-alpha 88 | 89 | - Release initial payjoin-cli to send and receive payjoin from bitcoind 90 | -------------------------------------------------------------------------------- /payjoin-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "payjoin-cli" 3 | version = "0.1.0" 4 | authors = ["Dan Gould "] 5 | description = "A command-line Payjoin client for Bitcoin Core" 6 | repository = "https://github.com/payjoin/rust-payjoin" 7 | readme = "README.md" 8 | keywords = ["bip78", "payjoin", "bitcoin"] 9 | categories = ["cryptography::cryptocurrencies", "network-programming"] 10 | license = "MITNFA" 11 | edition = "2021" 12 | rust-version = "1.63" 13 | resolver = "2" 14 | exclude = ["tests"] 15 | 16 | [[bin]] 17 | name = "payjoin-cli" 18 | path = "src/main.rs" 19 | 20 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 21 | [features] 22 | default = ["v2"] 23 | native-certs = ["reqwest/rustls-tls-native-roots"] 24 | _danger-local-https = ["rcgen", "reqwest/rustls-tls", "rustls", "hyper-rustls", "payjoin/_danger-local-https", "tokio-rustls"] 25 | v1 = ["payjoin/v1","hyper", "hyper-util", "http-body-util"] 26 | v2 = ["payjoin/v2", "payjoin/io"] 27 | 28 | [dependencies] 29 | anyhow = "1.0.70" 30 | async-trait = "0.1" 31 | bitcoincore-rpc = "0.19.0" 32 | clap = { version = "~4.0.32", features = ["derive"] } 33 | config = "0.13.3" 34 | env_logger = "0.9.0" 35 | http-body-util = { version = "0.1", optional = true } 36 | hyper = { version = "1", features = ["http1", "server"], optional = true } 37 | hyper-rustls = { version = "0.26", optional = true } 38 | hyper-util = { version = "0.1", optional = true } 39 | log = "0.4.7" 40 | payjoin = { version = "0.23.0", default-features = false } 41 | rcgen = { version = "0.11.1", optional = true } 42 | reqwest = { version = "0.12", default-features = false } 43 | rustls = { version = "0.22.4", optional = true } 44 | serde = { version = "1.0.160", features = ["derive"] } 45 | sled = "0.34" 46 | tokio = { version = "1.38.1", features = ["full"] } 47 | tokio-rustls = { version = "0.25", features = ["ring"], default-features = false, optional = true } 48 | url = { version = "2.3.1", features = ["serde"] } 49 | 50 | [dev-dependencies] 51 | nix = "0.26.4" 52 | payjoin-test-utils = { path = "../payjoin-test-utils" } 53 | -------------------------------------------------------------------------------- /payjoin-cli/Dockerfile: -------------------------------------------------------------------------------- 1 | # x86_64-unknown-linux-musl 2 | 3 | ## Initial build Stage 4 | FROM rustlang/rust:nightly 5 | 6 | WORKDIR /usr/src/payjoin-cli 7 | COPY Cargo.toml Cargo.lock ./ 8 | COPY payjoin/Cargo.toml ./payjoin/ 9 | COPY payjoin/src ./payjoin/src/ 10 | COPY payjoin-cli/Cargo.toml ./payjoin-cli/ 11 | COPY payjoin-cli/src ./payjoin-cli/src/ 12 | 13 | # Install the required dependencies to build for `musl` static linking 14 | RUN apt-get update && apt-get install -y musl-tools musl-dev 15 | # Add our x86 target to rust, then compile and install 16 | RUN rustup target add x86_64-unknown-linux-musl 17 | RUN cargo build --release --bin=payjoin-cli --target x86_64-unknown-linux-musl --features=native-certs 18 | 19 | FROM alpine:latest 20 | RUN apk --no-cache add ca-certificates 21 | COPY --from=0 /usr/src/payjoin-cli/target/x86_64-unknown-linux-musl/release/payjoin-cli ./ 22 | # Run 23 | ENTRYPOINT ["./payjoin-cli"] -------------------------------------------------------------------------------- /payjoin-cli/README.md: -------------------------------------------------------------------------------- 1 | # `payjoin-cli` 2 | 3 | ## A command-line payjoin client for [Bitcoin Core](https://github.com/bitcoin/bitcoin?tab=readme-ov-file) in Rust 4 | 5 | `payjoin-cli` is the reference implementation for the payjoin protocol, written using the [Payjoin Dev Kit](https://payjoindevkit.org). 6 | 7 | It enables sending and receiving [BIP 78 Payjoin 8 | (v1)](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) and [Draft 9 | BIP 77 Async Payjoin (v2)](https://github.com/bitcoin/bips/blob/master/bip-0077.md) 10 | transactions via `bitcoind`. By default it supports Payjoin v2, which is 11 | backwards compatible with v1. Enable the `v1` feature to disable Payjoin v2 to 12 | send and receive using only v1. 13 | 14 | While this code and design have had significant testing, it is still alpha-quality experimental software. Use at your own risk. 15 | 16 | Independent audit is welcome. 17 | 18 | ## Quick Start 19 | 20 | Here's a minimal payjoin example using `payjoin-cli` with the `v2` feature connected to `bitcoind` on [regtest](https://developer.bitcoin.org/examples/testing.html#regtest-mode). This example uses [`nigiri`](https://github.com/vulpemventures/nigiri) to setup a regtest environment. 21 | 22 | Payjoin `v2` allows for transactions to be completed asynchronously. Thus the sender and receiver do not need to be online at the same time to payjoin. Learn more about how `v2` works [here](https://payjoin.org/docs/how-it-works/payjoin-v2-bip-77). 23 | 24 | To get started, install `nigiri` and [`docker`](https://www.docker.com/get-started). Payjoin requires the sender and receiver each to have spendable [UTXOs](https://www.unchained.com/blog/what-is-a-utxo-bitcoin), so we'll create two wallets and fund each. 25 | 26 | ```sh 27 | # Download nigiri and check that installation has succeeded. 28 | curl https://getnigiri.vulpem.com | bash 29 | nigiri --version 30 | 31 | # Create two regtest wallets. 32 | nigiri rpc createwallet sender 33 | nigiri rpc createwallet receiver 34 | 35 | # We need 101 blocks for the UTXOs to be spendable due to the coinbase maturity requirement. 36 | nigiri rpc generatetoaddress 101 $(nigiri rpc -rpcwallet=sender getnewaddress) 37 | nigiri rpc generatetoaddress 101 $(nigiri rpc -rpcwallet=receiver getnewaddress) 38 | 39 | # Check the balances before doing a Payjoin transaction. 40 | nigiri rpc -rpcwallet=sender getbalance 41 | nigiri rpc -rpcwallet=receiver getbalance 42 | ``` 43 | 44 | Great! Our wallets are setup. Now let's do an async payjoin. 45 | 46 | ### Install `payjoin-cli` 47 | 48 | The following command will install the most recent version of payjoin-cli. See the crates.io page [here](https://crates.io/crates/payjoin-cli). 49 | 50 | ```sh 51 | cargo install payjoin-cli 52 | ``` 53 | 54 | Optionally, you can install a specific version by setting the `--version` flag in the command. 55 | 56 | Next, create a directory for the sender & receiver and create a file called `config.toml` for each. This file provides the information required for `payjoin-cli` to connect to your node and, for `v2`, to know which Payjoin Directory and OHTTP Relay to use. 57 | 58 | When running commands, `payjoin-cli` will read the `config.toml` file which is in the current working directory. 59 | 60 | ```sh 61 | mkdir sender receiver 62 | touch sender/config.toml receiver/config.toml 63 | ``` 64 | 65 | Edit the `config.toml` files. 66 | 67 | #### `sender/config.toml` 68 | ```toml 69 | # Nigiri uses the following RPC credentials 70 | [bitcoind] 71 | rpcuser = "admin1" 72 | rpcpassword = "123" 73 | rpchost = "http://localhost:18443/wallet/sender" 74 | 75 | # For v2, our config also requires a payjoin directory server and OHTTP relay 76 | [v2] 77 | pj_directory = "https://payjo.in" 78 | ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://ohttp.achow101.com"] 79 | ``` 80 | 81 | #### `receiver/config.toml` 82 | ```toml 83 | # Nigiri uses the following RPC credentials 84 | [bitcoind] 85 | rpcuser = "admin1" 86 | rpcpassword = "123" 87 | rpchost = "http://localhost:18443/wallet/receiver" 88 | 89 | # For v2, our config also requires a payjoin directory server and OHTTP relay 90 | [v2] 91 | pj_directory = "https://payjo.in" 92 | ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://ohttp.achow101.com"] 93 | ``` 94 | 95 | Now, the receiver must generate an address to receive the payment. The format is: 96 | 97 | ```sh 98 | payjoin-cli receive 99 | ``` 100 | 101 | For example, to receive 10000 sats from our top-level directory: 102 | 103 | ```sh 104 | receiver/payjoin-cli receive 10000 105 | ``` 106 | 107 | This will output a [bitcoin URI](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) containing the receiver's address, amount, payjoin directory, and other session information the client needs. For example: 108 | 109 | ```sh 110 | bitcoin:tb1qfttmt4z68cfyn2z25t3dusp03rq6gxrucfxs5a?amount=0.0001&pj=HTTPS://PAYJO.IN/EUQKYLU92GC6U%23RK1QFWVXS2LQ2VD4T6DUMQ0F4RZQ5NL9GM0EFWVHJZ9L796L20Z7SL3J+OH1QYP87E2AVMDKXDTU6R25WCPQ5ZUF02XHNPA65JMD8ZA2W4YRQN6UUWG+EX10T57UE 111 | ``` 112 | 113 | Note that the session can be paused by pressing `Ctrl+C`. The receiver can come back online and resume the session by running `payjoin-cli resume` again, and the sender may do a `send` against it while the receiver is offline. 114 | 115 | ### Send a Payjoin 116 | 117 | Now, let's send the payjoin. Here is an example format: 118 | 119 | ```sh 120 | payjoin-cli send --fee-rate 121 | ``` 122 | 123 | Where `` is the BIP21 URL containing the receiver's address, amount, payjoin directory, and OHTTP relay. Using the example from above: 124 | 125 | ```sh 126 | sender/payjoin-cli send "bitcoin:tb1qfttmt4z68cfyn2z25t3dusp03rq6gxrucfxs5a?amount=0.0001&pj=HTTPS://PAYJO.IN/EUQKYLU92GC6U%23RK1QFWVXS2LQ2VD4T6DUMQ0F4RZQ5NL9GM0EFWVHJZ9L796L20Z7SL3J+OH1QYP87E2AVMDKXDTU6R25WCPQ5ZUF02XHNPA65JMD8ZA2W4YRQN6UUWG+EX10T57UE" --fee-rate 1 127 | ``` 128 | 129 | Congratulations! You've completed a version 2 payjoin, which can be used for cheaper, more efficient, and more private on-chain payments. Additionally, because we're using `v2`, the sender and receiver don't need to be online at the same time to do the payjoin. 130 | 131 | ## Configuration 132 | 133 | Config options can be passed from the command line, or manually edited in a `config.toml` file within the directory you run `payjoin-cli` from. 134 | 135 | See the 136 | [example.config.toml](https://github.com/payjoin/rust-payjoin/blob/fde867b93ede767c9a50913432a73782a94ef40b/payjoin-cli/example.config.toml) 137 | for inspiration. 138 | 139 | ### Asynchronous Operation 140 | 141 | Sender and receiver state is saved to a database in the directory from which `payjoin-cli` is run, called `payjoin.sled`. Once a send or receive session is started, it may resume using the `resume` argument if prior payjoin sessions have not yet complete. 142 | 143 | ## Usage 144 | 145 | Get a list of commands and options: 146 | 147 | ```sh 148 | payjoin-cli --help 149 | ``` 150 | 151 | or with a subcommand e.g. 152 | 153 | ```sh 154 | payjoin-cli send --help 155 | ``` 156 | -------------------------------------------------------------------------------- /payjoin-cli/contrib/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Individual features with no defaults. 5 | features=("v1" "v2") 6 | 7 | for feature in "${features[@]}"; do 8 | # Don't duplicate --all-targets clippy. Clilppy end-user code, not tests. 9 | cargo clippy --no-default-features --features "$feature" -- -D warnings 10 | done 11 | -------------------------------------------------------------------------------- /payjoin-cli/contrib/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | cargo test --locked --package payjoin-cli --verbose --all-features 5 | -------------------------------------------------------------------------------- /payjoin-cli/example.config.toml: -------------------------------------------------------------------------------- 1 | ## 2 | ## Payjoin config.toml configuration file. Lines beginning with # are comments. 3 | ## 4 | 5 | # Common Settings 6 | # -------------- 7 | 8 | # The path to the database file 9 | db_path = "payjoin.sled" 10 | 11 | # The maximum fee rate that the receiver is willing to pay (in sat/vB) 12 | max_fee_rate = 2.0 13 | 14 | # Bitcoin RPC Connection Settings 15 | # ------------------------------ 16 | [bitcoind] 17 | # The RPC host of the wallet to connect to. 18 | # For example, if the wallet is "sender", then default values are: 19 | # - mainnet: http://localhost:8332/wallet/sender 20 | # - testnet: http://localhost:18332/wallet/sender 21 | # - regtest: http://localhost:18443/wallet/sender 22 | # - signet: http://localhost:38332/wallet/sender 23 | rpchost = "http://localhost:18443/wallet/sender" 24 | 25 | # The RPC .cookie file used only for local authentication to bitcoind. 26 | # If rpcuser and rpcpassword are being used, this is not necessary. 27 | # Found in data directory, which is located: 28 | # Linux: ~/.bitcoin//.cookie 29 | # MacOS: ~/Library/Application Support/Bitcoin//.cookie 30 | # Windows Vista and later: C:\Users\YourUserName\AppData\Roaming\Bitcoin\\.cookie 31 | # Windows XP: C:\Documents and Settings\YourUserName\Application Data\Bitcoin\\.cookie 32 | # cookie = "" 33 | 34 | # The rpcuser to connect to (specified in bitcoin.conf). 35 | rpcuser = "user" 36 | 37 | # The rpcpassword of the user to connect to (specified in bitcoin.conf). 38 | rpcpassword = "password" 39 | 40 | # Version Configuration 41 | # ------------------- 42 | # Uncomment ONE of the following version configurations depending on which version you want to use 43 | 44 | # Version 1 Configuration 45 | # [v1] 46 | # port = 3000 47 | # pj_endpoint = "https://localhost:3000" 48 | 49 | # Version 2 Configuration 50 | # [v2] 51 | # pj_directory = "https://payjo.in" 52 | # ohttp_relays = ["https://pj.benalleng.com", "https://pj.bobspacebkk.com", "https://ohttp.achow101.com", "https://example.com"] 53 | # # Optional: The HPKE keys which need to be fetched ahead of time from the pj_endpoint 54 | # # for the payjoin packets to be encrypted. 55 | # # These can now be fetched and no longer need to be configured. 56 | # ohttp_keys = "./path/to/ohttp_keys" 57 | -------------------------------------------------------------------------------- /payjoin-cli/src/app/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use bitcoincore_rpc::bitcoin::Amount; 5 | use payjoin::bitcoin::psbt::Psbt; 6 | use payjoin::bitcoin::FeeRate; 7 | use payjoin::{bitcoin, PjUri}; 8 | use tokio::signal; 9 | use tokio::sync::watch; 10 | 11 | pub mod config; 12 | pub mod wallet; 13 | use crate::app::config::Config; 14 | use crate::app::wallet::BitcoindWallet; 15 | 16 | #[cfg(feature = "v1")] 17 | pub(crate) mod v1; 18 | #[cfg(feature = "v2")] 19 | pub(crate) mod v2; 20 | 21 | #[cfg(feature = "_danger-local-https")] 22 | pub const LOCAL_CERT_FILE: &str = "localhost.der"; 23 | 24 | #[async_trait::async_trait] 25 | pub trait App: Send + Sync { 26 | fn new(config: Config) -> Result 27 | where 28 | Self: Sized; 29 | fn wallet(&self) -> BitcoindWallet; 30 | async fn send_payjoin(&self, bip21: &str, fee_rate: FeeRate) -> Result<()>; 31 | async fn receive_payjoin(&self, amount: Amount) -> Result<()>; 32 | #[cfg(feature = "v2")] 33 | async fn resume_payjoins(&self) -> Result<()>; 34 | 35 | fn create_original_psbt(&self, uri: &PjUri, fee_rate: FeeRate) -> Result { 36 | let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?; 37 | 38 | // wallet_create_funded_psbt requires a HashMap 39 | let mut outputs = HashMap::with_capacity(1); 40 | outputs.insert(uri.address.to_string(), amount); 41 | 42 | self.wallet().create_psbt(outputs, fee_rate, true) 43 | } 44 | 45 | fn process_pj_response(&self, psbt: Psbt) -> Result { 46 | log::debug!("Proposed psbt: {psbt:#?}"); 47 | 48 | let signed = self.wallet().process_psbt(&psbt)?; 49 | let tx = self.wallet().finalize_psbt(&signed)?; 50 | 51 | let txid = self.wallet().broadcast_tx(&tx)?; 52 | 53 | println!("Payjoin sent. TXID: {txid}"); 54 | Ok(txid) 55 | } 56 | } 57 | 58 | #[cfg(feature = "_danger-local-https")] 59 | fn http_agent() -> Result { Ok(http_agent_builder()?.build()?) } 60 | 61 | #[cfg(not(feature = "_danger-local-https"))] 62 | fn http_agent() -> Result { Ok(reqwest::Client::new()) } 63 | 64 | #[cfg(feature = "_danger-local-https")] 65 | fn http_agent_builder() -> Result { 66 | use rustls::pki_types::CertificateDer; 67 | use rustls::RootCertStore; 68 | 69 | let cert_der = read_local_cert()?; 70 | let mut root_cert_store = RootCertStore::empty(); 71 | root_cert_store.add(CertificateDer::from(cert_der.as_slice()))?; 72 | Ok(reqwest::ClientBuilder::new() 73 | .use_rustls_tls() 74 | .add_root_certificate(reqwest::tls::Certificate::from_der(cert_der.as_slice())?)) 75 | } 76 | 77 | #[cfg(feature = "_danger-local-https")] 78 | fn read_local_cert() -> Result> { 79 | let mut local_cert_path = std::env::temp_dir(); 80 | local_cert_path.push(LOCAL_CERT_FILE); 81 | Ok(std::fs::read(local_cert_path)?) 82 | } 83 | 84 | async fn handle_interrupt(tx: watch::Sender<()>) { 85 | if let Err(e) = signal::ctrl_c().await { 86 | eprintln!("Error setting up Ctrl-C handler: {e}"); 87 | } 88 | let _ = tx.send(()); 89 | } 90 | -------------------------------------------------------------------------------- /payjoin-cli/src/app/v2/ohttp.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, Mutex}; 2 | 3 | #[cfg(feature = "_danger-local-https")] 4 | use anyhow::Result; 5 | #[cfg(not(feature = "_danger-local-https"))] 6 | use anyhow::{anyhow, Result}; 7 | 8 | use super::Config; 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct RelayManager { 12 | selected_relay: Option, 13 | #[cfg(not(feature = "_danger-local-https"))] 14 | failed_relays: Vec, 15 | } 16 | 17 | impl RelayManager { 18 | #[cfg(feature = "_danger-local-https")] 19 | pub fn new() -> Self { RelayManager { selected_relay: None } } 20 | #[cfg(not(feature = "_danger-local-https"))] 21 | pub fn new() -> Self { RelayManager { selected_relay: None, failed_relays: Vec::new() } } 22 | 23 | #[cfg(not(feature = "_danger-local-https"))] 24 | pub fn set_selected_relay(&mut self, relay: payjoin::Url) { self.selected_relay = Some(relay); } 25 | 26 | pub fn get_selected_relay(&self) -> Option { self.selected_relay.clone() } 27 | 28 | #[cfg(not(feature = "_danger-local-https"))] 29 | pub fn add_failed_relay(&mut self, relay: payjoin::Url) { self.failed_relays.push(relay); } 30 | 31 | #[cfg(not(feature = "_danger-local-https"))] 32 | pub fn get_failed_relays(&self) -> Vec { self.failed_relays.clone() } 33 | } 34 | 35 | pub(crate) async fn unwrap_ohttp_keys_or_else_fetch( 36 | config: &Config, 37 | relay_manager: Arc>, 38 | ) -> Result { 39 | if let Some(keys) = config.v2()?.ohttp_keys.clone() { 40 | println!("Using OHTTP Keys from config"); 41 | Ok(keys) 42 | } else { 43 | println!("Bootstrapping private network transport over Oblivious HTTP"); 44 | 45 | fetch_keys(config, relay_manager.clone()) 46 | .await 47 | .and_then(|keys| keys.ok_or_else(|| anyhow::anyhow!("No OHTTP keys found"))) 48 | } 49 | } 50 | 51 | #[cfg(not(feature = "_danger-local-https"))] 52 | async fn fetch_keys( 53 | config: &Config, 54 | relay_manager: Arc>, 55 | ) -> Result> { 56 | use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; 57 | let payjoin_directory = config.v2()?.pj_directory.clone(); 58 | let relays = config.v2()?.ohttp_relays.clone(); 59 | 60 | loop { 61 | let failed_relays = 62 | relay_manager.lock().expect("Lock should not be poisoned").get_failed_relays(); 63 | 64 | let remaining_relays: Vec<_> = 65 | relays.iter().filter(|r| !failed_relays.contains(r)).cloned().collect(); 66 | 67 | if remaining_relays.is_empty() { 68 | return Err(anyhow!("No valid relays available")); 69 | } 70 | 71 | let selected_relay = 72 | match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) { 73 | Some(relay) => relay.clone(), 74 | None => return Err(anyhow!("Failed to select from remaining relays")), 75 | }; 76 | 77 | relay_manager 78 | .lock() 79 | .expect("Lock should not be poisoned") 80 | .set_selected_relay(selected_relay.clone()); 81 | 82 | let ohttp_keys = { 83 | payjoin::io::fetch_ohttp_keys(selected_relay.clone(), payjoin_directory.clone()).await 84 | }; 85 | 86 | match ohttp_keys { 87 | Ok(keys) => return Ok(Some(keys)), 88 | Err(payjoin::io::Error::UnexpectedStatusCode(e)) => { 89 | return Err(payjoin::io::Error::UnexpectedStatusCode(e).into()); 90 | } 91 | Err(e) => { 92 | log::debug!("Failed to connect to relay: {selected_relay}, {e:?}"); 93 | relay_manager 94 | .lock() 95 | .expect("Lock should not be poisoned") 96 | .add_failed_relay(selected_relay); 97 | } 98 | } 99 | } 100 | } 101 | 102 | ///Local relays are incapable of acting as proxies so we must opportunistically fetch keys from the config 103 | #[cfg(feature = "_danger-local-https")] 104 | async fn fetch_keys( 105 | config: &Config, 106 | _relay_manager: Arc>, 107 | ) -> Result> { 108 | let keys = config.v2()?.ohttp_keys.clone().expect("No OHTTP keys set"); 109 | 110 | Ok(Some(keys)) 111 | } 112 | 113 | #[cfg(not(feature = "_danger-local-https"))] 114 | pub(crate) async fn validate_relay( 115 | config: &Config, 116 | relay_manager: Arc>, 117 | ) -> Result { 118 | use payjoin::bitcoin::secp256k1::rand::prelude::SliceRandom; 119 | let payjoin_directory = config.v2()?.pj_directory.clone(); 120 | let relays = config.v2()?.ohttp_relays.clone(); 121 | 122 | loop { 123 | let failed_relays = 124 | relay_manager.lock().expect("Lock should not be poisoned").get_failed_relays(); 125 | 126 | let remaining_relays: Vec<_> = 127 | relays.iter().filter(|r| !failed_relays.contains(r)).cloned().collect(); 128 | 129 | if remaining_relays.is_empty() { 130 | return Err(anyhow!("No valid relays available")); 131 | } 132 | 133 | let selected_relay = 134 | match remaining_relays.choose(&mut payjoin::bitcoin::key::rand::thread_rng()) { 135 | Some(relay) => relay.clone(), 136 | None => return Err(anyhow!("Failed to select from remaining relays")), 137 | }; 138 | 139 | relay_manager 140 | .lock() 141 | .expect("Lock should not be poisoned") 142 | .set_selected_relay(selected_relay.clone()); 143 | 144 | let ohttp_keys = 145 | payjoin::io::fetch_ohttp_keys(selected_relay.clone(), payjoin_directory.clone()).await; 146 | 147 | match ohttp_keys { 148 | Ok(_) => return Ok(selected_relay), 149 | Err(payjoin::io::Error::UnexpectedStatusCode(e)) => { 150 | return Err(payjoin::io::Error::UnexpectedStatusCode(e).into()); 151 | } 152 | Err(e) => { 153 | log::debug!("Failed to connect to relay: {selected_relay}, {e:?}"); 154 | relay_manager 155 | .lock() 156 | .expect("Lock should not be poisoned") 157 | .add_failed_relay(selected_relay); 158 | } 159 | } 160 | } 161 | } 162 | 163 | #[cfg(feature = "_danger-local-https")] 164 | pub(crate) async fn validate_relay( 165 | config: &Config, 166 | _relay_manager: Arc>, 167 | ) -> Result { 168 | let relay = config.v2()?.ohttp_relays.first().expect("no OHTTP relay set").clone(); 169 | 170 | Ok(relay) 171 | } 172 | -------------------------------------------------------------------------------- /payjoin-cli/src/app/wallet.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::str::FromStr; 3 | use std::sync::Arc; 4 | 5 | use anyhow::{anyhow, Context, Result}; 6 | use bitcoincore_rpc::json::WalletCreateFundedPsbtOptions; 7 | use bitcoincore_rpc::{Auth, Client, RpcApi}; 8 | use payjoin::bitcoin::consensus::encode::{deserialize, serialize_hex}; 9 | use payjoin::bitcoin::consensus::Encodable; 10 | use payjoin::bitcoin::psbt::{Input, Psbt}; 11 | use payjoin::bitcoin::{ 12 | Address, Amount, Denomination, FeeRate, Network, OutPoint, Script, Transaction, TxIn, TxOut, 13 | Txid, 14 | }; 15 | use payjoin::receive::InputPair; 16 | 17 | /// Implementation of PayjoinWallet for bitcoind 18 | #[derive(Clone, Debug)] 19 | pub struct BitcoindWallet { 20 | pub bitcoind: std::sync::Arc, 21 | } 22 | 23 | impl BitcoindWallet { 24 | pub fn new(config: &crate::app::config::BitcoindConfig) -> Result { 25 | let client = match &config.cookie { 26 | Some(cookie) if cookie.as_os_str().is_empty() => 27 | return Err(anyhow!( 28 | "Cookie authentication enabled but no cookie path provided in config.toml" 29 | )), 30 | Some(cookie) => Client::new(config.rpchost.as_str(), Auth::CookieFile(cookie.into())), 31 | None => Client::new( 32 | config.rpchost.as_str(), 33 | Auth::UserPass(config.rpcuser.clone(), config.rpcpassword.clone()), 34 | ), 35 | }?; 36 | Ok(Self { bitcoind: Arc::new(client) }) 37 | } 38 | } 39 | 40 | impl BitcoindWallet { 41 | /// Create a PSBT with the given outputs and fee rate 42 | pub fn create_psbt( 43 | &self, 44 | outputs: HashMap, 45 | fee_rate: FeeRate, 46 | lock_unspent: bool, 47 | ) -> Result { 48 | let fee_sat_per_kvb = 49 | fee_rate.to_sat_per_kwu().checked_mul(4).ok_or_else(|| anyhow!("Invalid fee rate"))?; 50 | let fee_per_kvb = Amount::from_sat(fee_sat_per_kvb); 51 | log::debug!("Fee rate sat/kvb: {}", fee_per_kvb.display_in(Denomination::Satoshi)); 52 | 53 | let options = WalletCreateFundedPsbtOptions { 54 | lock_unspent: Some(lock_unspent), 55 | fee_rate: Some(fee_per_kvb), 56 | ..Default::default() 57 | }; 58 | 59 | let psbt = self 60 | .bitcoind 61 | .wallet_create_funded_psbt( 62 | &[], // inputs 63 | &outputs, 64 | None, // locktime 65 | Some(options), 66 | None, 67 | ) 68 | .context("Failed to create PSBT")? 69 | .psbt; 70 | 71 | let psbt = self 72 | .bitcoind 73 | .wallet_process_psbt(&psbt, None, None, None) 74 | .context("Failed to process PSBT")? 75 | .psbt; 76 | 77 | Psbt::from_str(&psbt).context("Failed to load PSBT from base64") 78 | } 79 | 80 | /// Process a PSBT, validating and signing inputs owned by this wallet 81 | /// 82 | /// Does not include bip32 derivations in the PSBT 83 | pub fn process_psbt(&self, psbt: &Psbt) -> Result { 84 | let psbt_str = psbt.to_string(); 85 | let processed = self 86 | .bitcoind 87 | .wallet_process_psbt(&psbt_str, None, None, Some(false)) 88 | .context("Failed to process PSBT")? 89 | .psbt; 90 | Psbt::from_str(&processed).context("Failed to parse processed PSBT") 91 | } 92 | 93 | /// Finalize a PSBT and extract the transaction 94 | pub fn finalize_psbt(&self, psbt: &Psbt) -> Result { 95 | let result = self 96 | .bitcoind 97 | .finalize_psbt(&psbt.to_string(), Some(true)) 98 | .context("Failed to finalize PSBT")?; 99 | let tx = deserialize(&result.hex.ok_or_else(|| anyhow!("Incomplete PSBT"))?)?; 100 | Ok(tx) 101 | } 102 | 103 | pub fn can_broadcast(&self, tx: &Transaction) -> Result { 104 | let raw_tx = serialize_hex(&tx); 105 | let mempool_results = self.bitcoind.test_mempool_accept(&[raw_tx])?; 106 | match mempool_results.first() { 107 | Some(result) => Ok(result.allowed), 108 | None => Err(anyhow!("No mempool results returned on broadcast check",)), 109 | } 110 | } 111 | 112 | /// Broadcast a raw transaction 113 | pub fn broadcast_tx(&self, tx: &Transaction) -> Result { 114 | let mut serialized_tx = Vec::new(); 115 | tx.consensus_encode(&mut serialized_tx)?; 116 | self.bitcoind 117 | .send_raw_transaction(&serialized_tx) 118 | .context("Failed to broadcast transaction") 119 | } 120 | 121 | /// Check if a script belongs to this wallet 122 | pub fn is_mine(&self, script: &Script) -> Result { 123 | if let Ok(address) = Address::from_script(script, self.network()?) { 124 | self.bitcoind 125 | .get_address_info(&address) 126 | .map(|info| info.is_mine.unwrap_or(false)) 127 | .context("Failed to get address info") 128 | } else { 129 | Ok(false) 130 | } 131 | } 132 | 133 | /// Get a new address from the wallet 134 | pub fn get_new_address(&self) -> Result
{ 135 | self.bitcoind 136 | .get_new_address(None, None) 137 | .context("Failed to get new address")? 138 | .require_network(self.network()?) 139 | .context("Invalid network for address") 140 | } 141 | 142 | /// List unspent UTXOs 143 | pub fn list_unspent(&self) -> Result> { 144 | let unspent = self 145 | .bitcoind 146 | .list_unspent(None, None, None, None, None) 147 | .context("Failed to list unspent")?; 148 | Ok(unspent.into_iter().map(input_pair_from_list_unspent).collect()) 149 | } 150 | 151 | /// Get the network this wallet is operating on 152 | pub fn network(&self) -> Result { 153 | self.bitcoind 154 | .get_blockchain_info() 155 | .map_err(|_| anyhow!("Failed to get blockchain info")) 156 | .map(|info| info.chain) 157 | } 158 | } 159 | 160 | pub fn input_pair_from_list_unspent( 161 | utxo: bitcoincore_rpc::bitcoincore_rpc_json::ListUnspentResultEntry, 162 | ) -> InputPair { 163 | let psbtin = Input { 164 | // NOTE: non_witness_utxo is not necessary because bitcoin-cli always supplies 165 | // witness_utxo, even for non-witness inputs 166 | witness_utxo: Some(TxOut { 167 | value: utxo.amount, 168 | script_pubkey: utxo.script_pub_key.clone(), 169 | }), 170 | redeem_script: utxo.redeem_script.clone(), 171 | witness_script: utxo.witness_script.clone(), 172 | ..Default::default() 173 | }; 174 | let txin = TxIn { 175 | previous_output: OutPoint { txid: utxo.txid, vout: utxo.vout }, 176 | ..Default::default() 177 | }; 178 | InputPair::new(txin, psbtin).expect("Input pair should be valid") 179 | } 180 | -------------------------------------------------------------------------------- /payjoin-cli/src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use clap::{value_parser, Parser, Subcommand}; 4 | use payjoin::bitcoin::amount::ParseAmountError; 5 | use payjoin::bitcoin::{Amount, FeeRate}; 6 | use serde::Deserialize; 7 | use url::Url; 8 | 9 | #[derive(Debug, Clone, Deserialize, Parser)] 10 | pub struct Flags { 11 | #[arg(long = "bip77", help = "Use BIP77 (v2) protocol (default)", action = clap::ArgAction::SetTrue)] 12 | pub bip77: Option, 13 | #[arg(long = "bip78", help = "Use BIP78 (v1) protocol", action = clap::ArgAction::SetTrue)] 14 | pub bip78: Option, 15 | } 16 | 17 | #[derive(Debug, Parser)] 18 | #[command( 19 | version = env!("CARGO_PKG_VERSION"), 20 | about = "Payjoin - bitcoin scaling, savings, and privacy by default", 21 | long_about = None, 22 | subcommand_required = true 23 | )] 24 | pub struct Cli { 25 | #[command(flatten)] 26 | pub flags: Flags, 27 | 28 | #[command(subcommand)] 29 | pub command: Commands, 30 | 31 | #[arg(long, short = 'd', help = "Sets a custom database path")] 32 | pub db_path: Option, 33 | 34 | #[arg(long = "max-fee-rate", short = 'f', help = "The maximum fee rate to accept in sat/vB")] 35 | pub max_fee_rate: Option, 36 | 37 | #[arg( 38 | long, 39 | short = 'r', 40 | num_args(1), 41 | help = "The URL of the Bitcoin RPC host, e.g. regtest default is http://localhost:18443" 42 | )] 43 | pub rpchost: Option, 44 | 45 | #[arg( 46 | long = "cookie-file", 47 | short = 'c', 48 | num_args(1), 49 | help = "Path to the cookie file of the bitcoin node" 50 | )] 51 | pub cookie_file: Option, 52 | 53 | #[arg(long = "rpcuser", num_args(1), help = "The username for the bitcoin node")] 54 | pub rpcuser: Option, 55 | 56 | #[arg(long = "rpcpassword", num_args(1), help = "The password for the bitcoin node")] 57 | pub rpcpassword: Option, 58 | 59 | #[cfg(feature = "v1")] 60 | #[arg(long = "port", help = "The local port to listen on")] 61 | pub port: Option, 62 | 63 | #[cfg(feature = "v1")] 64 | #[arg(long = "pj-endpoint", help = "The `pj=` endpoint to receive the payjoin request", value_parser = value_parser!(Url))] 65 | pub pj_endpoint: Option, 66 | 67 | #[cfg(feature = "v2")] 68 | #[arg(long = "ohttp-relays", help = "One or more ohttp relay URLs, comma-separated", value_parser = value_parser!(Url), value_delimiter = ',', action = clap::ArgAction::Append)] 69 | pub ohttp_relays: Option>, 70 | 71 | #[cfg(feature = "v2")] 72 | #[arg(long = "ohttp-keys", help = "The ohttp key config file path", value_parser = value_parser!(PathBuf))] 73 | pub ohttp_keys: Option, 74 | 75 | #[cfg(feature = "v2")] 76 | #[arg(long = "pj-directory", help = "The directory to store payjoin requests", value_parser = value_parser!(Url))] 77 | pub pj_directory: Option, 78 | } 79 | 80 | #[derive(Subcommand, Debug)] 81 | pub enum Commands { 82 | /// Send a payjoin payment 83 | Send { 84 | /// The `bitcoin:...` payjoin uri to send to 85 | #[arg(required = true)] 86 | bip21: String, 87 | 88 | /// Fee rate in sat/vB 89 | #[arg(required = true, short, long = "fee-rate", value_parser = parse_fee_rate_in_sat_per_vb)] 90 | fee_rate: FeeRate, 91 | }, 92 | /// Receive a payjoin payment 93 | Receive { 94 | /// The amount to receive in satoshis 95 | #[arg(required = true, value_parser = parse_amount_in_sat)] 96 | amount: Amount, 97 | 98 | /// The maximum effective fee rate the receiver is willing to pay (in sat/vB) 99 | #[arg(short, long = "max-fee-rate", value_parser = parse_fee_rate_in_sat_per_vb)] 100 | max_fee_rate: Option, 101 | 102 | #[cfg(feature = "v1")] 103 | /// The local port to listen on 104 | #[arg(short, long = "port")] 105 | port: Option, 106 | 107 | #[cfg(feature = "v1")] 108 | /// The `pj=` endpoint to receive the payjoin request 109 | #[arg(long = "pj-endpoint", value_parser = value_parser!(Url))] 110 | pj_endpoint: Option, 111 | 112 | #[cfg(feature = "v2")] 113 | /// The directory to store payjoin requests 114 | #[arg(long = "pj-directory", value_parser = value_parser!(Url))] 115 | pj_directory: Option, 116 | 117 | #[cfg(feature = "v2")] 118 | /// The path to the ohttp keys file 119 | #[arg(long = "ohttp-keys", value_parser = value_parser!(PathBuf))] 120 | ohttp_keys: Option, 121 | }, 122 | /// Resume pending payjoins (BIP77/v2 only) 123 | #[cfg(feature = "v2")] 124 | Resume, 125 | } 126 | 127 | pub fn parse_amount_in_sat(s: &str) -> Result { 128 | Amount::from_str_in(s, payjoin::bitcoin::Denomination::Satoshi) 129 | } 130 | 131 | pub fn parse_fee_rate_in_sat_per_vb(s: &str) -> Result { 132 | let fee_rate_sat_per_vb: f32 = s.parse()?; 133 | let fee_rate_sat_per_kwu = fee_rate_sat_per_vb * 250.0_f32; 134 | Ok(FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64)) 135 | } 136 | -------------------------------------------------------------------------------- /payjoin-cli/src/db/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[cfg(feature = "v2")] 4 | use bitcoincore_rpc::jsonrpc::serde_json; 5 | use sled::Error as SledError; 6 | 7 | pub(crate) type Result = std::result::Result; 8 | 9 | #[derive(Debug)] 10 | pub(crate) enum Error { 11 | Sled(SledError), 12 | #[cfg(feature = "v2")] 13 | Serialize(serde_json::Error), 14 | #[cfg(feature = "v2")] 15 | Deserialize(serde_json::Error), 16 | #[cfg(feature = "v2")] 17 | NotFound(String), 18 | } 19 | 20 | impl fmt::Display for Error { 21 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 22 | match self { 23 | Error::Sled(e) => write!(f, "Database operation failed: {e}"), 24 | #[cfg(feature = "v2")] 25 | Error::Serialize(e) => write!(f, "Serialization failed: {e}"), 26 | #[cfg(feature = "v2")] 27 | Error::Deserialize(e) => write!(f, "Deserialization failed: {e}"), 28 | #[cfg(feature = "v2")] 29 | Error::NotFound(key) => write!(f, "Key not found: {key}"), 30 | } 31 | } 32 | } 33 | 34 | impl std::error::Error for Error {} 35 | 36 | impl From for Error { 37 | fn from(error: SledError) -> Self { Error::Sled(error) } 38 | } 39 | -------------------------------------------------------------------------------- /payjoin-cli/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use payjoin::bitcoin::consensus::encode::serialize; 4 | use payjoin::bitcoin::OutPoint; 5 | use sled::IVec; 6 | 7 | pub(crate) mod error; 8 | use error::*; 9 | 10 | pub(crate) const DB_PATH: &str = "payjoin.sled"; 11 | 12 | pub(crate) struct Database(sled::Db); 13 | 14 | impl Database { 15 | pub(crate) fn create(path: impl AsRef) -> Result { 16 | let db = sled::open(path)?; 17 | Ok(Self(db)) 18 | } 19 | 20 | /// Inserts the input and returns true if the input was seen before, false otherwise. 21 | pub(crate) fn insert_input_seen_before(&self, input: OutPoint) -> Result { 22 | let key = serialize(&input); 23 | let was_seen_before = self.0.insert(key.as_slice(), IVec::from(vec![]))?.is_some(); 24 | self.0.flush()?; 25 | Ok(was_seen_before) 26 | } 27 | } 28 | 29 | #[cfg(feature = "v2")] 30 | pub(crate) mod v2; 31 | -------------------------------------------------------------------------------- /payjoin-cli/src/db/v2.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use bitcoincore_rpc::jsonrpc::serde_json; 4 | use payjoin::persist::{Persister, Value}; 5 | use payjoin::receive::v2::{Receiver, ReceiverToken, WithContext}; 6 | use payjoin::send::v2::{Sender, SenderToken, WithReplyKey}; 7 | use sled::Tree; 8 | use url::Url; 9 | 10 | use super::*; 11 | 12 | pub(crate) struct SenderPersister(Arc); 13 | impl SenderPersister { 14 | pub fn new(db: Arc) -> Self { Self(db) } 15 | } 16 | 17 | impl Persister> for SenderPersister { 18 | type Token = SenderToken; 19 | type Error = crate::db::error::Error; 20 | fn save( 21 | &mut self, 22 | value: Sender, 23 | ) -> std::result::Result { 24 | let send_tree = self.0 .0.open_tree("send_sessions")?; 25 | let key = value.key(); 26 | let value = serde_json::to_vec(&value).map_err(Error::Serialize)?; 27 | send_tree.insert(key.clone(), value.as_slice())?; 28 | send_tree.flush()?; 29 | Ok(key) 30 | } 31 | 32 | fn load(&self, key: SenderToken) -> std::result::Result, Self::Error> { 33 | let send_tree = self.0 .0.open_tree("send_sessions")?; 34 | let value = send_tree.get(key.as_ref())?.ok_or(Error::NotFound(key.to_string()))?; 35 | serde_json::from_slice(&value).map_err(Error::Deserialize) 36 | } 37 | } 38 | 39 | pub(crate) struct ReceiverPersister(Arc); 40 | impl ReceiverPersister { 41 | pub fn new(db: Arc) -> Self { Self(db) } 42 | } 43 | 44 | impl Persister> for ReceiverPersister { 45 | type Token = ReceiverToken; 46 | type Error = crate::db::error::Error; 47 | fn save( 48 | &mut self, 49 | value: Receiver, 50 | ) -> std::result::Result { 51 | let recv_tree = self.0 .0.open_tree("recv_sessions")?; 52 | let key = value.key(); 53 | let value = serde_json::to_vec(&value).map_err(Error::Serialize)?; 54 | recv_tree.insert(key.clone(), value.as_slice())?; 55 | recv_tree.flush()?; 56 | Ok(key) 57 | } 58 | fn load(&self, key: ReceiverToken) -> std::result::Result, Self::Error> { 59 | let recv_tree = self.0 .0.open_tree("recv_sessions")?; 60 | let value = recv_tree.get(key.as_ref())?.ok_or(Error::NotFound(key.to_string()))?; 61 | serde_json::from_slice(&value).map_err(Error::Deserialize) 62 | } 63 | } 64 | 65 | impl Database { 66 | pub(crate) fn get_recv_sessions(&self) -> Result>> { 67 | let recv_tree = self.0.open_tree("recv_sessions")?; 68 | let mut sessions = Vec::new(); 69 | for item in recv_tree.iter() { 70 | let (_, value) = item?; 71 | let session: Receiver = 72 | serde_json::from_slice(&value).map_err(Error::Deserialize)?; 73 | sessions.push(session); 74 | } 75 | Ok(sessions) 76 | } 77 | 78 | pub(crate) fn clear_recv_session(&self) -> Result<()> { 79 | let recv_tree: Tree = self.0.open_tree("recv_sessions")?; 80 | recv_tree.clear()?; 81 | recv_tree.flush()?; 82 | Ok(()) 83 | } 84 | 85 | pub(crate) fn get_send_sessions(&self) -> Result>> { 86 | let send_tree: Tree = self.0.open_tree("send_sessions")?; 87 | let mut sessions = Vec::new(); 88 | for item in send_tree.iter() { 89 | let (_, value) = item?; 90 | let session: Sender = 91 | serde_json::from_slice(&value).map_err(Error::Deserialize)?; 92 | sessions.push(session); 93 | } 94 | Ok(sessions) 95 | } 96 | 97 | pub(crate) fn get_send_session(&self, pj_url: &Url) -> Result>> { 98 | let send_tree = self.0.open_tree("send_sessions")?; 99 | if let Some(val) = send_tree.get(pj_url.as_str())? { 100 | let session: Sender = 101 | serde_json::from_slice(&val).map_err(Error::Deserialize)?; 102 | Ok(Some(session)) 103 | } else { 104 | Ok(None) 105 | } 106 | } 107 | 108 | pub(crate) fn clear_send_session(&self, pj_url: &Url) -> Result<()> { 109 | let send_tree: Tree = self.0.open_tree("send_sessions")?; 110 | send_tree.remove(pj_url.as_str())?; 111 | send_tree.flush()?; 112 | Ok(()) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /payjoin-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use app::config::Config; 3 | use app::App as AppTrait; 4 | use clap::Parser; 5 | use cli::{Cli, Commands}; 6 | 7 | mod app; 8 | mod cli; 9 | mod db; 10 | 11 | #[cfg(not(any(feature = "v1", feature = "v2")))] 12 | compile_error!("At least one of the features ['v1', 'v2'] must be enabled"); 13 | 14 | #[tokio::main] 15 | async fn main() -> Result<()> { 16 | env_logger::init(); 17 | 18 | let cli = Cli::parse(); 19 | let config = Config::new(&cli)?; 20 | 21 | #[allow(clippy::if_same_then_else)] 22 | let app: Box = if cli.flags.bip78.unwrap_or(false) { 23 | #[cfg(feature = "v1")] 24 | { 25 | Box::new(crate::app::v1::App::new(config)?) 26 | } 27 | #[cfg(not(feature = "v1"))] 28 | { 29 | anyhow::bail!( 30 | "BIP78 (v1) support is not enabled in this build. Recompile with --features v1" 31 | ) 32 | } 33 | } else if cli.flags.bip77.unwrap_or(false) { 34 | #[cfg(feature = "v2")] 35 | { 36 | Box::new(crate::app::v2::App::new(config)?) 37 | } 38 | #[cfg(not(feature = "v2"))] 39 | { 40 | anyhow::bail!( 41 | "BIP77 (v2) support is not enabled in this build. Recompile with --features v2" 42 | ) 43 | } 44 | } else { 45 | #[cfg(feature = "v2")] 46 | { 47 | Box::new(crate::app::v2::App::new(config)?) 48 | } 49 | #[cfg(all(feature = "v1", not(feature = "v2")))] 50 | { 51 | Box::new(crate::app::v1::App::new(config)?) 52 | } 53 | #[cfg(not(any(feature = "v1", feature = "v2")))] 54 | { 55 | anyhow::bail!("No valid version available - must compile with v1 or v2 feature") 56 | } 57 | }; 58 | 59 | match &cli.command { 60 | Commands::Send { bip21, fee_rate } => { 61 | app.send_payjoin(bip21, *fee_rate).await?; 62 | } 63 | Commands::Receive { amount, .. } => { 64 | app.receive_payjoin(*amount).await?; 65 | } 66 | #[cfg(feature = "v2")] 67 | Commands::Resume => { 68 | app.resume_payjoins().await?; 69 | } 70 | }; 71 | 72 | Ok(()) 73 | } 74 | -------------------------------------------------------------------------------- /payjoin-directory/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # payjoin-directory Changelog 2 | 3 | ## 0.0.2 4 | 5 | - Do not log ERROR on directory validation errors [#628](https://github.com/payjoin/rust-payjoin/pull/628) 6 | - Use payjoin 0.23.0 (056a39b8a8849451ee605dc7ae786f9cda31ace5) 7 | - Announce allowed purposes (6282ffb2c76a93e1849ecc1a84c9f54ccf152cc5) 8 | - Serve `/.well-known/ohttp-gateway` as per RFC 9540 (6282ffb2c76a93e1849ecc1a84c9f54ccf152cc5) 9 | - Rely on `payjoin/directory` feature module [#502](https://github.com/payjoin/rust-payjoin/pull/502) 10 | - Introduce db-module-specific `Result` [#488](https://github.com/payjoin/rust-payjoin/pull/488) 11 | - Return bound port on listen for test stability (d4fa3d440abd102fcbb061b721480dee14ff91dc) 12 | 13 | ## 0.0.1 14 | 15 | - Release initial payjoin-directory to store and forward payjoin payloads using secp256k1 hpke 16 | -------------------------------------------------------------------------------- /payjoin-directory/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "payjoin-directory" 3 | version = "0.0.2" 4 | authors = ["Dan Gould "] 5 | description = "A store-and-forward and Oblivious Gateway Resource directory server for Async Payjoin" 6 | repository = "https://github.com/payjoin/rust-payjoin" 7 | readme = "README.md" 8 | keywords = ["bip78", "bip77", "payjoin", "bitcoin", "ohttp"] 9 | categories = ["cryptography::cryptocurrencies", "network-programming"] 10 | license = "MITNFA" 11 | edition = "2021" 12 | rust-version = "1.63" 13 | resolver = "2" 14 | 15 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 16 | 17 | [features] 18 | _danger-local-https = ["hyper-rustls", "rustls", "tokio-rustls"] 19 | 20 | [dependencies] 21 | anyhow = "1.0.71" 22 | bitcoin = { version = "0.32.4", features = ["base64", "rand-std"] } 23 | bhttp = { version = "=0.5.1", features = ["http"] } 24 | futures = "0.3.17" 25 | http-body-util = "0.1.2" 26 | hyper = { version = "1", features = ["http1", "server"] } 27 | hyper-rustls = { version = "0.26", optional = true } 28 | hyper-util = { version = "0.1", features = ["tokio"] } 29 | ohttp = { package = "bitcoin-ohttp", version = "0.6.0"} 30 | payjoin = { version = "0.23.0", features = ["directory"], default-features = false } 31 | redis = { version = "0.23.3", features = ["aio", "tokio-comp"] } 32 | rustls = { version = "0.22.4", optional = true } 33 | tokio = { version = "1.12.0", features = ["full"] } 34 | tokio-rustls = { version = "0.25", features = ["ring"], default-features = false, optional = true } 35 | tracing = "0.1.37" 36 | tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } 37 | 38 | [dev-dependencies] 39 | tempfile = "3.5.0" 40 | -------------------------------------------------------------------------------- /payjoin-directory/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Rust image as the builder 2 | FROM --platform=linux/amd64 rust:1.81-slim as builder 3 | 4 | WORKDIR /usr/src/payjoin-directory 5 | 6 | # Install cross-compilation dependencies 7 | RUN apt-get update && \ 8 | apt-get install -y \ 9 | build-essential \ 10 | musl-tools \ 11 | musl-dev \ 12 | pkg-config \ 13 | gcc-multilib \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # Set the linker 17 | ENV CC_x86_64_unknown_linux_musl=musl-gcc 18 | ENV AR_x86_64_unknown_linux_musl=ar 19 | 20 | # Add the x86_64-unknown-linux-musl target 21 | RUN rustup target add x86_64-unknown-linux-musl 22 | 23 | # Copy the workspace manifest and source code 24 | COPY . . 25 | 26 | # Build the binary 27 | RUN cargo build --bin payjoin-directory --release --target x86_64-unknown-linux-musl 28 | 29 | # Create final minimal image 30 | FROM --platform=linux/amd64 alpine:latest 31 | 32 | # Copy the binary from builder 33 | COPY --from=builder /usr/src/payjoin-directory/target/x86_64-unknown-linux-musl/release/payjoin-directory ./ 34 | 35 | # Run the binary 36 | ENTRYPOINT ["./payjoin-directory"] -------------------------------------------------------------------------------- /payjoin-directory/README.md: -------------------------------------------------------------------------------- 1 | # Payjoin Directory 2 | 3 | [BIP 77](https://github.com/bitcoin/bips/blob/master/bip-0077.md) Async Payjoin (v2) 4 | peers store and forward HTTP client messages via a directory server in order to 5 | make asynchronous Payjoin transactions. This is a reference implementation of 6 | such a server 7 | 8 | V2 clients encapsulate requests using 9 | [Oblivious HTTP](https://www.ietf.org/rfc/rfc9458.html) (OHTTP) which allows 10 | them to make payjoins without the directory being able to link payjoins to 11 | specific client IP. Payjoin Directory is therefore an [Oblivious Gateway 12 | Resource](https://www.ietf.org/rfc/rfc9458.html#dfn-gateway). 13 | 14 | Payjoin Directory also behaves as an [unsecured public-facing HTTP 15 | server](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#unsecured-payjoin-server) 16 | in order to provide backwards-compatible support for [BIP 17 | 78](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) Payjoin (v1) 18 | clients. 19 | -------------------------------------------------------------------------------- /payjoin-directory/contrib/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | cargo test --locked --package payjoin-directory --verbose --all-features --lib 5 | -------------------------------------------------------------------------------- /payjoin-directory/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | image: nginx:latest 4 | ports: 5 | - "80:80" 6 | - "443:443" 7 | volumes: 8 | - ./nginx/logs:/var/log/nginx 9 | - ./nginx/conf.d:/etc/nginx/conf.d 10 | - ./nginx/certs:/etc/ssl/certs 11 | - ./nginx/html:/var/www/html 12 | networks: 13 | - payjoin-network 14 | 15 | certbot: 16 | image: certbot/certbot 17 | volumes: 18 | - ./nginx/certs:/etc/letsencrypt 19 | - ./nginx/html:/var/www/html 20 | entrypoint: /bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/html --deploy-hook "nginx -s reload"; sleep 12h & wait $${!}; done;' 21 | depends_on: 22 | - nginx 23 | networks: 24 | - payjoin-network 25 | 26 | payjoin-directory: 27 | image: dangould/payjoin-directory:0.0.1 28 | environment: 29 | RUST_LOG: "trace" 30 | PJ_DB_HOST: "redis:6379" 31 | PJ_DIR_PORT: "8080" 32 | depends_on: 33 | - redis 34 | networks: 35 | - payjoin-network 36 | 37 | redis: 38 | image: redis:latest 39 | volumes: 40 | - redis-data:/data 41 | networks: 42 | - payjoin-network 43 | 44 | networks: 45 | payjoin-network: 46 | 47 | volumes: 48 | redis-data: 49 | -------------------------------------------------------------------------------- /payjoin-directory/src/db.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use futures::StreamExt; 4 | use payjoin::directory::ShortId; 5 | use redis::{AsyncCommands, Client, ErrorKind, RedisError, RedisResult}; 6 | use tracing::debug; 7 | 8 | const DEFAULT_COLUMN: &str = ""; 9 | const PJ_V1_COLUMN: &str = "pjv1"; 10 | 11 | #[derive(Debug, Clone)] 12 | pub(crate) struct DbPool { 13 | client: Client, 14 | timeout: Duration, 15 | } 16 | 17 | /// Errors pertaining to [`DbPool`] 18 | #[derive(Debug)] 19 | pub(crate) enum Error { 20 | Redis(RedisError), 21 | Timeout(tokio::time::error::Elapsed), 22 | } 23 | 24 | impl std::fmt::Display for Error { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | use Error::*; 27 | 28 | match &self { 29 | Redis(error) => write!(f, "Redis error: {error}"), 30 | Timeout(timeout) => write!(f, "Timeout: {timeout}"), 31 | } 32 | } 33 | } 34 | 35 | impl std::error::Error for Error { 36 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 37 | match self { 38 | Error::Redis(e) => Some(e), 39 | Error::Timeout(e) => Some(e), 40 | } 41 | } 42 | } 43 | 44 | impl From for Error { 45 | fn from(value: RedisError) -> Self { Error::Redis(value) } 46 | } 47 | 48 | pub(crate) type Result = core::result::Result; 49 | 50 | impl DbPool { 51 | pub async fn new(timeout: Duration, db_host: String) -> Result { 52 | let client = Client::open(format!("redis://{db_host}"))?; 53 | Ok(Self { client, timeout }) 54 | } 55 | 56 | /// Peek using [`DEFAULT_COLUMN`] as the channel type. 57 | pub async fn push_default(&self, subdirectory_id: &ShortId, data: Vec) -> Result<()> { 58 | self.push(subdirectory_id, DEFAULT_COLUMN, data).await 59 | } 60 | 61 | pub async fn peek_default(&self, subdirectory_id: &ShortId) -> Result> { 62 | self.peek_with_timeout(subdirectory_id, DEFAULT_COLUMN).await 63 | } 64 | 65 | pub async fn push_v1(&self, subdirectory_id: &ShortId, data: Vec) -> Result<()> { 66 | self.push(subdirectory_id, PJ_V1_COLUMN, data).await 67 | } 68 | 69 | /// Peek using [`PJ_V1_COLUMN`] as the channel type. 70 | pub async fn peek_v1(&self, subdirectory_id: &ShortId) -> Result> { 71 | self.peek_with_timeout(subdirectory_id, PJ_V1_COLUMN).await 72 | } 73 | 74 | async fn push( 75 | &self, 76 | subdirectory_id: &ShortId, 77 | channel_type: &str, 78 | data: Vec, 79 | ) -> Result<()> { 80 | let mut conn = self.client.get_async_connection().await?; 81 | let key = channel_name(subdirectory_id, channel_type); 82 | () = conn.set(&key, data.clone()).await?; 83 | () = conn.publish(&key, "updated").await?; 84 | Ok(()) 85 | } 86 | 87 | async fn peek_with_timeout( 88 | &self, 89 | subdirectory_id: &ShortId, 90 | channel_type: &str, 91 | ) -> Result> { 92 | match tokio::time::timeout(self.timeout, self.peek(subdirectory_id, channel_type)).await { 93 | Ok(redis_result) => match redis_result { 94 | Ok(result) => Ok(result), 95 | Err(redis_err) => Err(Error::Redis(redis_err)), 96 | }, 97 | Err(elapsed) => Err(Error::Timeout(elapsed)), 98 | } 99 | } 100 | 101 | async fn peek(&self, subdirectory_id: &ShortId, channel_type: &str) -> RedisResult> { 102 | let mut conn = self.client.get_async_connection().await?; 103 | let key = channel_name(subdirectory_id, channel_type); 104 | 105 | // Attempt to fetch existing content for the given subdirectory_id and channel_type 106 | if let Ok(data) = conn.get::<_, Vec>(&key).await { 107 | if !data.is_empty() { 108 | return Ok(data); 109 | } 110 | } 111 | debug!("Failed to fetch content initially"); 112 | 113 | // Set up a temporary listener for changes 114 | let mut pubsub_conn = self.client.get_async_connection().await?.into_pubsub(); 115 | let channel_name = channel_name(subdirectory_id, channel_type); 116 | pubsub_conn.subscribe(&channel_name).await?; 117 | 118 | // Use a block to limit the scope of the mutable borrow 119 | let data = { 120 | let mut message_stream = pubsub_conn.on_message(); 121 | 122 | loop { 123 | match message_stream.next().await { 124 | Some(msg) => { 125 | () = msg.get_payload()?; // Notification received 126 | // Try fetching the data again 127 | if let Some(data) = conn.get::<_, Option>>(&key).await? { 128 | if !data.is_empty() { 129 | break data; // Exit the block, returning the data 130 | } 131 | } 132 | } 133 | None => 134 | return Err(RedisError::from(( 135 | ErrorKind::IoError, 136 | "PubSub connection closed", 137 | ))), 138 | } 139 | } 140 | }; 141 | 142 | // Since the stream is dropped here, we can now unsubscribe 143 | pubsub_conn.unsubscribe(&channel_name).await?; 144 | 145 | Ok(data) 146 | } 147 | } 148 | 149 | fn channel_name(subdirectory_id: &ShortId, channel_type: &str) -> Vec { 150 | (subdirectory_id.to_string() + channel_type).into_bytes() 151 | } 152 | -------------------------------------------------------------------------------- /payjoin-directory/src/key_config.rs: -------------------------------------------------------------------------------- 1 | //! Manage the OHTTP key configuration 2 | 3 | use std::fs; 4 | use std::path::{Path, PathBuf}; 5 | 6 | use anyhow::{anyhow, Result}; 7 | use ohttp::hpke::{Aead, Kdf, Kem}; 8 | use ohttp::SymmetricSuite; 9 | use tracing::info; 10 | 11 | const KEY_ID: u8 = 1; 12 | const KEM: Kem = Kem::K256Sha256; 13 | const SYMMETRIC: &[SymmetricSuite] = 14 | &[SymmetricSuite::new(Kdf::HkdfSha256, Aead::ChaCha20Poly1305)]; 15 | 16 | /// OHTTP server key configuration 17 | /// 18 | /// This is combined so that the test path and the prod path both use the same 19 | /// code. The ServerKeyConfig.ikm is persisted to the configured path, and the 20 | /// server is used to run the directory server. 21 | #[derive(Debug, Clone)] 22 | pub struct ServerKeyConfig { 23 | ikm: [u8; 32], 24 | server: ohttp::Server, 25 | } 26 | 27 | impl From for ohttp::Server { 28 | fn from(value: ServerKeyConfig) -> Self { value.server } 29 | } 30 | 31 | /// Generate a new OHTTP server key configuration 32 | pub fn gen_ohttp_server_config() -> Result { 33 | let ikm = bitcoin::key::rand::random::<[u8; 32]>(); 34 | let config = ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC))?; 35 | Ok(ServerKeyConfig { ikm, server: ohttp::Server::new(config)? }) 36 | } 37 | 38 | /// Persist an OHTTP Key Configuration to the default path 39 | pub fn persist_new_key_config(ohttp_config: ServerKeyConfig, dir: &Path) -> Result { 40 | use std::fs::OpenOptions; 41 | use std::io::Write; 42 | 43 | let key_path = key_path(dir); 44 | 45 | let mut file = OpenOptions::new() 46 | .write(true) 47 | .create_new(true) 48 | .open(&key_path) 49 | .map_err(|e| anyhow!("Failed to create new OHTTP key file: {}", e))?; 50 | 51 | file.write_all(&ohttp_config.ikm) 52 | .map_err(|e| anyhow!("Failed to write OHTTP keys to file: {}", e))?; 53 | info!("Saved OHTTP Key Configuration to {}", &key_path.display()); 54 | 55 | Ok(key_path) 56 | } 57 | 58 | /// Read the configured server from the default path 59 | /// May panic if key exists but is the unexpected format. 60 | pub fn read_server_config(dir: &Path) -> Result { 61 | let key_path = key_path(dir); 62 | let ikm: [u8; 32] = fs::read(&key_path) 63 | .map_err(|e| anyhow!("Failed to read OHTTP key file: {}", e))? 64 | .try_into() 65 | .expect("Key wrong size: expected 32 bytes"); 66 | 67 | let server_config = ohttp::KeyConfig::derive(KEY_ID, KEM, SYMMETRIC.to_vec(), &ikm) 68 | .expect("Failed to derive OHTTP keys from file"); 69 | 70 | info!("Loaded existing OHTTP Key Configuration from {}", key_path.display()); 71 | Ok(ServerKeyConfig { ikm, server: ohttp::Server::new(server_config)? }) 72 | } 73 | 74 | /// Get the path to the key configuration file 75 | /// For now, default to [KEY_ID].ikm. 76 | /// In the future this might be able to save multiple keys named by KeyId. 77 | fn key_path(dir: &Path) -> PathBuf { dir.join(format!("{KEY_ID}.ikm")) } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use super::*; 82 | 83 | #[test] 84 | fn round_trip_server_config() { 85 | let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); 86 | let ohttp_config = gen_ohttp_server_config().expect("Failed to generate server config"); 87 | let _path = persist_new_key_config(ohttp_config.clone(), temp_dir.path()) 88 | .expect("Failed to persist server config"); 89 | let ohttp_config_again = 90 | read_server_config(temp_dir.path()).expect("Failed to read server config"); 91 | assert_eq!(ohttp_config.ikm, ohttp_config_again.ikm); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /payjoin-directory/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use payjoin_directory::*; 4 | use tracing_subscriber::filter::LevelFilter; 5 | use tracing_subscriber::EnvFilter; 6 | 7 | const DEFAULT_KEY_CONFIG_DIR: &str = "ohttp_keys"; 8 | 9 | #[tokio::main] 10 | async fn main() -> Result<(), Box> { 11 | init_logging(); 12 | 13 | let dir_port = 14 | env::var("PJ_DIR_PORT").map_or(DEFAULT_DIR_PORT, |s| s.parse().expect("Invalid port")); 15 | 16 | let timeout_env = env::var("PJ_DIR_TIMEOUT_SECS") 17 | .map_or(DEFAULT_TIMEOUT_SECS, |s| s.parse().expect("Invalid timeout")); 18 | let timeout = std::time::Duration::from_secs(timeout_env); 19 | 20 | let db_host = env::var("PJ_DB_HOST").unwrap_or_else(|_| DEFAULT_DB_HOST.to_string()); 21 | 22 | let key_dir = 23 | std::env::var("PJ_OHTTP_KEY_DIR").map(std::path::PathBuf::from).unwrap_or_else(|_| { 24 | let key_dir = std::path::PathBuf::from(DEFAULT_KEY_CONFIG_DIR); 25 | std::fs::create_dir_all(&key_dir).expect("Failed to create key directory"); 26 | key_dir 27 | }); 28 | 29 | let ohttp = match key_config::read_server_config(&key_dir) { 30 | Ok(config) => config, 31 | Err(_) => { 32 | let ohttp_config = key_config::gen_ohttp_server_config()?; 33 | let path = key_config::persist_new_key_config(ohttp_config, &key_dir)?; 34 | println!("Generated new key configuration at {}", path.display()); 35 | key_config::read_server_config(&key_dir).expect("Failed to read newly generated config") 36 | } 37 | }; 38 | 39 | payjoin_directory::listen_tcp(dir_port, db_host, timeout, ohttp.into()).await 40 | } 41 | 42 | fn init_logging() { 43 | let env_filter = 44 | EnvFilter::builder().with_default_directive(LevelFilter::INFO.into()).from_env_lossy(); 45 | 46 | tracing_subscriber::fmt().with_target(true).with_level(true).with_env_filter(env_filter).init(); 47 | 48 | println!("Logging initialized"); 49 | } 50 | -------------------------------------------------------------------------------- /payjoin-ffi/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | target/ 4 | 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | .vscode/settings.json 9 | .idea 10 | 11 | # Python related 12 | __pycache__ 13 | 14 | 15 | /python/.vscode/ 16 | /python/payjoin.egg-info/ 17 | /python/.venv/ 18 | /python/.env 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /payjoin-ffi/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.23.0] 2 | 3 | - Update to payjoin-0.23.0 4 | - Expose many error variants 5 | ([#58](https://github.com/LtbLightning/payjoin-ffi/pull/58)) 6 | ([#71](https://github.com/LtbLightning/payjoin-ffi/pull/71)) 7 | - Bind payjoin-test-utils ([#82](https://github.com/LtbLightning/payjoin-ffi/pull/82)) 8 | - Depend on bitcoin-ffi @ 6b1d1315dff8696b5ffeb3e5669f308ade227749 9 | - Rename to payjoin-ffi from payjoin_ffi to match bitcoin-ffi 10 | 11 | ## [0.22.1] 12 | - Expose label and message params on Uri. ([#44](https://github.com/LtbLightning/payjoin-ffi/pull/44)) 13 | 14 | ## [0.22.0] 15 | - Update `payjoin` to `0.22.0`. (Serialize reply_key with Sender [#41](https://github.com/LtbLightning/payjoin-ffi/pull/41)) 16 | 17 | ## [0.21.2] 18 | - Add `pj_endpoint` method to `PjUri` types. ([#40](https://github.com/LtbLightning/payjoin-ffi/pull/40)) 19 | 20 | ## [0.21.1] 21 | - Add `to_json` and `from_json` methods to `Sender` and `Receiver` UniFFI types. ([#39](https://github.com/LtbLightning/payjoin-ffi/pull/39)) 22 | 23 | ## [0.21.0] 24 | This release updates the bindings libraries to `payjoin` version `0.21.0`. 25 | #### APIs changed 26 | - Major overhaul to attempt a stable BIP 77 protocol implementation. 27 | - v1 support is now only available through the V2 backwards-compatible APIs. 28 | - see [payjoin-0.21.0 changelog](https://github.com/payjoin/rust-payjoin/blob/master/payjoin/CHANGELOG.md#0210) for more details. 29 | - Separate `payjoin_ffi` and `payjoin_ffi::uni` UniFFI types into two layers. 30 | 31 | ## [0.20.0] 32 | #### APIs added 33 | - Make backwards-compatible `v2` to `v1` sends possible. 34 | #### APIs changed 35 | - Removed `contribute_non_nitness_input` from `v1` & `v2`. 36 | - Allow receivers to make `payjoins` out of sweep transactions ([#259](https://github.com/payjoin/rust-payjoin/pull/259)). 37 | - Encode &ohttp= and &exp= parameters in the &pj= URL as a fragment instead of as URI params ([#298](https://github.com/payjoin/rust-payjoin/pull/298)) 38 | 39 | ## [0.18.0] 40 | This release updates the bindings libraries to `payjoin` version `0.18.0`. 41 | #### APIs changed 42 | - Upgrade `receive/v2` type state machine to resume multiple `payjoins` simultaneously ([#283](https://github.com/payjoin/rust-payjoin/pull/283)) 43 | - Refactor output substitution with new fallable `try_substitute_outputs` ([#277](https://github.com/payjoin/rust-payjoin/pull/277)) 44 | - Replaced `Enroller` with `SessionInitializer`. 45 | - Replaced `Enrolled` with `ActiveSession`. 46 | - Replaced `fallback_target()` with `pj_url`. 47 | #### APIs added 48 | - Exposed `PjUriBuilder` and `PjUri`. 49 | - Exposed `pjUrl_builder()` in `ActiveSession`. 50 | - Exposed `check_pj_supported()` in `PjUri`. 51 | - Exposed `fetch_ohttp_keys()` to fetch the `ohttp` keys from the specified `payjoin` directory. 52 | 53 | ## [0.13.0] 54 | ### Features & Modules 55 | #### Send module 56 | - ##### V1 57 | - `RequestBuilder` exposes `from_psbt_and_uri`, `build_with_additional_fee`, `build_recommended`, `build_non_incentivizing`, `always_disable_output_substitution`. 58 | - `RequestContext` exposes `extract_contextV1` & `extract_contextV2`. 59 | - `ContextV1` exposes `process_response`. 60 | - ##### V2 61 | - `ContextV2` exposes `process_response`. 62 | #### Receive module 63 | - ##### V1 64 | - `UncheckedProposal` exposes `from_request`, `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability`, `build_non_incentivizing`, 65 | `assume_interactive_receiver` &`always_disable_output_substitution`. 66 | - `MaybeInputsOwned` exposes `check_inputs_not_owned`. 67 | - `MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. 68 | - `MaybeInputsSeen` exposes `check_no_inputs_seen_before`. 69 | - `OutputsUnknown` exposes `identify_receiver_outputs`. 70 | - `ProvisionalProposal` exposes `substitute_output_address`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & 71 | `finalize_proposal`. 72 | - `PayjoinProposal` exposes `is_output_substitution_disabled`, `owned_vouts`, `psbt` & `utxos_to_be_locked`. 73 | - ##### V2 74 | - `Enroller` exposes `from_directory_config`, `process_response` & `extract_request`. 75 | - `Enrolled` exposes `extract_request`, `process_response` & `fall_back_target`. 76 | - `V2UncheckedProposal` exposes `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability` & `assume_interactive_receiver`. 77 | - `V2MaybeInputsOwned` exposes `check_inputs_not_owned`. 78 | - `V2MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. 79 | - `V2MaybeInputsSeen` exposes `check_no_inputs_seen_before`. 80 | - `V2OutputsUnknown` exposes `identify_receiver_outputs`. 81 | - `V2ProvisionalProposal` exposes `substitute_output_address`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & 82 | `finalize_proposal`. 83 | - `V2PayjoinProposal` exposes `deserialize_res`, `extract_v1_req`, `extract_v2_req`, `is_output_substitution_disabled`, `owned_vouts`, `psbt` & 84 | `utxos_to_be_locked`. 85 | -------------------------------------------------------------------------------- /payjoin-ffi/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "payjoin-ffi" 3 | version = "0.23.0" 4 | edition = "2021" 5 | license = "MIT OR Apache-2.0" 6 | exclude = ["tests"] 7 | 8 | [features] 9 | _test-utils = ["payjoin-test-utils", "tokio", "bitcoind"] 10 | _danger-local-https = ["payjoin/_danger-local-https"] 11 | uniffi = ["uniffi/cli", "bitcoin-ffi/default"] 12 | 13 | [lib] 14 | name = "payjoin_ffi" 15 | crate-type = ["lib", "staticlib", "cdylib"] 16 | 17 | [[bin]] 18 | name = "uniffi-bindgen" 19 | path = "uniffi-bindgen.rs" 20 | 21 | [build-dependencies] 22 | uniffi = { version = "0.29.1", features = ["build"] } 23 | 24 | [dependencies] 25 | base64 = "0.22.1" 26 | bitcoind = { version = "0.36.0", features = ["0_21_2"], optional = true } 27 | bitcoin-ffi = { git = "https://github.com/benalleng/bitcoin-ffi.git", rev = "8e3a23b" } 28 | hex = "0.4.3" 29 | lazy_static = "1.5.0" 30 | ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } 31 | payjoin = { version = "0.23.0", features = ["v1", "v2", "io"] } 32 | payjoin-test-utils = { version = "0.0.0", optional = true } 33 | serde = { version = "1.0.200", features = ["derive"] } 34 | serde_json = "1.0.128" 35 | thiserror = "1.0.58" 36 | tokio = { version = "1.38.0", features = ["full"], optional = true } 37 | uniffi = { version = "0.29.1", optional = true } 38 | url = "2.5.0" 39 | 40 | [dev-dependencies] 41 | bdk = { version = "0.29.0", features = ["all-keys", "use-esplora-ureq", "keys-bip39", "rpc"] } 42 | bitcoincore-rpc = "0.19.0" 43 | http = "1" 44 | ohttp-relay = "0.0.8" 45 | rcgen = { version = "0.11" } 46 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } 47 | rustls = "0.22.2" 48 | testcontainers = "0.15.0" 49 | testcontainers-modules = { version = "0.1.3", features = ["redis"] } 50 | uniffi = { version = "0.29.1", features = ["bindgen-tests"] } 51 | 52 | [profile.release-smaller] 53 | inherits = "release" 54 | opt-level = 'z' 55 | lto = true 56 | codegen-units = 1 57 | strip = true 58 | 59 | [patch.crates-io.payjoin] 60 | path = "../payjoin" 61 | -------------------------------------------------------------------------------- /payjoin-ffi/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 | -------------------------------------------------------------------------------- /payjoin-ffi/LICENSE.md: -------------------------------------------------------------------------------- 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. 15 | -------------------------------------------------------------------------------- /payjoin-ffi/README.md: -------------------------------------------------------------------------------- 1 | # Payjoin Language Bindings 2 | 3 | Welcome! This repository creates libraries for various programming languages, all using the Rust-based [Payjoin](https://github.com/payjoin/rust-payjoin) as the core implementation of BIP-77, sourced from the [Payjoin Dev Kit](https://payjoindevkit.org/). 4 | 5 | Our mission is to provide developers with cross-language libraries that seamlessly integrate with different platform languages. By offering support for multiple languages, we aim to enhance the accessibility and usability of Payjoin, empowering developers to incorporate this privacy-enhancing feature into their applications, no matter their preferred programming language. 6 | 7 | With a commitment to collaboration and interoperability, this repository strives to foster a more inclusive and diverse ecosystem around Payjoin, contributing to the wider adoption of privacy-focused practices within the Bitcoin community. Join us in our mission to build a more private and secure future for Bitcoin transactions through Payjoin! 8 | 9 | **Current Status:** 10 | This project is in the pre-alpha stage and currently in the design phase. The first language bindings available will be for Python, followed by Swift and Kotlin. Our ultimate goal is to provide Payjoin implementations for Android, iOS, Java, React, Python Native, Flutter, C#, and Golang. 11 | 12 | ## Supported Target Languages and Platforms 13 | 14 | Each supported language and the platform(s) it's packaged for has its own directory. The Rust code in this project is in the `src` directory and is a wrapper around the [Payjoin Dev Kit] to expose its APIs uniformly using the [mozilla/uniffi-rs] bindings generator for each supported target language. 15 | 16 | The directories below include instructions for using, building, and publishing the native language bindings for [Payjoin Dev Kit] supported by this project. 17 | 18 | | Language | Platform | Published Package | Building Documentation | API Docs | 19 | |----------|-----------------------|-------------------|------------------------------------|----------| 20 | | Python | linux, macOS | payjoin | [Readme payjoin](python/README.md) | | 21 | 22 | ## Minimum Supported Rust Version (MSRV) 23 | 24 | This library should compile with any combination of features with Rust 1.78.0. 25 | 26 | ## Using the Libraries 27 | 28 | ### Python 29 | 30 | ```shell 31 | pip install payjoin 32 | 33 | ``` 34 | ## Running the Integration Test 35 | 36 | 37 | The integration tests illustrates and verify integration using bitcoin core and bdk. 38 | 39 | ```shell 40 | 41 | # Run the integration test 42 | cargo test --package payjoin_ffi --test bdk_integration_test v2_to_v2_full_cycle --features _danger-local-https 43 | 44 | 45 | ``` 46 | ## References 47 | 48 | [Payjoin Dev Kit](https://payjoindevkit.org/) 49 | 50 | [mozilla/uniffi-rs](https://github.com/mozilla/uniffi-rs) 51 | 52 | ## Release Status and Disclaimer 53 | 54 | This project is in active development and currently in its Alpha stage. **Please proceed with caution**, particularly when using real funds. 55 | We encourage thorough review, testing, and contributions to help improve its stability and security before considering production use. 56 | -------------------------------------------------------------------------------- /payjoin-ffi/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(feature = "uniffi")] 3 | uniffi::generate_scaffolding("src/payjoin_ffi.udl").unwrap(); 4 | } 5 | -------------------------------------------------------------------------------- /payjoin-ffi/contrib/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | RUST_VERSION=$(rustc --version | awk '{print $2}') 5 | 6 | if [[ ! "$RUST_VERSION" =~ ^1\.63\. ]]; then 7 | cargo test --package payjoin-ffi --verbose --features=_danger-local-https,_test-utils 8 | else 9 | echo "Skipping payjoin-ffi tests for Rust version $RUST_VERSION (MSRV)" 10 | fi 11 | -------------------------------------------------------------------------------- /payjoin-ffi/python/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | payjoin.egg-info/ 4 | __pycache__/ 5 | .idea/ 6 | .DS_Store 7 | 8 | *.swp 9 | *.whl 10 | build/ 11 | venv 12 | 13 | # Auto-generated shared libraries 14 | *.dylib 15 | *.so 16 | *.dll 17 | 18 | # Auto-generated bindings python file 19 | src/payjoin/payjoin_ffi.py 20 | src/payjoin/bitcoin.py 21 | -------------------------------------------------------------------------------- /payjoin-ffi/python/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.20.0] 2 | #### APIs added 3 | - Make backwards-compatible `v2` to `v1` sends possible. 4 | #### APIs changed 5 | - Removed `contribute_non_nitness_input` from `v1` & `v2`. 6 | - Allow receivers to make `payjoins` out of sweep transactions ([#259](https://github.com/payjoin/rust-payjoin/pull/259)). 7 | - Encode &ohttp= and &exp= parameters in the &pj= URL as a fragment instead of as URI params ([#298](https://github.com/payjoin/rust-payjoin/pull/298)) 8 | 9 | ## [0.18.0] 10 | This release updates the python library to `payjoin` version `0.18.0`. 11 | ### Features & Modules 12 | #### Send module 13 | - ##### V1 14 | - `RequestBuilder` exposes `from_psbt_and_uri`, `build_with_additional_fee`, `build_recommended`, `build_non_incentivizing`, 15 | `always_disable_output_substitution`. 16 | - `RequestContext` exposes `extract_contextV1` & `extract_contextV2`. 17 | - `ContextV1` exposes `process_response`. 18 | - ##### V2 19 | - `ContextV2` exposes `process_response`. 20 | #### Receive module 21 | - ##### V1 22 | - `UncheckedProposal` exposes `from_request`, `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability`, `build_non_incentivizing`, 23 | `assume_interactive_receiver` & `always_disable_output_substitution`. 24 | - `MaybeInputsOwned` exposes `check_inputs_not_owned`. 25 | - `MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. 26 | - `MaybeInputsSeen` exposes `check_no_inputs_seen_before`. 27 | - `OutputsUnknown` exposes `identify_receiver_outputs`. 28 | - `ProvisionalProposal` exposes `try_substitute_receiver_output`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & 29 | `finalize_proposal`. 30 | - `PayjoinProposal` exposes `is_output_substitution_disabled`, `owned_vouts`, `psbt` & `utxos_to_be_locked`. 31 | - ##### V2 32 | - `SessionInitializer` exposes `from_directory_config`, `process_res` & `extract_request`. 33 | - `ActiveSession` exposes `extract_request`, `process_res`, `pj_uri_builder` & `pj_url`. 34 | - `V2UncheckedProposal` exposes `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability` & `assume_interactive_receiver`. 35 | - `V2MaybeInputsOwned` exposes `check_inputs_not_owned`. 36 | - `V2MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. 37 | - `V2MaybeInputsSeen` exposes `check_no_inputs_seen_before`. 38 | - `V2OutputsUnknown` exposes `identify_receiver_outputs`. 39 | - `V2ProvisionalProposal` exposes `try_substitute_receiver_output`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & 40 | `finalize_proposal`. 41 | - `V2PayjoinProposal` exposes `process_res`, `extract_v1_req`, `extract_v2_req`, `is_output_substitution_disabled`, `owned_vouts`, `psbt` & 42 | `utxos_to_be_locked`. 43 | #### io module 44 | - Exposed `fetch_ohttp_keys()` to fetch the `ohttp` keys from the specified `payjoin` directory. -------------------------------------------------------------------------------- /payjoin-ffi/python/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include ./src/payjoin/libpayjoin_ffi.dylib 2 | include ./src/payjoin/payjoin_ffi.dll 3 | include ./src/payjoin/libpayjoin_ffi.so -------------------------------------------------------------------------------- /payjoin-ffi/python/README.md: -------------------------------------------------------------------------------- 1 | # Payjoin 2 | 3 | Welcome to the Python language bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/)! Let's get you up and running with some smooth transactions and a sprinkle of fun. 4 | 5 | ## Install from PyPI 6 | 7 | Grab the latest release with a simple: 8 | 9 | ```shell 10 | pip install payjoin 11 | ``` 12 | 13 | ## Running Tests 14 | 15 | Follow these steps to clone the repository and run the tests. 16 | 17 | 18 | ```shell 19 | git clone https://github.com/payjoin/rust-payjoin.git 20 | cd rust-payjoin/payjoin-ffi/python 21 | 22 | # Setup a python virtual environment 23 | python -m venv venv 24 | source venv/bin/activate 25 | 26 | # Install dependencies 27 | pip install --requirement requirements.txt --requirement requirements-dev.txt 28 | 29 | # Generate the bindings (use the script appropriate for your platform) 30 | PYBIN="./venv/bin/" bash ./scripts/generate_.sh 31 | 32 | # Build the wheel 33 | python setup.py bdist_wheel --verbose 34 | 35 | # Force reinstall payjoin 36 | pip install ./dist/payjoin-.whl --force-reinstall 37 | 38 | # Run all tests 39 | python -m unittest --verbose 40 | ``` 41 | 42 | Note that you'll need Docker to run the integration tests. If you get a "Failed to start container" error, ensure the Docker engine is running on your machine. 43 | You can [filter which tests](https://docs.python.org/3/library/unittest.html#command-line-interface) to run by passing a file or test name as argument. 44 | 45 | ## Building the Package 46 | 47 | ```shell 48 | # Setup a python virtual environment 49 | python -m venv venv 50 | source venv/bin/activate 51 | 52 | # Install dependencies 53 | pip install --requirement requirements.txt 54 | 55 | # Generate the bindings (use the script appropriate for your platform) 56 | PYBIN="./venv/bin/" bash ./scripts/generate_.sh 57 | 58 | # Build the wheel 59 | python setup.py --verbose bdist_wheel 60 | 61 | ``` 62 | We hope everything worked smoothly! Now go forth test, and may your test results be as reliable as the Bitcoin blockchain itself! 63 | ₿🔒🤝 64 | -------------------------------------------------------------------------------- /payjoin-ffi/python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "setuptools-rust"] 3 | 4 | [tool.pytest.ini_options] 5 | pythonpath = [ 6 | "." 7 | ] -------------------------------------------------------------------------------- /payjoin-ffi/python/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | python-bitcoinlib==0.12.2 2 | toml==0.10.2 3 | yapf==0.43.0 4 | httpx==0.28.1 5 | -------------------------------------------------------------------------------- /payjoin-ffi/python/requirements.txt: -------------------------------------------------------------------------------- 1 | semantic-version==2.9.0 2 | typing_extensions==4.0.1 3 | setuptools==78.1.1 4 | wheel==0.38.4 5 | -------------------------------------------------------------------------------- /payjoin-ffi/python/scripts/bindgen_generate.sh: -------------------------------------------------------------------------------- 1 | 2 | 3 | #!/bin/bash 4 | chmod +x ./scripts/generate_linux.sh 5 | chmod +x ./scripts/generate_macos.sh 6 | 7 | 8 | # Run each script 9 | scripts/generate_linux.sh 10 | scripts/generate_macos.sh 11 | -------------------------------------------------------------------------------- /payjoin-ffi/python/scripts/generate_linux.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | ${PYBIN}/python --version 4 | ${PYBIN}/pip install -r requirements.txt -r requirements-dev.txt 5 | LIBNAME=libpayjoin_ffi.so 6 | LINUX_TARGET=x86_64-unknown-linux-gnu 7 | 8 | echo "Generating payjoin_ffi.py..." 9 | cd ../ 10 | # This is a test script the actual release should not include the test utils feature 11 | cargo build --profile release --features uniffi,_test-utils 12 | cargo run --profile release --features uniffi,_test-utils --bin uniffi-bindgen generate --library target/release/$LIBNAME --language python --out-dir python/src/payjoin/ 13 | 14 | echo "Generating native binaries..." 15 | rustup target add $LINUX_TARGET 16 | # This is a test script the actual release should not include the test utils feature 17 | cargo build --profile release-smaller --target $LINUX_TARGET --features uniffi,_test-utils 18 | 19 | echo "Copying linux payjoin_ffi.so" 20 | cp target/$LINUX_TARGET/release-smaller/$LIBNAME python/src/payjoin/$LIBNAME 21 | 22 | echo "All done!" 23 | -------------------------------------------------------------------------------- /payjoin-ffi/python/scripts/generate_macos.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | python3 --version 5 | pip install -r requirements.txt -r requirements-dev.txt 6 | LIBNAME=libpayjoin_ffi.dylib 7 | 8 | echo "Generating payjoin_ffi.py..." 9 | cd ../ 10 | # This is a test script the actual release should not include the test utils feature 11 | cargo build --features uniffi,_test-utils --profile release 12 | cargo run --features uniffi,_test-utils --profile release --bin uniffi-bindgen generate --library target/release/$LIBNAME --language python --out-dir python/src/payjoin/ 13 | 14 | echo "Generating native binaries..." 15 | rustup target add aarch64-apple-darwin x86_64-apple-darwin 16 | 17 | # This is a test script the actual release should not include the test utils feature 18 | cargo build --profile release-smaller --target aarch64-apple-darwin --features uniffi,_test-utils 19 | echo "Done building aarch64-apple-darwin" 20 | 21 | # This is a test script the actual release should not include the test utils feature 22 | cargo build --profile release-smaller --target x86_64-apple-darwin --features uniffi,_test-utils 23 | echo "Done building x86_64-apple-darwin" 24 | 25 | echo "Building macos fat library" 26 | 27 | lipo -create -output python/src/payjoin/$LIBNAME \ 28 | target/aarch64-apple-darwin/release-smaller/$LIBNAME \ 29 | target/x86_64-apple-darwin/release-smaller/$LIBNAME 30 | 31 | 32 | echo "All done!" 33 | -------------------------------------------------------------------------------- /payjoin-ffi/python/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from setuptools import setup 5 | import toml 6 | 7 | # Read version from Cargo.toml 8 | cargo_toml_path = os.path.join(os.path.dirname(__file__), '..', 'Cargo.toml') 9 | cargo_toml = toml.load(cargo_toml_path) 10 | version = cargo_toml['package']['version'] 11 | 12 | LONG_DESCRIPTION = """# payjoin 13 | This repository creates libraries for various programming languages, all using the Rust-based [Payjoin](https://github.com/payjoin/rust-payjoin) 14 | as the core implementation of BIP77, sourced from the [Payjoin Dev Kit](https://payjoindevkit.org/). 15 | 16 | ## Install the package 17 | ```shell 18 | pip install payjoin 19 | ``` 20 | 21 | ## Usage 22 | ```python 23 | import payjoin as payjoin 24 | """ 25 | 26 | setup( 27 | name="payjoin", 28 | description="The Python language bindings for the Payjoin Dev Kit", 29 | long_description=LONG_DESCRIPTION, 30 | long_description_content_type="text/markdown", 31 | include_package_data=True, 32 | zip_safe=False, 33 | packages=["payjoin"], 34 | package_dir={"payjoin": "./src/payjoin"}, 35 | version=version, 36 | license="MIT or Apache 2.0", 37 | has_ext_modules=lambda: True, 38 | ) 39 | -------------------------------------------------------------------------------- /payjoin-ffi/python/src/payjoin/__init__.py: -------------------------------------------------------------------------------- 1 | from payjoin.payjoin_ffi import * -------------------------------------------------------------------------------- /payjoin-ffi/python/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/payjoin/rust-payjoin/1063687eb64e401799eb970a9f7cd59f8535fbc1/payjoin-ffi/python/test/__init__.py -------------------------------------------------------------------------------- /payjoin-ffi/python/test/test_payjoin_unit_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import payjoin as payjoin 3 | import payjoin.bitcoin 4 | 5 | class TestURIs(unittest.TestCase): 6 | def test_todo_url_encoded(self): 7 | uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao" 8 | self.assertTrue(payjoin.Url.parse(uri), "pj url should be url encoded") 9 | 10 | def test_valid_url(self): 11 | uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao" 12 | self.assertTrue(payjoin.Url.parse(uri), "pj is not a valid url") 13 | 14 | def test_missing_amount(self): 15 | uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj" 16 | self.assertTrue(payjoin.Url.parse(uri), "missing amount should be ok") 17 | 18 | def test_valid_uris(self): 19 | https = str(payjoin.example_url()) 20 | onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion" 21 | 22 | base58 = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX" 23 | bech32_upper = "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4" 24 | bech32_lower = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4" 25 | 26 | for address in [base58, bech32_upper, bech32_lower]: 27 | for pj in [https, onion]: 28 | uri = f"{address}?amount=1&pj={pj}" 29 | try: 30 | payjoin.Url.parse(uri) 31 | except Exception as e: 32 | self.fail(f"Failed to create a valid Uri for {uri}. Error: {e}") 33 | 34 | class InMemoryReceiverPersister(payjoin.payjoin_ffi.ReceiverPersister): 35 | def __init__(self): 36 | self.receivers = {} 37 | 38 | def save(self, receiver: payjoin.WithContext) -> payjoin.ReceiverToken: 39 | self.receivers[str(receiver.key())] = receiver.to_json() 40 | 41 | return receiver.key() 42 | 43 | def load(self, token: payjoin.ReceiverToken) -> payjoin.WithContext: 44 | token = str(token) 45 | if token not in self.receivers.keys(): 46 | raise ValueError(f"Token not found: {token}") 47 | return payjoin.WithContext.from_json(self.receivers[token]) 48 | 49 | 50 | class TestReceiverPersistence(unittest.TestCase): 51 | def test_receiver_persistence(self): 52 | persister = InMemoryReceiverPersister() 53 | address = payjoin.bitcoin.Address("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", payjoin.bitcoin.Network.SIGNET) 54 | new_receiver = payjoin.NewReceiver( 55 | address, 56 | "https://example.com", 57 | payjoin.OhttpKeys.from_string("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), 58 | None 59 | ) 60 | token = new_receiver.persist(persister) 61 | payjoin.WithContext.load(token, persister) 62 | 63 | class InMemorySenderPersister(payjoin.payjoin_ffi.SenderPersister): 64 | def __init__(self): 65 | self.senders = {} 66 | 67 | def save(self, sender: payjoin.WithReplyKey) -> payjoin.SenderToken: 68 | self.senders[str(sender.key())] = sender.to_json() 69 | return sender.key() 70 | 71 | def load(self, token: payjoin.SenderToken) -> payjoin.WithReplyKey: 72 | token = str(token) 73 | if token not in self.senders.keys(): 74 | raise ValueError(f"Token not found: {token}") 75 | return payjoin.WithReplyKey.from_json(self.senders[token]) 76 | 77 | class TestSenderPersistence(unittest.TestCase): 78 | def test_sender_persistence(self): 79 | # Create a receiver to just get the pj uri 80 | persister = InMemoryReceiverPersister() 81 | address = payjoin.bitcoin.Address("2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", payjoin.bitcoin.Network.TESTNET) 82 | new_receiver = payjoin.NewReceiver( 83 | address, 84 | "https://example.com", 85 | payjoin.OhttpKeys.from_string("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), 86 | None 87 | ) 88 | token = new_receiver.persist(persister) 89 | receiver = payjoin.WithContext.load(token, persister) 90 | uri = receiver.pj_uri() 91 | 92 | persister = InMemorySenderPersister() 93 | psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=" 94 | new_sender = payjoin.SenderBuilder(psbt, uri).build_recommended(1000) 95 | token = new_sender.persist(persister) 96 | payjoin.WithReplyKey.load(token, persister) 97 | 98 | if __name__ == "__main__": 99 | unittest.main() 100 | -------------------------------------------------------------------------------- /payjoin-ffi/src/bitcoin_ffi.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | #[cfg(not(feature = "uniffi"))] 4 | pub use bitcoin_ffi::*; 5 | use payjoin::bitcoin; 6 | 7 | #[cfg(feature = "uniffi")] 8 | mod uni { 9 | pub use bitcoin_ffi::*; 10 | } 11 | 12 | #[cfg(feature = "uniffi")] 13 | pub use uni::*; 14 | 15 | #[derive(Debug, Clone)] 16 | #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] 17 | pub struct PsbtInput { 18 | pub witness_utxo: Option, 19 | pub redeem_script: Option>, 20 | pub witness_script: Option>, 21 | } 22 | 23 | impl PsbtInput { 24 | pub fn new( 25 | witness_utxo: Option, 26 | redeem_script: Option>, 27 | witness_script: Option>, 28 | ) -> Self { 29 | Self { witness_utxo, redeem_script, witness_script } 30 | } 31 | } 32 | 33 | impl From for PsbtInput { 34 | fn from(psbt_input: bitcoin::psbt::Input) -> Self { 35 | Self { 36 | witness_utxo: psbt_input.witness_utxo.map(|s| s.into()), 37 | redeem_script: psbt_input.redeem_script.clone().map(|s| Arc::new(s.into())), 38 | witness_script: psbt_input.witness_script.clone().map(|s| Arc::new(s.into())), 39 | } 40 | } 41 | } 42 | 43 | impl From for bitcoin::psbt::Input { 44 | fn from(psbt_input: PsbtInput) -> Self { 45 | Self { 46 | witness_utxo: psbt_input.witness_utxo.map(|s| s.into()), 47 | redeem_script: psbt_input 48 | .redeem_script 49 | .map(|s| Arc::try_unwrap(s).unwrap_or_else(|arc| (*arc).clone()).into()), 50 | witness_script: psbt_input 51 | .witness_script 52 | .map(|s| Arc::try_unwrap(s).unwrap_or_else(|arc| (*arc).clone()).into()), 53 | ..Default::default() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /payjoin-ffi/src/error.rs: -------------------------------------------------------------------------------- 1 | /// Error arising due to the specific receiver implementation 2 | /// 3 | /// e.g. database errors, network failures, wallet errors 4 | #[derive(Debug, thiserror::Error)] 5 | #[error(transparent)] 6 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 7 | pub struct ImplementationError(#[from] payjoin::ImplementationError); 8 | 9 | impl From for ImplementationError { 10 | fn from(value: String) -> Self { Self(value.into()) } 11 | } 12 | 13 | #[derive(Debug, thiserror::Error)] 14 | #[error("Error de/serializing JSON object: {0}")] 15 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 16 | pub struct SerdeJsonError(#[from] serde_json::Error); 17 | 18 | #[derive(Debug, thiserror::Error)] 19 | #[cfg_attr(feature = "uniffi", derive(uniffi::Error))] 20 | pub enum ForeignError { 21 | #[error("Internal error: {0}")] 22 | InternalError(String), 23 | } 24 | 25 | #[cfg(feature = "uniffi")] 26 | impl From for ForeignError { 27 | fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { 28 | Self::InternalError("Unexpected Uniffi callback error".to_string()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /payjoin-ffi/src/io.rs: -------------------------------------------------------------------------------- 1 | pub use error::IoError; 2 | 3 | use crate::ohttp::OhttpKeys; 4 | 5 | pub mod error { 6 | #[derive(Debug, PartialEq, Eq, thiserror::Error)] 7 | #[error("IO error: {message}")] 8 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 9 | pub struct IoError { 10 | message: String, 11 | } 12 | impl From for IoError { 13 | fn from(value: payjoin::io::Error) -> Self { IoError { message: format!("{value:?}") } } 14 | } 15 | } 16 | 17 | /// Fetch the ohttp keys from the specified payjoin directory via proxy. 18 | /// 19 | /// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin 20 | /// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to 21 | /// the payjoin directory. 22 | /// 23 | /// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This 24 | /// directory stores and forwards payjoin client payloads. 25 | pub async fn fetch_ohttp_keys( 26 | ohttp_relay: &str, 27 | payjoin_directory: &str, 28 | ) -> Result { 29 | payjoin::io::fetch_ohttp_keys(ohttp_relay, payjoin_directory) 30 | .await 31 | .map(|e| e.into()) 32 | .map_err(|e| e.into()) 33 | } 34 | 35 | /// Fetch the ohttp keys from the specified payjoin directory via proxy. 36 | /// 37 | /// * `ohttp_relay`: The http CONNECT method proxy to request the ohttp keys from a payjoin 38 | /// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to 39 | /// the payjoin directory. 40 | /// 41 | /// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This 42 | /// directory stores and forwards payjoin client payloads. 43 | /// 44 | /// * `cert_der`: The DER-encoded certificate to use for local HTTPS connections. 45 | #[cfg(feature = "_danger-local-https")] 46 | pub async fn fetch_ohttp_keys_with_cert( 47 | ohttp_relay: &str, 48 | payjoin_directory: &str, 49 | cert_der: Vec, 50 | ) -> Result { 51 | payjoin::io::fetch_ohttp_keys_with_cert(ohttp_relay, payjoin_directory, cert_der) 52 | .await 53 | .map(|e| e.into()) 54 | .map_err(|e| e.into()) 55 | } 56 | -------------------------------------------------------------------------------- /payjoin-ffi/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![crate_name = "payjoin_ffi"] 2 | 3 | pub mod bitcoin_ffi; 4 | pub mod error; 5 | pub mod io; 6 | pub mod ohttp; 7 | pub mod output_substitution; 8 | pub mod receive; 9 | pub mod request; 10 | pub mod send; 11 | #[cfg(feature = "_test-utils")] 12 | pub mod test_utils; 13 | pub mod uri; 14 | 15 | pub use payjoin::persist::NoopPersister; 16 | 17 | pub use crate::bitcoin_ffi::*; 18 | pub use crate::ohttp::*; 19 | pub use crate::output_substitution::*; 20 | #[cfg(feature = "uniffi")] 21 | pub use crate::receive::uni::*; 22 | pub use crate::request::Request; 23 | #[cfg(feature = "uniffi")] 24 | pub use crate::send::uni::*; 25 | #[cfg(feature = "_test-utils")] 26 | pub use crate::test_utils::*; 27 | pub use crate::uri::{PjUri, Uri, Url}; 28 | #[cfg(feature = "uniffi")] 29 | uniffi::setup_scaffolding!(); 30 | -------------------------------------------------------------------------------- /payjoin-ffi/src/ohttp.rs: -------------------------------------------------------------------------------- 1 | pub use error::OhttpError; 2 | 3 | pub mod error { 4 | #[derive(Debug, PartialEq, Eq, thiserror::Error)] 5 | #[error("OHTTP error: {message}")] 6 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 7 | pub struct OhttpError { 8 | message: String, 9 | } 10 | impl From for OhttpError { 11 | fn from(value: ohttp::Error) -> Self { OhttpError { message: format!("{value:?}") } } 12 | } 13 | impl From for OhttpError { 14 | fn from(value: String) -> Self { OhttpError { message: value } } 15 | } 16 | } 17 | 18 | impl From for OhttpKeys { 19 | fn from(value: payjoin::OhttpKeys) -> Self { Self(value) } 20 | } 21 | impl From for payjoin::OhttpKeys { 22 | fn from(value: OhttpKeys) -> Self { value.0 } 23 | } 24 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 25 | #[derive(Debug, Clone)] 26 | pub struct OhttpKeys(payjoin::OhttpKeys); 27 | 28 | #[cfg_attr(feature = "uniffi", uniffi::export)] 29 | impl OhttpKeys { 30 | /// Decode an OHTTP KeyConfig 31 | #[cfg_attr(feature = "uniffi", uniffi::constructor)] 32 | pub fn decode(bytes: Vec) -> Result { 33 | payjoin::OhttpKeys::decode(bytes.as_slice()).map(Into::into).map_err(Into::into) 34 | } 35 | 36 | /// Create an OHTTP KeyConfig from a string 37 | #[cfg_attr(feature = "uniffi", uniffi::constructor)] 38 | pub fn from_string(s: String) -> Result { 39 | let res = payjoin::OhttpKeys::from_str(s.as_str()) 40 | .map_err(|e| OhttpError::from(e.to_string()))?; 41 | Ok(Self(res)) 42 | } 43 | } 44 | 45 | use std::str::FromStr; 46 | use std::sync::Mutex; 47 | 48 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 49 | pub struct ClientResponse(Mutex>); 50 | 51 | impl From<&ClientResponse> for ohttp::ClientResponse { 52 | fn from(value: &ClientResponse) -> Self { 53 | let mut data_guard = value.0.lock().unwrap(); 54 | Option::take(&mut *data_guard).expect("ClientResponse moved out of memory") 55 | } 56 | } 57 | 58 | impl From for ClientResponse { 59 | fn from(value: ohttp::ClientResponse) -> Self { Self(Mutex::new(Some(value))) } 60 | } 61 | -------------------------------------------------------------------------------- /payjoin-ffi/src/output_substitution.rs: -------------------------------------------------------------------------------- 1 | pub type OutputSubstitution = payjoin::OutputSubstitution; 2 | 3 | #[cfg(feature = "uniffi")] 4 | #[cfg_attr(feature = "uniffi", uniffi::remote(Enum))] 5 | enum OutputSubstitution { 6 | Enabled, 7 | Disabled, 8 | } 9 | -------------------------------------------------------------------------------- /payjoin-ffi/src/payjoin_ffi.udl: -------------------------------------------------------------------------------- 1 | namespace payjoin_ffi { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /payjoin-ffi/src/receive/error.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use payjoin::receive; 4 | 5 | /// The top-level error type for the payjoin receiver 6 | #[derive(Debug, thiserror::Error)] 7 | #[non_exhaustive] 8 | #[cfg_attr(feature = "uniffi", derive(uniffi::Error))] 9 | pub enum Error { 10 | /// Errors that can be replied to the sender 11 | #[error("Replyable error: {0}")] 12 | ReplyToSender(Arc), 13 | /// V2-specific errors that are infeasable to reply to the sender 14 | #[error("Unreplyable error: {0}")] 15 | V2(Arc), 16 | /// Catch-all for unhandled error variants 17 | #[error("An unexpected error occurred")] 18 | Unexpected, 19 | } 20 | 21 | impl From for Error { 22 | fn from(value: receive::Error) -> Self { 23 | match value { 24 | receive::Error::ReplyToSender(e) => Error::ReplyToSender(Arc::new(ReplyableError(e))), 25 | receive::Error::V2(e) => Error::V2(Arc::new(SessionError(e))), 26 | _ => Error::Unexpected, 27 | } 28 | } 29 | } 30 | 31 | /// The replyable error type for the payjoin receiver, representing failures need to be 32 | /// returned to the sender. 33 | /// 34 | /// The error handling is designed to: 35 | /// 1. Provide structured error responses for protocol-level failures 36 | /// 2. Hide implementation details of external errors for security 37 | /// 3. Support proper error propagation through the receiver stack 38 | /// 4. Provide errors according to BIP-78 JSON error specifications for return 39 | /// after conversion into [`JsonReply`] 40 | #[derive(Debug, thiserror::Error)] 41 | #[error(transparent)] 42 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 43 | pub struct ReplyableError(#[from] receive::ReplyableError); 44 | 45 | /// The standard format for errors that can be replied as JSON. 46 | /// 47 | /// The JSON output includes the following fields: 48 | /// ```json 49 | /// { 50 | /// "errorCode": "specific-error-code", 51 | /// "message": "Human readable error message" 52 | /// } 53 | /// ``` 54 | #[derive(Debug, Clone, PartialEq, Eq)] 55 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 56 | pub struct JsonReply(receive::JsonReply); 57 | 58 | impl From for receive::JsonReply { 59 | fn from(value: JsonReply) -> Self { value.0 } 60 | } 61 | 62 | impl From for JsonReply { 63 | fn from(value: ReplyableError) -> Self { Self(value.0.into()) } 64 | } 65 | 66 | /// Error that may occur during a v2 session typestate change 67 | #[derive(Debug, thiserror::Error)] 68 | #[error(transparent)] 69 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 70 | pub struct SessionError(#[from] receive::v2::SessionError); 71 | 72 | /// Error that may occur when output substitution fails. 73 | #[derive(Debug, thiserror::Error)] 74 | #[error(transparent)] 75 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 76 | pub struct OutputSubstitutionError(#[from] receive::OutputSubstitutionError); 77 | 78 | /// Error that may occur when coin selection fails. 79 | #[derive(Debug, thiserror::Error)] 80 | #[error(transparent)] 81 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 82 | pub struct SelectionError(#[from] receive::SelectionError); 83 | 84 | /// Error that may occur when input contribution fails. 85 | #[derive(Debug, thiserror::Error)] 86 | #[error(transparent)] 87 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 88 | pub struct InputContributionError(#[from] receive::InputContributionError); 89 | 90 | /// Error validating a PSBT Input 91 | #[derive(Debug, thiserror::Error)] 92 | #[error(transparent)] 93 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 94 | pub struct PsbtInputError(#[from] receive::PsbtInputError); 95 | -------------------------------------------------------------------------------- /payjoin-ffi/src/request.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::uri::Url; 4 | 5 | ///Represents data that needs to be transmitted to the receiver. 6 | ///You need to send this request over HTTP(S) to the receiver. 7 | #[derive(Clone, Debug)] 8 | #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] 9 | pub struct Request { 10 | /// URL to send the request to. 11 | /// 12 | /// This is full URL with scheme etc - you can pass it right to `reqwest` or a similar library. 13 | pub url: Arc, 14 | 15 | /// The `Content-Type` header to use for the request. 16 | /// 17 | /// `text/plain` for v1 requests and `message/ohttp-req` for v2 requests. 18 | pub content_type: String, 19 | 20 | /// Bytes to be sent to the receiver. 21 | /// 22 | /// This is properly encoded PSBT payload either in base64 in v1 or an OHTTP encapsulated payload in v2. 23 | pub body: Vec, 24 | } 25 | 26 | impl From for Request { 27 | fn from(value: payjoin::Request) -> Self { 28 | Self { 29 | url: Arc::new(value.url.into()), 30 | content_type: value.content_type.to_string(), 31 | body: value.body, 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /payjoin-ffi/src/send/error.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use payjoin::bitcoin::psbt::PsbtParseError; 4 | use payjoin::send; 5 | 6 | /// Error building a Sender from a SenderBuilder. 7 | /// 8 | /// This error is unrecoverable. 9 | #[derive(Debug, PartialEq, Eq, thiserror::Error)] 10 | #[error("Error initializing the sender: {msg}")] 11 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 12 | pub struct BuildSenderError { 13 | msg: String, 14 | } 15 | 16 | impl From for BuildSenderError { 17 | fn from(value: PsbtParseError) -> Self { BuildSenderError { msg: value.to_string() } } 18 | } 19 | 20 | impl From for BuildSenderError { 21 | fn from(value: send::BuildSenderError) -> Self { BuildSenderError { msg: value.to_string() } } 22 | } 23 | 24 | /// Error returned when request could not be created. 25 | /// 26 | /// This error can currently only happen due to programmer mistake. 27 | /// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying 28 | /// it. 29 | #[derive(Debug, thiserror::Error)] 30 | #[error(transparent)] 31 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 32 | pub struct CreateRequestError(#[from] send::v2::CreateRequestError); 33 | 34 | /// Error returned for v2-specific payload encapsulation errors. 35 | #[derive(Debug, thiserror::Error)] 36 | #[error(transparent)] 37 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 38 | pub struct EncapsulationError(#[from] send::v2::EncapsulationError); 39 | 40 | /// Error that may occur when the response from receiver is malformed. 41 | #[derive(Debug, thiserror::Error)] 42 | #[error(transparent)] 43 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 44 | pub struct ValidationError(#[from] send::ValidationError); 45 | 46 | /// Represent an error returned by Payjoin receiver. 47 | #[derive(Debug, thiserror::Error)] 48 | #[cfg_attr(feature = "uniffi", derive(uniffi::Error))] 49 | pub enum ResponseError { 50 | /// `WellKnown` Errors are defined in the [`BIP78::ReceiverWellKnownError`] spec. 51 | /// 52 | /// It is safe to display `WellKnown` errors to end users. 53 | /// 54 | /// [`BIP78::ReceiverWellKnownError`]: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#user-content-Receivers_well_known_errors 55 | #[error("A receiver error occurred: {0}")] 56 | WellKnown(Arc), 57 | 58 | /// Errors caused by malformed responses. 59 | #[error("An error occurred due to a malformed response: {0}")] 60 | Validation(Arc), 61 | 62 | /// `Unrecognized` Errors are NOT defined in the [`BIP78::ReceiverWellKnownError`] spec. 63 | /// 64 | /// It is NOT safe to display `Unrecognized` errors to end users as they could be used 65 | /// maliciously to phish a non technical user. Only display them in debug logs. 66 | /// 67 | /// [`BIP78::ReceiverWellKnownError`]: https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#user-content-Receivers_well_known_errors 68 | #[error("An unrecognized error occurred")] 69 | Unrecognized { error_code: String, msg: String }, 70 | } 71 | 72 | impl From for ResponseError { 73 | fn from(value: send::ResponseError) -> Self { 74 | match value { 75 | send::ResponseError::WellKnown(e) => ResponseError::WellKnown(Arc::new(e.into())), 76 | send::ResponseError::Validation(e) => ResponseError::Validation(Arc::new(e.into())), 77 | send::ResponseError::Unrecognized { error_code, message } => 78 | ResponseError::Unrecognized { error_code, msg: message }, 79 | } 80 | } 81 | } 82 | 83 | /// A well-known error that can be safely displayed to end users. 84 | #[derive(Debug, thiserror::Error)] 85 | #[error(transparent)] 86 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 87 | pub struct WellKnownError(#[from] send::WellKnownError); 88 | -------------------------------------------------------------------------------- /payjoin-ffi/src/uri/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, PartialEq, Eq, thiserror::Error)] 2 | #[error("Error parsing the payjoin URI: {msg}")] 3 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 4 | pub struct PjParseError { 5 | msg: String, 6 | } 7 | 8 | impl From for PjParseError { 9 | fn from(msg: String) -> Self { PjParseError { msg } } 10 | } 11 | 12 | #[derive(Debug, PartialEq, Eq, thiserror::Error)] 13 | #[error("URI doesn't support payjoin: {msg}")] 14 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 15 | pub struct PjNotSupported { 16 | msg: String, 17 | } 18 | 19 | impl From for PjNotSupported { 20 | fn from(msg: String) -> Self { PjNotSupported { msg } } 21 | } 22 | 23 | #[derive(Debug, thiserror::Error)] 24 | #[error(transparent)] 25 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 26 | pub struct UrlParseError(#[from] payjoin::ParseError); 27 | 28 | #[derive(Debug, thiserror::Error)] 29 | #[error(transparent)] 30 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 31 | pub struct IntoUrlError(#[from] payjoin::IntoUrlError); 32 | -------------------------------------------------------------------------------- /payjoin-ffi/src/uri/mod.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | #[cfg(feature = "uniffi")] 3 | use std::sync::Arc; 4 | 5 | pub use error::{PjNotSupported, PjParseError, UrlParseError}; 6 | use payjoin::bitcoin::address::NetworkChecked; 7 | use payjoin::UriExt; 8 | 9 | pub mod error; 10 | #[derive(Clone)] 11 | pub struct Uri(payjoin::Uri<'static, NetworkChecked>); 12 | impl From for payjoin::Uri<'static, NetworkChecked> { 13 | fn from(value: Uri) -> Self { value.0 } 14 | } 15 | 16 | impl From> for Uri { 17 | fn from(value: payjoin::Uri<'static, NetworkChecked>) -> Self { Uri(value) } 18 | } 19 | 20 | impl Uri { 21 | pub fn parse(uri: String) -> Result { 22 | match payjoin::Uri::from_str(uri.as_str()) { 23 | Ok(e) => Ok(e.assume_checked().into()), 24 | Err(e) => Err(e.to_string().into()), 25 | } 26 | } 27 | pub fn address(&self) -> String { self.clone().0.address.to_string() } 28 | /// Gets the amount in satoshis. 29 | pub fn amount_sats(&self) -> Option { self.0.amount.map(|x| x.to_sat()) } 30 | pub fn label(&self) -> Option { 31 | self.0.label.clone().and_then(|x| String::try_from(x).ok()) 32 | } 33 | pub fn message(&self) -> Option { 34 | self.0.message.clone().and_then(|x| String::try_from(x).ok()) 35 | } 36 | #[cfg(not(feature = "uniffi"))] 37 | pub fn check_pj_supported(&self) -> Result { 38 | match self.0.clone().check_pj_supported() { 39 | Ok(e) => Ok(e.into()), 40 | Err(uri) => Err(uri.to_string().into()), 41 | } 42 | } 43 | #[cfg(feature = "uniffi")] 44 | pub fn check_pj_supported(&self) -> Result, PjNotSupported> { 45 | match self.0.clone().check_pj_supported() { 46 | Ok(e) => Ok(Arc::new(e.into())), 47 | Err(uri) => Err(uri.to_string().into()), 48 | } 49 | } 50 | pub fn as_string(&self) -> String { self.0.clone().to_string() } 51 | } 52 | 53 | impl From> for PjUri { 54 | fn from(value: payjoin::PjUri<'static>) -> Self { Self(value) } 55 | } 56 | 57 | impl<'a> From for payjoin::PjUri<'a> { 58 | fn from(value: PjUri) -> Self { value.0 } 59 | } 60 | 61 | #[derive(Clone)] 62 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 63 | pub struct PjUri(pub payjoin::PjUri<'static>); 64 | 65 | #[cfg_attr(feature = "uniffi", uniffi::export)] 66 | impl PjUri { 67 | pub fn address(&self) -> String { self.0.clone().address.to_string() } 68 | /// Number of sats requested as payment 69 | pub fn amount_sats(&self) -> Option { self.0.clone().amount.map(|e| e.to_sat()) } 70 | 71 | /// Sets the amount in sats and returns a new PjUri 72 | pub fn set_amount_sats(&self, amount_sats: u64) -> Self { 73 | let mut uri = self.0.clone(); 74 | let amount = payjoin::bitcoin::Amount::from_sat(amount_sats); 75 | uri.amount = Some(amount); 76 | uri.into() 77 | } 78 | 79 | pub fn pj_endpoint(&self) -> String { self.0.extras.endpoint().to_string() } 80 | 81 | pub fn as_string(&self) -> String { self.0.clone().to_string() } 82 | } 83 | 84 | impl From for Url { 85 | fn from(value: payjoin::Url) -> Self { Self(value) } 86 | } 87 | 88 | impl From for payjoin::Url { 89 | fn from(value: Url) -> Self { value.0 } 90 | } 91 | 92 | #[derive(Clone, Debug)] 93 | #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] 94 | pub struct Url(payjoin::Url); 95 | 96 | #[cfg_attr(feature = "uniffi", uniffi::export)] 97 | impl Url { 98 | #[cfg_attr(feature = "uniffi", uniffi::constructor)] 99 | pub fn parse(input: String) -> Result { 100 | payjoin::Url::parse(input.as_str()).map_err(Into::into).map(Self) 101 | } 102 | pub fn query(&self) -> Option { self.0.query().map(|x| x.to_string()) } 103 | pub fn as_string(&self) -> String { self.0.to_string() } 104 | } 105 | -------------------------------------------------------------------------------- /payjoin-ffi/uniffi-bindgen.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(feature = "uniffi")] 3 | uniffi::uniffi_bindgen_main() 4 | } 5 | -------------------------------------------------------------------------------- /payjoin-ffi/uniffi.toml: -------------------------------------------------------------------------------- 1 | [bindings.kotlin] 2 | package_name = "org.payjoindevkit" 3 | cdylib_name = "payjoin_ffi" 4 | 5 | [bindings.python] 6 | cdylib_name = "payjoin_ffi" 7 | 8 | [bindings.swift] 9 | cdylib_name = "payjoin_ffi" -------------------------------------------------------------------------------- /payjoin-test-utils/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # payjoin-test-utils Changelog 2 | 3 | ## 0.0.0 4 | 5 | - Release initial payjoin-test-utils to spin up payjoin test services 6 | -------------------------------------------------------------------------------- /payjoin-test-utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "payjoin-test-utils" 3 | version = "0.0.0" 4 | edition = "2021" 5 | authors = ["Dan Gould "] 6 | description = "Payjoin test utilities" 7 | repository = "https://github.com/payjoin/rust-payjoin" 8 | rust-version = "1.63" 9 | license = "MIT" 10 | 11 | [dependencies] 12 | bitcoin = { version = "0.32.5", features = ["base64"] } 13 | bitcoincore-rpc = "0.19.0" 14 | bitcoind = { version = "0.36.0", features = ["0_21_2"] } 15 | http = "1.1.0" 16 | log = "0.4.7" 17 | ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } 18 | ohttp-relay = { version = "0.0.10", features = ["_test-util"] } 19 | once_cell = "1.19.0" 20 | payjoin = { version = "0.23.0", features = ["io", "_danger-local-https"] } 21 | payjoin-directory = { version = "0.0.2", features = ["_danger-local-https"] } 22 | rcgen = "0.11" 23 | rustls = "0.22" 24 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } 25 | testcontainers = "0.15.0" 26 | testcontainers-modules = { version = "0.3.7", features = ["redis"] } 27 | tokio = { version = "1.38.1", features = ["full"] } 28 | tracing = "0.1.40" 29 | tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } 30 | url = "2.2.2" 31 | -------------------------------------------------------------------------------- /payjoin-test-utils/README.md: -------------------------------------------------------------------------------- 1 | # payjoin-test-utils 2 | 3 | A collection of testing utilities for Payjoin protocol implementations. 4 | 5 | ## Overview 6 | 7 | The `payjoin-test-utils` crate provides commonly used testing fixtures for 8 | Payjoin development and testing, including: 9 | 10 | - Local OHTTP relay and Payjoin directory services 11 | - Bitcoin Core node and wallet management 12 | - Official test vectors 13 | - HTTP client configuration for testing 14 | - Tracing setup for debugging 15 | 16 | ## Features 17 | 18 | - **Test Services**: Easily spin up and manage OHTTP relay and Payjoin Directory 19 | test services required for Payjoin testing 20 | - **Bitcoin Core Integration**: Initialize and configure Bitcoin nodes for 21 | testing 22 | - **Wallet Management**: Create and fund wallets for sender and receiver testing 23 | - **OHTTP Relay**: Set up local OHTTP relay services 24 | - **Directory Service**: Configure Payjoin directory services 25 | - **Test Vectors**: Get access to official Payjoin test vectors 26 | 27 | ## Usage 28 | 29 | For examples of using the TestServices, switch to the appropriate 30 | `payjoin-test-utils` tag in 31 | [rust-payjoin](https://github.com/payjoin/rust-payjoin) and view the e2e or 32 | integration tests there. 33 | 34 | ## Minimum Supported Rust Version (MSRV) 35 | 36 | This crate supports Rust 1.63.0 and above. 37 | 38 | ## License 39 | 40 | MIT 41 | -------------------------------------------------------------------------------- /payjoin/.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock -------------------------------------------------------------------------------- /payjoin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "payjoin" 3 | version = "0.23.0" 4 | authors = ["Dan Gould "] 5 | description = "Payjoin Library implementing BIP 78 and BIP 77 batching protocols." 6 | repository = "https://github.com/payjoin/rust-payjoin" 7 | readme = "../README.md" 8 | keywords = ["bip78", "payjoin", "bitcoin"] 9 | categories = ["api-bindings", "cryptography::cryptocurrencies", "network-programming"] 10 | license = "MITNFA" 11 | resolver = "2" 12 | edition = "2021" 13 | rust-version = "1.63" 14 | exclude = ["tests"] 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [features] 19 | default = ["v2"] 20 | #[doc = "Core features for payjoin state machines"] 21 | _core = ["bitcoin/rand-std", "serde_json", "url", "bitcoin_uri", "serde"] 22 | directory = [] 23 | v1 = ["_core"] 24 | v2 = ["_core", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "url/serde", "directory"] 25 | #[doc = "Functions to fetch OHTTP keys via CONNECT proxy using reqwest. Enables `v2` since only `v2` uses OHTTP."] 26 | io = ["v2", "reqwest/rustls-tls"] 27 | _danger-local-https = ["reqwest/rustls-tls", "rustls"] 28 | _multiparty = ["v2"] 29 | 30 | [dependencies] 31 | bitcoin = { version = "0.32.5", features = ["base64"] } 32 | bitcoin_uri = { version = "0.1.0", optional = true } 33 | hpke = { package = "bitcoin-hpke", version = "0.13.0", optional = true } 34 | log = { version = "0.4.14"} 35 | http = { version = "1.1.0", optional = true } 36 | bhttp = { version = "=0.5.1", optional = true } 37 | ohttp = { package = "bitcoin-ohttp", version = "0.6.0", optional = true } 38 | serde = { version = "1.0.186", default-features = false, optional = true } 39 | reqwest = { version = "0.12", default-features = false, optional = true } 40 | rustls = { version = "0.22.4", optional = true } 41 | url = { version = "2.2.2", optional = true } 42 | serde_json = { version = "1.0.108", optional = true } 43 | 44 | [dev-dependencies] 45 | bitcoind = { version = "0.36.0", features = ["0_21_2"] } 46 | payjoin-test-utils = { path = "../payjoin-test-utils" } 47 | once_cell = "1.19.0" 48 | tokio = { version = "1.38.1", features = ["full"] } 49 | tracing = "0.1.40" 50 | 51 | [package.metadata.docs.rs] 52 | all-features = true 53 | rustdoc-args = ["--cfg", "docsrs"] 54 | -------------------------------------------------------------------------------- /payjoin/contrib/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Individual features with no defaults. 5 | features=("v1" "v2" "_multiparty" "directory") 6 | 7 | for feature in "${features[@]}"; do 8 | # Don't duplicate --all-targets clippy. Clilppy end-user code, not tests. 9 | cargo clippy --no-default-features --features "$feature" -- -D warnings 10 | done 11 | -------------------------------------------------------------------------------- /payjoin/contrib/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | cargo test --locked --package payjoin --verbose --all-features --lib 5 | cargo test --locked --package payjoin --verbose --all-features --test integration 6 | -------------------------------------------------------------------------------- /payjoin/src/bech32.rs: -------------------------------------------------------------------------------- 1 | use bitcoin::bech32::primitives::decode::{CheckedHrpstring, CheckedHrpstringError}; 2 | use bitcoin::bech32::{self, EncodeError, Hrp, NoChecksum}; 3 | 4 | pub mod nochecksum { 5 | use super::*; 6 | 7 | pub fn decode(encoded: &str) -> Result<(Hrp, Vec), CheckedHrpstringError> { 8 | let hrp_string = CheckedHrpstring::new::(encoded)?; 9 | Ok((hrp_string.hrp(), hrp_string.byte_iter().collect::>())) 10 | } 11 | 12 | pub fn encode(hrp: Hrp, data: &[u8]) -> Result { 13 | bech32::encode_upper::(hrp, data) 14 | } 15 | 16 | #[cfg(feature = "v2")] 17 | pub fn encode_to_fmt( 18 | f: &mut core::fmt::Formatter, 19 | hrp: Hrp, 20 | data: &[u8], 21 | ) -> Result<(), EncodeError> { 22 | bech32::encode_upper_to_fmt::(f, hrp, data) 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod test { 28 | use super::*; 29 | 30 | #[test] 31 | fn bech32_for_qr() { 32 | let bytes = vec![0u8, 1, 2, 3, 31, 32, 33, 95, 0, 96, 127, 128, 129, 254, 255, 0]; 33 | let hrp = Hrp::parse("STUFF").unwrap(); 34 | let encoded = nochecksum::encode(hrp, &bytes).unwrap(); 35 | let decoded = nochecksum::decode(&encoded).unwrap(); 36 | assert_eq!(decoded, (hrp, bytes.to_vec())); 37 | 38 | // no checksum 39 | assert_eq!( 40 | encoded.len() as f32, 41 | (hrp.as_str().len() + 1) as f32 + (bytes.len() as f32 * 8.0 / 5.0).ceil() 42 | ); 43 | 44 | // TODO assert uppercase 45 | 46 | // should not error 47 | let corrupted = encoded + "QQPP"; 48 | let decoded = nochecksum::decode(&corrupted).unwrap(); 49 | assert_eq!(decoded.0, hrp); 50 | assert_ne!(decoded, (hrp, bytes.to_vec())); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /payjoin/src/core/error.rs: -------------------------------------------------------------------------------- 1 | use std::error; 2 | 3 | pub type ImplementationError = Box; 4 | -------------------------------------------------------------------------------- /payjoin/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | //! Core Payjoin 2 | //! 3 | //! This module contains types and methods used to implement Payjoin. 4 | pub mod error; 5 | 6 | pub mod version; 7 | -------------------------------------------------------------------------------- /payjoin/src/core/version.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use serde::{Serialize, Serializer}; 4 | 5 | /// The Payjoin version 6 | /// 7 | /// From [BIP 78](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#optional-parameters), 8 | /// the supported versions should be output as numbers: 9 | /// 10 | /// ```json 11 | /// { 12 | /// "errorCode": "version-unsupported", 13 | /// "supported" : [ 2, 3, 4 ], 14 | /// "message": "The version is not supported anymore" 15 | /// } 16 | /// ``` 17 | /// 18 | /// # Note 19 | /// - Only [`Serialize`] is implemented for json array serialization in the `unsupported-version` error message, 20 | /// not [`serde::Deserialize`], as deserialization is not required for this type. 21 | /// - [`fmt::Display`] and [`fmt::Debug`] output the `u8` representation for compatibility with BIP 78/77 22 | /// and to match the expected wire format. 23 | #[repr(u8)] 24 | #[derive(Clone, Copy, PartialEq, Eq)] 25 | pub enum Version { 26 | /// BIP 78 Payjoin 27 | One = 1, 28 | /// BIP 77 Async Payjoin 29 | Two = 2, 30 | } 31 | 32 | impl fmt::Display for Version { 33 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { (*self as u8).fmt(f) } 34 | } 35 | 36 | impl fmt::Debug for Version { 37 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Display::fmt(self, f) } 38 | } 39 | 40 | impl Serialize for Version { 41 | fn serialize(&self, serializer: S) -> Result 42 | where 43 | S: Serializer, 44 | { 45 | (*self as u8).serialize(serializer) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /payjoin/src/directory.rs: -------------------------------------------------------------------------------- 1 | //! Types relevant to the Payjoin Directory as defined in BIP 77. 2 | 3 | pub const ENCAPSULATED_MESSAGE_BYTES: usize = 8192; 4 | 5 | /// A 64-bit identifier used to identify Payjoin Directory entries. 6 | /// 7 | /// ShortId is derived from a truncated SHA256 hash of a compressed public key. While SHA256 is used 8 | /// internally, ShortIds should be treated only as unique identifiers, not cryptographic hashes. 9 | /// The truncation to 64 bits means they are not cryptographically binding. 10 | /// 11 | /// ## Security Characteristics 12 | /// 13 | /// - Provides sufficient entropy for practical uniqueness in the Payjoin Directory context 14 | /// - With ~2^21 concurrent entries (24h tx limit), collision probability is < 1e-6 15 | /// - Individual entry collision probability is << 1e-10 16 | /// - Collisions only affect liveness (ability to complete the payjoin), not security 17 | /// - For v2 entries, collisions result in HPKE failure 18 | /// - For v1 entries, collisions may leak PSBT proposals to interceptors 19 | /// 20 | /// Note: This implementation assumes ephemeral public keys with sufficient entropy. The short length 21 | /// is an intentional tradeoff that provides adequate practical uniqueness while reducing DoS surface. 22 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 | pub struct ShortId(pub [u8; 8]); 24 | 25 | impl ShortId { 26 | pub fn as_bytes(&self) -> &[u8] { &self.0 } 27 | pub fn as_slice(&self) -> &[u8] { &self.0 } 28 | } 29 | 30 | impl std::fmt::Display for ShortId { 31 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 32 | let id_hrp = bitcoin::bech32::Hrp::parse("ID").unwrap(); 33 | f.write_str( 34 | crate::bech32::nochecksum::encode(id_hrp, &self.0) 35 | .expect("bech32 encoding of short ID must succeed") 36 | .strip_prefix("ID1") 37 | .expect("human readable part must be ID1"), 38 | ) 39 | } 40 | } 41 | 42 | #[derive(Debug)] 43 | pub enum ShortIdError { 44 | DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), 45 | IncorrectLength(std::array::TryFromSliceError), 46 | } 47 | 48 | impl std::convert::From for ShortId { 49 | fn from(h: bitcoin::hashes::sha256::Hash) -> Self { 50 | bitcoin::hashes::Hash::as_byte_array(&h)[..8] 51 | .try_into() 52 | .expect("truncating SHA256 to 8 bytes should always succeed") 53 | } 54 | } 55 | 56 | impl std::convert::TryFrom<&[u8]> for ShortId { 57 | type Error = ShortIdError; 58 | fn try_from(bytes: &[u8]) -> Result { 59 | let bytes: [u8; 8] = bytes.try_into().map_err(ShortIdError::IncorrectLength)?; 60 | Ok(Self(bytes)) 61 | } 62 | } 63 | 64 | impl std::str::FromStr for ShortId { 65 | type Err = ShortIdError; 66 | fn from_str(s: &str) -> Result { 67 | let (_, bytes) = crate::bech32::nochecksum::decode(&("ID1".to_string() + s)) 68 | .map_err(ShortIdError::DecodeBech32)?; 69 | (&bytes[..]).try_into() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /payjoin/src/error_codes.rs: -------------------------------------------------------------------------------- 1 | //! Well-known error codes as defined in BIP-78 2 | //! See: 3 | 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum ErrorCode { 6 | /// The payjoin endpoint is not available for now. 7 | Unavailable, 8 | /// The receiver added some inputs but could not bump the fee of the payjoin proposal. 9 | NotEnoughMoney, 10 | /// This version of payjoin is not supported. 11 | VersionUnsupported, 12 | /// The receiver rejected the original PSBT. 13 | OriginalPsbtRejected, 14 | } 15 | 16 | impl ErrorCode { 17 | pub const fn as_str(&self) -> &'static str { 18 | match self { 19 | Self::Unavailable => "unavailable", 20 | Self::NotEnoughMoney => "not-enough-money", 21 | Self::VersionUnsupported => "version-unsupported", 22 | Self::OriginalPsbtRejected => "original-psbt-rejected", 23 | } 24 | } 25 | } 26 | 27 | impl core::str::FromStr for ErrorCode { 28 | type Err = (); 29 | 30 | fn from_str(s: &str) -> Result { 31 | match s { 32 | "unavailable" => Ok(Self::Unavailable), 33 | "not-enough-money" => Ok(Self::NotEnoughMoney), 34 | "version-unsupported" => Ok(Self::VersionUnsupported), 35 | "original-psbt-rejected" => Ok(Self::OriginalPsbtRejected), 36 | _ => Err(()), 37 | } 38 | } 39 | } 40 | 41 | impl core::fmt::Display for ErrorCode { 42 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 43 | f.write_str(self.as_str()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /payjoin/src/into_url.rs: -------------------------------------------------------------------------------- 1 | use url::{ParseError, Url}; 2 | 3 | #[derive(Debug)] 4 | pub enum Error { 5 | BadScheme, 6 | ParseError(ParseError), 7 | } 8 | 9 | impl std::fmt::Display for Error { 10 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 11 | use Error::*; 12 | 13 | match self { 14 | BadScheme => write!(f, "URL scheme is not allowed"), 15 | ParseError(e) => write!(f, "{e}"), 16 | } 17 | } 18 | } 19 | 20 | impl std::error::Error for Error {} 21 | 22 | impl From for Error { 23 | fn from(err: ParseError) -> Error { Error::ParseError(err) } 24 | } 25 | 26 | type Result = core::result::Result; 27 | 28 | /// Try to convert some type into a [`Url`]. 29 | /// 30 | /// This trait is "sealed", such that only types within payjoin can 31 | /// implement it. 32 | /// 33 | /// This design is inspired by the `reqwest` crate's design: 34 | /// see 35 | pub trait IntoUrl: IntoUrlSealed {} 36 | 37 | impl IntoUrl for &Url {} 38 | impl IntoUrl for Url {} 39 | impl IntoUrl for &str {} 40 | impl IntoUrl for &String {} 41 | impl IntoUrl for String {} 42 | 43 | pub trait IntoUrlSealed { 44 | /// Besides parsing as a valid `Url`, the `Url` must be a valid 45 | /// `http::Uri`, in that it makes sense to use in a network request. 46 | fn into_url(self) -> Result; 47 | 48 | fn as_str(&self) -> &str; 49 | } 50 | 51 | impl IntoUrlSealed for &Url { 52 | fn into_url(self) -> Result { self.clone().into_url() } 53 | 54 | fn as_str(&self) -> &str { self.as_ref() } 55 | } 56 | 57 | impl IntoUrlSealed for Url { 58 | fn into_url(self) -> Result { 59 | if self.has_host() { 60 | Ok(self) 61 | } else { 62 | Err(Error::BadScheme) 63 | } 64 | } 65 | 66 | fn as_str(&self) -> &str { self.as_ref() } 67 | } 68 | 69 | impl IntoUrlSealed for &str { 70 | fn into_url(self) -> Result { Url::parse(self)?.into_url() } 71 | 72 | fn as_str(&self) -> &str { self } 73 | } 74 | 75 | impl IntoUrlSealed for &String { 76 | fn into_url(self) -> Result { (&**self).into_url() } 77 | 78 | fn as_str(&self) -> &str { self.as_ref() } 79 | } 80 | 81 | impl IntoUrlSealed for String { 82 | fn into_url(self) -> Result { (&*self).into_url() } 83 | 84 | fn as_str(&self) -> &str { self.as_ref() } 85 | } 86 | 87 | #[cfg(test)] 88 | mod tests { 89 | use super::*; 90 | 91 | #[test] 92 | fn http_uri_scheme_is_allowed() { 93 | let url = "http://localhost".into_url().unwrap(); 94 | assert_eq!(url.scheme(), "http"); 95 | } 96 | 97 | #[test] 98 | fn https_uri_scheme_is_allowed() { 99 | let url = "https://localhost".into_url().unwrap(); 100 | assert_eq!(url.scheme(), "https"); 101 | } 102 | 103 | #[test] 104 | fn into_url_file_scheme() { 105 | let err = "file:///etc/hosts".into_url().unwrap_err(); 106 | assert_eq!(err.to_string(), "URL scheme is not allowed"); 107 | } 108 | 109 | #[test] 110 | fn into_url_blob_scheme() { 111 | let err = "blob:https://example.com".into_url().unwrap_err(); 112 | assert_eq!(err.to_string(), "URL scheme is not allowed"); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /payjoin/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_cfg))] 2 | 3 | //! # Payjoin implementation in Rust 4 | //! 5 | //! Supercharge payment batching to save you fees and preserve your privacy. 6 | //! 7 | //! This library implements both [BIP 78 Payjoin V1](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) and [BIP 77 Payjoin V2](https://github.com/bitcoin/bips/blob/master/bip-0077.md). 8 | //! 9 | //! Only the latest BIP 77 Payjoin V2 is enabled by default. To use BIP 78 Payjoin V1, enable the `v1` feature. 10 | //! 11 | //! The library is perfectly IO-agnostic — in fact, it does no IO by default without the `io` feature. 12 | //! 13 | //! Types relevant to a Payjoin Directory as defined in BIP 77 are available in the [`directory`] module enabled by 14 | //! the `directory` feature. 15 | //! 16 | //! ## Disclaimer ⚠️ WIP 17 | //! 18 | //! **Use at your own risk. This crate has not yet been reviewed by independent Rust and Bitcoin security professionals.** 19 | 20 | #[cfg(not(any(feature = "directory", feature = "v1", feature = "v2")))] 21 | compile_error!("At least one of the features ['directory', 'v1', 'v2'] must be enabled"); 22 | 23 | #[cfg(feature = "_core")] 24 | pub extern crate bitcoin; 25 | 26 | #[cfg(feature = "_core")] 27 | pub mod receive; 28 | #[cfg(feature = "_core")] 29 | pub mod send; 30 | 31 | #[cfg(feature = "v2")] 32 | pub mod persist; 33 | 34 | #[cfg(feature = "v2")] 35 | pub(crate) mod hpke; 36 | #[cfg(feature = "v2")] 37 | pub use crate::hpke::{HpkeKeyPair, HpkePublicKey}; 38 | #[cfg(feature = "v2")] 39 | pub(crate) mod ohttp; 40 | #[cfg(feature = "v2")] 41 | pub use crate::ohttp::OhttpKeys; 42 | #[cfg(any(feature = "v2", feature = "directory"))] 43 | pub(crate) mod bech32; 44 | #[cfg(feature = "directory")] 45 | #[cfg_attr(docsrs, doc(cfg(feature = "directory")))] 46 | pub mod directory; 47 | 48 | #[cfg(feature = "_core")] 49 | pub(crate) mod into_url; 50 | #[cfg(feature = "io")] 51 | #[cfg_attr(docsrs, doc(cfg(feature = "io")))] 52 | pub mod io; 53 | #[cfg(feature = "_core")] 54 | pub(crate) mod psbt; 55 | #[cfg(feature = "_core")] 56 | mod request; 57 | #[cfg(feature = "_core")] 58 | pub use request::*; 59 | #[cfg(feature = "_core")] 60 | pub(crate) mod output_substitution; 61 | #[cfg(feature = "v1")] 62 | pub use output_substitution::OutputSubstitution; 63 | #[cfg(feature = "_core")] 64 | mod uri; 65 | #[cfg(feature = "_core")] 66 | pub use into_url::{Error as IntoUrlError, IntoUrl}; 67 | #[cfg(feature = "_core")] 68 | pub use uri::{PjParseError, PjUri, Uri, UriExt}; 69 | #[cfg(feature = "_core")] 70 | pub use url::{ParseError, Url}; 71 | #[cfg(feature = "_core")] 72 | pub mod core; 73 | #[cfg(feature = "_core")] 74 | pub(crate) mod error_codes; 75 | #[cfg(feature = "_core")] 76 | pub use crate::core::error::ImplementationError; 77 | #[cfg(feature = "_core")] 78 | pub(crate) use crate::core::version::Version; 79 | 80 | /// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length 81 | /// 4_000_000 * 4 / 3 fits in u32 82 | pub const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3; 83 | -------------------------------------------------------------------------------- /payjoin/src/output_substitution.rs: -------------------------------------------------------------------------------- 1 | /// Whether the receiver is allowed to substitute original outputs or not. 2 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 3 | #[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))] 4 | pub enum OutputSubstitution { 5 | Enabled, 6 | Disabled, 7 | } 8 | 9 | impl OutputSubstitution { 10 | /// Combine two output substitution flags. 11 | /// 12 | /// If both are enabled, the result is enabled. 13 | /// If one is disabled, the result is disabled. 14 | pub(crate) fn combine(self, other: Self) -> Self { 15 | match (self, other) { 16 | (Self::Enabled, Self::Enabled) => Self::Enabled, 17 | _ => Self::Disabled, 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /payjoin/src/psbt/merge.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for merging unique v0 PSBTs 2 | use bitcoin::Psbt; 3 | 4 | /// Try to merge two PSBTs 5 | /// PSBTs here should not have the same unsigned tx 6 | /// if you do have the same unsigned tx, use `combine` instead 7 | /// Note: this method does not merge non inputs or outputs 8 | /// Note: if there are duplicate inputs, the first input will be kept 9 | /// Note: if there are duplicate outputs, both outputs will be kept 10 | pub(crate) fn merge_unsigned_tx(acc: Psbt, psbt: Psbt) -> Psbt { 11 | let mut unsigned_tx = acc.unsigned_tx; 12 | unsigned_tx.input.extend(psbt.unsigned_tx.input); 13 | unsigned_tx.input.dedup_by_key(|input| input.previous_output); 14 | unsigned_tx.output.extend(psbt.unsigned_tx.output); 15 | 16 | let mut merged_psbt = 17 | Psbt::from_unsigned_tx(unsigned_tx).expect("pulling from unsigned tx above"); 18 | let zip = acc.inputs.iter().chain(psbt.inputs.iter()).collect::>(); 19 | merged_psbt.inputs.iter_mut().enumerate().for_each(|(i, input)| { 20 | input.witness_utxo = zip[i].witness_utxo.clone(); 21 | }); 22 | merged_psbt 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use bitcoin::absolute::LockTime; 28 | use bitcoin::hashes::Hash; 29 | use bitcoin::key::rand::Rng; 30 | use bitcoin::secp256k1::rand::thread_rng; 31 | use bitcoin::secp256k1::SECP256K1; 32 | use bitcoin::{ 33 | Amount, Network, OutPoint, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, 34 | Witness, 35 | }; 36 | 37 | use super::merge_unsigned_tx; 38 | 39 | /// Create a random p2wpkh script 40 | fn random_p2wpkh_script() -> ScriptBuf { 41 | let sk = bitcoin::PrivateKey::generate(Network::Bitcoin); 42 | let pk = sk.public_key(SECP256K1); 43 | 44 | pk.p2wpkh_script_code().unwrap() 45 | } 46 | 47 | /// Create a random 32 byte txid 48 | fn random_txid() -> Txid { 49 | let mut rng = thread_rng(); 50 | let mut txid = [0u8; 32]; 51 | rng.try_fill(&mut txid).expect("should fill"); 52 | Txid::from_slice(&txid).unwrap() 53 | } 54 | 55 | /// Create a tx with random inputs and outputs 56 | /// Note: all outputs have the same 1000 sat value 57 | /// Transactions are created with version 2 58 | fn create_tx(num_inputs: usize, num_outputs: usize) -> Transaction { 59 | let txid = random_txid(); 60 | 61 | let mut inputs = vec![]; 62 | for i in 0..num_inputs { 63 | let op = OutPoint::new(txid, i as u32); 64 | inputs.push(TxIn { 65 | previous_output: op, 66 | script_sig: ScriptBuf::new(), 67 | sequence: Sequence::MAX, 68 | witness: Default::default(), 69 | }); 70 | } 71 | 72 | let mut outputs = vec![]; 73 | for _ in 0..num_outputs { 74 | outputs.push(TxOut { 75 | value: Amount::from_sat(1000), 76 | script_pubkey: random_p2wpkh_script(), 77 | }); 78 | } 79 | 80 | Transaction { 81 | version: bitcoin::transaction::Version(2), 82 | lock_time: LockTime::ZERO, 83 | input: inputs, 84 | output: outputs, 85 | } 86 | } 87 | 88 | /// Test that we can merge two psbts with unique unsigned txs 89 | #[test] 90 | fn test_merge_unsigned_txs() { 91 | let txs = (0..10).map(|_| create_tx(2, 3)).collect::>(); 92 | let psbts = txs.iter().map(|tx| Psbt::from_unsigned_tx(tx.clone()).unwrap()); 93 | let merged_psbt = psbts.reduce(merge_unsigned_tx).unwrap(); 94 | 95 | for tx in txs.iter() { 96 | assert!(merged_psbt.unsigned_tx.input.contains(&tx.input[0])); 97 | assert!(merged_psbt.unsigned_tx.input.contains(&tx.input[1])); 98 | assert!(merged_psbt.unsigned_tx.output.contains(&tx.output[0])); 99 | assert!(merged_psbt.unsigned_tx.output.contains(&tx.output[1])); 100 | assert!(merged_psbt.unsigned_tx.output.contains(&tx.output[2])); 101 | } 102 | } 103 | 104 | /// Test merging empty PSBTs 105 | #[test] 106 | fn test_merge_empty_psbts() { 107 | let tx_1 = create_tx(0, 0); 108 | let tx_2 = create_tx(0, 0); 109 | let psbts = 110 | vec![Psbt::from_unsigned_tx(tx_1).unwrap(), Psbt::from_unsigned_tx(tx_2).unwrap()]; 111 | 112 | let merged_psbt = psbts.into_iter().reduce(merge_unsigned_tx).unwrap(); 113 | 114 | assert_eq!(merged_psbt.inputs.len(), 0); 115 | assert_eq!(merged_psbt.outputs.len(), 0); 116 | } 117 | 118 | /// Test that we cannot merge two psbts if psbts share inputs 119 | #[test] 120 | fn should_not_merge_if_psbt_share_inputs() { 121 | let tx = create_tx(1, 1); 122 | let psbt = Psbt::from_unsigned_tx(tx.clone()).unwrap(); 123 | let psbts = vec![psbt.clone(), psbt.clone()]; 124 | 125 | let res = psbts.into_iter().reduce(merge_unsigned_tx).unwrap(); 126 | let unsigned_tx = res.unsigned_tx; 127 | 128 | assert_eq!(unsigned_tx.input.len(), 1); 129 | assert_eq!(unsigned_tx.input[0].previous_output, tx.input[0].previous_output); 130 | assert_eq!(unsigned_tx.output.len(), 2); 131 | assert_eq!(unsigned_tx.output[0], tx.output[0]); 132 | assert_eq!(unsigned_tx.output[1], tx.output[0]); 133 | } 134 | 135 | /// Test that we cannot merge two psbts if psbts have inputs with witness data 136 | #[test] 137 | fn should_not_merge_signed_psbt() { 138 | let tx_1 = create_tx(1, 1); 139 | let tx_2 = create_tx(1, 1); 140 | let mut original_psbt = Psbt::from_unsigned_tx(tx_1.clone()).unwrap(); 141 | let mut other = Psbt::from_unsigned_tx(tx_2.clone()).unwrap(); 142 | 143 | original_psbt.inputs[0].final_script_witness = Some(Witness::new()); 144 | original_psbt.unsigned_tx.input[0].witness = Witness::new(); 145 | other.inputs[0].final_script_witness = Some(Witness::new()); 146 | let psbts = vec![original_psbt.clone(), other.clone()]; 147 | let merged_psbt = psbts.into_iter().reduce(merge_unsigned_tx).unwrap(); 148 | 149 | assert_eq!(merged_psbt.unsigned_tx.input[0], original_psbt.unsigned_tx.input[0]); 150 | assert_eq!(merged_psbt.unsigned_tx.input[1], other.unsigned_tx.input[0]); 151 | assert_eq!(merged_psbt.unsigned_tx.output[0], original_psbt.unsigned_tx.output[0]); 152 | assert_eq!(merged_psbt.unsigned_tx.output[1], other.unsigned_tx.output[0]); 153 | } 154 | 155 | /// Test merging PSBTs with only inputs or only outputs 156 | #[test] 157 | fn test_merge_inputs_or_outputs_only() { 158 | let tx_1 = create_tx(2, 0); 159 | let tx_2 = create_tx(0, 3); 160 | 161 | let psbts = 162 | vec![Psbt::from_unsigned_tx(tx_1).unwrap(), Psbt::from_unsigned_tx(tx_2).unwrap()]; 163 | 164 | let merged_psbt = psbts.into_iter().reduce(merge_unsigned_tx).unwrap(); 165 | 166 | assert_eq!(merged_psbt.inputs.len(), 2); 167 | assert_eq!(merged_psbt.outputs.len(), 3); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /payjoin/src/receive/multiparty/error.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::error; 3 | 4 | use crate::uri::ShortId; 5 | use crate::Version; 6 | 7 | #[derive(Debug)] 8 | pub struct MultipartyError(InternalMultipartyError); 9 | 10 | #[derive(Debug)] 11 | pub(crate) enum InternalMultipartyError { 12 | /// Not enough proposals 13 | NotEnoughProposals, 14 | /// Duplicate proposals 15 | IdenticalProposals(IdenticalProposalError), 16 | /// Proposal version not supported 17 | ProposalVersionNotSupported(Version), 18 | /// Optimistic merge not supported 19 | OptimisticMergeNotSupported, 20 | /// Bitcoin Internal Error 21 | BitcoinExtractTxError(Box), 22 | /// Input in Finalized Proposal is missing witness or script_sig 23 | InputMissingWitnessOrScriptSig, 24 | /// Failed to combine psbts 25 | FailedToCombinePsbts(bitcoin::psbt::Error), 26 | } 27 | 28 | #[derive(Debug)] 29 | pub enum IdenticalProposalError { 30 | IdenticalPsbts(Box, Box), 31 | IdenticalContexts(Box, Box), 32 | } 33 | 34 | impl std::fmt::Display for IdenticalProposalError { 35 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 36 | match self { 37 | IdenticalProposalError::IdenticalPsbts(current_psbt, incoming_psbt) => write!( 38 | f, 39 | "Two sender psbts are identical\n left psbt: {current_psbt}\n right psbt: {incoming_psbt}" 40 | ), 41 | IdenticalProposalError::IdenticalContexts(current_context, incoming_context) => write!( 42 | f, 43 | "Two sender contexts are identical\n left id: {current_context}\n right id: {incoming_context}" 44 | ), 45 | } 46 | } 47 | } 48 | 49 | impl From for MultipartyError { 50 | fn from(e: InternalMultipartyError) -> Self { MultipartyError(e) } 51 | } 52 | 53 | impl fmt::Display for MultipartyError { 54 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 55 | match &self.0 { 56 | InternalMultipartyError::NotEnoughProposals => write!(f, "Not enough proposals"), 57 | InternalMultipartyError::IdenticalProposals(e) => 58 | write!(f, "More than one identical participant: {e}"), 59 | InternalMultipartyError::ProposalVersionNotSupported(v) => 60 | write!(f, "Proposal version not supported: {v}"), 61 | InternalMultipartyError::OptimisticMergeNotSupported => 62 | write!(f, "Optimistic merge not supported"), 63 | InternalMultipartyError::BitcoinExtractTxError(e) => 64 | write!(f, "Bitcoin extract tx error: {e:?}"), 65 | InternalMultipartyError::InputMissingWitnessOrScriptSig => 66 | write!(f, "Input in Finalized Proposal is missing witness or script_sig"), 67 | InternalMultipartyError::FailedToCombinePsbts(e) => 68 | write!(f, "Failed to combine psbts: {e:?}"), 69 | } 70 | } 71 | } 72 | 73 | impl error::Error for MultipartyError { 74 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 75 | match &self.0 { 76 | InternalMultipartyError::NotEnoughProposals => None, 77 | InternalMultipartyError::IdenticalProposals(_) => None, 78 | InternalMultipartyError::ProposalVersionNotSupported(_) => None, 79 | InternalMultipartyError::OptimisticMergeNotSupported => None, 80 | InternalMultipartyError::BitcoinExtractTxError(e) => Some(e), 81 | InternalMultipartyError::InputMissingWitnessOrScriptSig => None, 82 | InternalMultipartyError::FailedToCombinePsbts(e) => Some(e), 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /payjoin/src/receive/optional_parameters.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Borrow; 2 | use std::fmt; 3 | 4 | use bitcoin::FeeRate; 5 | use log::warn; 6 | 7 | use crate::output_substitution::OutputSubstitution; 8 | use crate::Version; 9 | 10 | #[derive(Debug, Clone)] 11 | pub(crate) struct Params { 12 | // version 13 | pub v: Version, 14 | // disableoutputsubstitution 15 | pub output_substitution: OutputSubstitution, 16 | // maxadditionalfeecontribution, additionalfeeoutputindex 17 | pub additional_fee_contribution: Option<(bitcoin::Amount, usize)>, 18 | // minfeerate 19 | pub min_fee_rate: FeeRate, 20 | #[cfg(feature = "_multiparty")] 21 | /// Opt in to optimistic psbt merge 22 | pub optimistic_merge: bool, 23 | } 24 | 25 | impl Default for Params { 26 | fn default() -> Self { 27 | Params { 28 | v: Version::One, 29 | output_substitution: OutputSubstitution::Enabled, 30 | additional_fee_contribution: None, 31 | min_fee_rate: FeeRate::BROADCAST_MIN, 32 | #[cfg(feature = "_multiparty")] 33 | optimistic_merge: false, 34 | } 35 | } 36 | } 37 | 38 | impl Params { 39 | pub fn from_query_pairs( 40 | pairs: I, 41 | supported_versions: &'static [Version], 42 | ) -> Result 43 | where 44 | I: Iterator, 45 | K: Borrow + Into, 46 | V: Borrow + Into, 47 | { 48 | let mut params = Params::default(); 49 | 50 | let mut additional_fee_output_index = None; 51 | let mut max_additional_fee_contribution = None; 52 | 53 | for (key, v) in pairs { 54 | match (key.borrow(), v.borrow()) { 55 | ("v", version) => 56 | params.v = match version { 57 | "1" => Version::One, 58 | "2" => Version::Two, 59 | _ => return Err(Error::UnknownVersion { supported_versions }), 60 | }, 61 | ("additionalfeeoutputindex", index) => 62 | additional_fee_output_index = match index.parse::() { 63 | Ok(index) => Some(index), 64 | Err(_error) => { 65 | warn!("bad `additionalfeeoutputindex` query value '{index}': {_error}"); 66 | None 67 | } 68 | }, 69 | ("maxadditionalfeecontribution", fee) => 70 | max_additional_fee_contribution = 71 | match bitcoin::Amount::from_str_in(fee, bitcoin::Denomination::Satoshi) { 72 | Ok(contribution) => Some(contribution), 73 | Err(_error) => { 74 | warn!( 75 | "bad `maxadditionalfeecontribution` query value '{fee}': {_error}" 76 | ); 77 | None 78 | } 79 | }, 80 | ("minfeerate", fee_rate) => 81 | params.min_fee_rate = match fee_rate.parse::() { 82 | Ok(fee_rate_sat_per_vb) => { 83 | // TODO Parse with serde when rust-bitcoin supports it 84 | let fee_rate_sat_per_kwu = fee_rate_sat_per_vb * 250.0_f32; 85 | // since it's a minimum, we want to round up 86 | FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64) 87 | } 88 | Err(_) => return Err(Error::FeeRate), 89 | }, 90 | ("disableoutputsubstitution", v) => 91 | params.output_substitution = if v == "true" { 92 | OutputSubstitution::Disabled 93 | } else { 94 | OutputSubstitution::Enabled 95 | }, 96 | #[cfg(feature = "_multiparty")] 97 | ("optimisticmerge", v) => params.optimistic_merge = v == "true", 98 | _ => (), 99 | } 100 | } 101 | 102 | match (max_additional_fee_contribution, additional_fee_output_index) { 103 | (Some(amount), Some(index)) => 104 | params.additional_fee_contribution = Some((amount, index)), 105 | (Some(_), None) | (None, Some(_)) => { 106 | warn!("only one additional-fee parameter specified: {params:?}"); 107 | } 108 | (None, None) => (), 109 | } 110 | 111 | log::debug!("parsed optional parameters: {params:?}"); 112 | Ok(params) 113 | } 114 | } 115 | 116 | #[derive(Debug, PartialEq, Eq)] 117 | pub(crate) enum Error { 118 | UnknownVersion { supported_versions: &'static [Version] }, 119 | FeeRate, 120 | } 121 | 122 | impl fmt::Display for Error { 123 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 124 | match self { 125 | Error::UnknownVersion { .. } => write!(f, "unknown version"), 126 | Error::FeeRate => write!(f, "could not parse feerate"), 127 | } 128 | } 129 | } 130 | 131 | impl std::error::Error for Error { 132 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } 133 | } 134 | 135 | #[cfg(test)] 136 | pub(crate) mod test { 137 | use bitcoin::{Amount, FeeRate}; 138 | 139 | use super::*; 140 | use crate::receive::optional_parameters::Params; 141 | use crate::Version; 142 | 143 | #[test] 144 | fn test_parse_params() { 145 | let pairs = url::form_urlencoded::parse(b"&maxadditionalfeecontribution=182&additionalfeeoutputindex=0&minfeerate=2&disableoutputsubstitution=true&optimisticmerge=true"); 146 | let params = Params::from_query_pairs(pairs, &[Version::One]) 147 | .expect("Could not parse params from query pairs"); 148 | assert_eq!(params.v, Version::One); 149 | assert_eq!(params.output_substitution, OutputSubstitution::Disabled); 150 | assert_eq!(params.additional_fee_contribution, Some((Amount::from_sat(182), 0))); 151 | assert_eq!( 152 | params.min_fee_rate, 153 | FeeRate::from_sat_per_vb(2).expect("Could not calculate feerate") 154 | ); 155 | #[cfg(feature = "_multiparty")] 156 | assert!(params.optimistic_merge) 157 | } 158 | 159 | #[test] 160 | fn from_query_pairs_unsupported_versions() { 161 | let invalid_pair: Vec<(&str, &str)> = vec![("v", "888")]; 162 | let supported_versions = &[Version::One, Version::Two]; 163 | let params = Params::from_query_pairs(invalid_pair.into_iter(), supported_versions); 164 | assert!(params.is_err()); 165 | assert_eq!(params.err().unwrap(), Error::UnknownVersion { supported_versions }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /payjoin/src/receive/v1/exclusive/error.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::error; 3 | 4 | use crate::receive::JsonReply; 5 | 6 | /// Error that occurs during validation of an incoming v1 payjoin request. 7 | /// 8 | /// This type provides a stable public API for v1 request validation errors while keeping internal 9 | /// error variants private. It handles validation of: 10 | /// - PSBT parsing and validation 11 | /// - I/O operations during request processing 12 | /// - HTTP headers (Content-Type, Content-Length) 13 | /// 14 | /// The error messages are formatted as JSON strings according to the BIP-78 spec with appropriate 15 | /// error codes and human-readable messages. 16 | #[derive(Debug)] 17 | pub struct RequestError(InternalRequestError); 18 | 19 | #[derive(Debug)] 20 | pub(crate) enum InternalRequestError { 21 | /// I/O error while reading the request body 22 | Io(std::io::Error), 23 | /// A required HTTP header is missing from the request 24 | MissingHeader(&'static str), 25 | /// The Content-Type header has an invalid value 26 | InvalidContentType(String), 27 | /// The Content-Length header could not be parsed as a number 28 | InvalidContentLength(std::num::ParseIntError), 29 | /// The Content-Length value exceeds the maximum allowed size 30 | ContentLengthTooLarge(usize), 31 | } 32 | 33 | impl From for RequestError { 34 | fn from(value: InternalRequestError) -> Self { RequestError(value) } 35 | } 36 | 37 | impl From for super::ReplyableError { 38 | fn from(e: InternalRequestError) -> Self { super::ReplyableError::V1(e.into()) } 39 | } 40 | 41 | impl From for JsonReply { 42 | fn from(e: RequestError) -> Self { 43 | use InternalRequestError::*; 44 | 45 | match &e.0 { 46 | Io(_) 47 | | MissingHeader(_) 48 | | InvalidContentType(_) 49 | | InvalidContentLength(_) 50 | | ContentLengthTooLarge(_) => 51 | JsonReply::new(crate::error_codes::ErrorCode::OriginalPsbtRejected, e), 52 | } 53 | } 54 | } 55 | 56 | impl fmt::Display for RequestError { 57 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 58 | match &self.0 { 59 | InternalRequestError::Io(e) => write!(f, "{e}"), 60 | InternalRequestError::MissingHeader(header) => write!(f, "Missing header: {header}"), 61 | InternalRequestError::InvalidContentType(content_type) => 62 | write!(f, "Invalid content type: {content_type}"), 63 | InternalRequestError::InvalidContentLength(e) => write!(f, "{e}"), 64 | InternalRequestError::ContentLengthTooLarge(length) => 65 | write!(f, "Content length too large: {length}."), 66 | } 67 | } 68 | } 69 | 70 | impl error::Error for RequestError { 71 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 72 | match &self.0 { 73 | InternalRequestError::Io(e) => Some(e), 74 | InternalRequestError::InvalidContentLength(e) => Some(e), 75 | InternalRequestError::MissingHeader(_) => None, 76 | InternalRequestError::InvalidContentType(_) => None, 77 | InternalRequestError::ContentLengthTooLarge(_) => None, 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /payjoin/src/receive/v1/exclusive/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | pub(crate) use error::InternalRequestError; 3 | pub use error::RequestError; 4 | 5 | use super::*; 6 | use crate::into_url::IntoUrl; 7 | use crate::{Version, MAX_CONTENT_LENGTH}; 8 | 9 | const SUPPORTED_VERSIONS: &[Version] = &[Version::One]; 10 | 11 | pub trait Headers { 12 | fn get_header(&self, key: &str) -> Option<&str>; 13 | } 14 | 15 | pub fn build_v1_pj_uri<'a>( 16 | address: &bitcoin::Address, 17 | endpoint: impl IntoUrl, 18 | output_substitution: OutputSubstitution, 19 | ) -> Result, crate::into_url::Error> { 20 | let extras = crate::uri::PayjoinExtras { endpoint: endpoint.into_url()?, output_substitution }; 21 | Ok(bitcoin_uri::Uri::with_extras(address.clone(), extras)) 22 | } 23 | 24 | impl UncheckedProposal { 25 | pub fn from_request( 26 | body: impl std::io::Read, 27 | query: &str, 28 | headers: impl Headers, 29 | ) -> Result { 30 | let parsed_body = parse_body(headers, body).map_err(ReplyableError::V1)?; 31 | 32 | let base64 = String::from_utf8(parsed_body).map_err(InternalPayloadError::Utf8)?; 33 | 34 | let (psbt, params) = crate::receive::parse_payload(base64, query, SUPPORTED_VERSIONS) 35 | .map_err(ReplyableError::Payload)?; 36 | 37 | Ok(UncheckedProposal { psbt, params }) 38 | } 39 | } 40 | 41 | /// Validate the request headers for a Payjoin request 42 | /// 43 | /// [`RequestError`] should only be produced here. 44 | fn parse_body( 45 | headers: impl Headers, 46 | mut body: impl std::io::Read, 47 | ) -> Result, RequestError> { 48 | let content_type = headers 49 | .get_header("content-type") 50 | .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; 51 | if !content_type.starts_with("text/plain") { 52 | return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); 53 | } 54 | 55 | let content_length = headers 56 | .get_header("content-length") 57 | .ok_or(InternalRequestError::MissingHeader("Content-Length"))? 58 | .parse::() 59 | .map_err(InternalRequestError::InvalidContentLength)?; 60 | if content_length > MAX_CONTENT_LENGTH { 61 | return Err(InternalRequestError::ContentLengthTooLarge(content_length).into()); 62 | } 63 | 64 | let mut buf = vec![0; content_length]; 65 | body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; 66 | Ok(buf) 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use bitcoin::{Address, AddressType}; 72 | use payjoin_test_utils::{ORIGINAL_PSBT, QUERY_PARAMS}; 73 | 74 | use super::*; 75 | 76 | #[derive(Debug, Clone)] 77 | struct MockHeaders { 78 | length: String, 79 | } 80 | 81 | impl MockHeaders { 82 | fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } 83 | } 84 | 85 | impl Headers for MockHeaders { 86 | fn get_header(&self, key: &str) -> Option<&str> { 87 | match key { 88 | "content-length" => Some(&self.length), 89 | "content-type" => Some("text/plain"), 90 | _ => None, 91 | } 92 | } 93 | } 94 | 95 | #[test] 96 | fn test_parse_body() { 97 | let mut padded_body = ORIGINAL_PSBT.as_bytes().to_vec(); 98 | assert_eq!(MAX_CONTENT_LENGTH, 5333333_usize); 99 | padded_body.resize(MAX_CONTENT_LENGTH + 1, 0); 100 | let headers = MockHeaders::new(padded_body.len() as u64); 101 | 102 | let parsed_request = parse_body(headers.clone(), padded_body.as_slice()); 103 | assert!(parsed_request.is_err()); 104 | match parsed_request { 105 | Ok(_) => panic!("Expected error, got success"), 106 | Err(error) => { 107 | assert_eq!( 108 | error.to_string(), 109 | RequestError::from(InternalRequestError::ContentLengthTooLarge( 110 | padded_body.len() 111 | )) 112 | .to_string() 113 | ); 114 | } 115 | } 116 | } 117 | 118 | #[test] 119 | fn test_from_request() -> Result<(), Box> { 120 | let body = ORIGINAL_PSBT.as_bytes(); 121 | let headers = MockHeaders::new(body.len() as u64); 122 | let parsed_request = parse_body(headers.clone(), body); 123 | assert!(parsed_request.is_ok()); 124 | 125 | let proposal = UncheckedProposal::from_request(body, QUERY_PARAMS, headers)?; 126 | 127 | let witness_utxo = 128 | proposal.psbt.inputs[0].witness_utxo.as_ref().expect("witness_utxo should be present"); 129 | let address = 130 | Address::from_script(&witness_utxo.script_pubkey, bitcoin::params::Params::MAINNET)?; 131 | assert_eq!(address.address_type(), Some(AddressType::P2sh)); 132 | 133 | assert_eq!(proposal.params.v, Version::One); 134 | assert_eq!(proposal.params.additional_fee_contribution, Some((Amount::from_sat(182), 0))); 135 | Ok(()) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /payjoin/src/receive/v2/error.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use std::error; 3 | 4 | use super::Error::V2; 5 | use crate::hpke::HpkeError; 6 | use crate::ohttp::OhttpEncapsulationError; 7 | use crate::receive::error::Error; 8 | 9 | /// Error that may occur during a v2 session typestate change 10 | /// 11 | /// This is currently opaque type because we aren't sure which variants will stay. 12 | /// You can only display it. 13 | #[derive(Debug)] 14 | pub struct SessionError(InternalSessionError); 15 | 16 | impl From for SessionError { 17 | fn from(value: InternalSessionError) -> Self { SessionError(value) } 18 | } 19 | 20 | impl From for Error { 21 | fn from(e: InternalSessionError) -> Self { V2(e.into()) } 22 | } 23 | 24 | #[derive(Debug)] 25 | pub(crate) enum InternalSessionError { 26 | /// Url parsing failed 27 | ParseUrl(crate::into_url::Error), 28 | /// The session has expired 29 | Expired(std::time::SystemTime), 30 | /// OHTTP Encapsulation failed 31 | OhttpEncapsulation(OhttpEncapsulationError), 32 | /// Hybrid Public Key Encryption failed 33 | Hpke(HpkeError), 34 | /// Unexpected response size 35 | UnexpectedResponseSize(usize), 36 | /// Unexpected status code 37 | UnexpectedStatusCode(http::StatusCode), 38 | } 39 | 40 | impl From for Error { 41 | fn from(e: OhttpEncapsulationError) -> Self { 42 | InternalSessionError::OhttpEncapsulation(e).into() 43 | } 44 | } 45 | 46 | impl From for Error { 47 | fn from(e: HpkeError) -> Self { InternalSessionError::Hpke(e).into() } 48 | } 49 | 50 | impl fmt::Display for SessionError { 51 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 52 | use InternalSessionError::*; 53 | 54 | match &self.0 { 55 | ParseUrl(e) => write!(f, "URL parsing failed: {e}"), 56 | Expired(expiry) => write!(f, "Session expired at {expiry:?}"), 57 | OhttpEncapsulation(e) => write!(f, "OHTTP Encapsulation Error: {e}"), 58 | Hpke(e) => write!(f, "Hpke decryption failed: {e}"), 59 | UnexpectedResponseSize(size) => write!( 60 | f, 61 | "Unexpected response size {}, expected {} bytes", 62 | size, 63 | crate::directory::ENCAPSULATED_MESSAGE_BYTES 64 | ), 65 | UnexpectedStatusCode(status) => write!(f, "Unexpected status code: {status}"), 66 | } 67 | } 68 | } 69 | 70 | impl error::Error for SessionError { 71 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 72 | use InternalSessionError::*; 73 | 74 | match &self.0 { 75 | ParseUrl(e) => Some(e), 76 | Expired(_) => None, 77 | OhttpEncapsulation(e) => Some(e), 78 | Hpke(e) => Some(e), 79 | UnexpectedResponseSize(_) => None, 80 | UnexpectedStatusCode(_) => None, 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /payjoin/src/receive/v2/persist.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use super::{Receiver, WithContext}; 4 | use crate::persist::{self}; 5 | use crate::uri::ShortId; 6 | 7 | /// Opaque key type for the receiver 8 | #[derive(Debug, Clone, PartialEq, Eq)] 9 | pub struct ReceiverToken(ShortId); 10 | 11 | impl Display for ReceiverToken { 12 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } 13 | } 14 | 15 | impl From> for ReceiverToken { 16 | fn from(receiver: Receiver) -> Self { ReceiverToken(receiver.context.id()) } 17 | } 18 | 19 | impl AsRef<[u8]> for ReceiverToken { 20 | fn as_ref(&self) -> &[u8] { self.0.as_bytes() } 21 | } 22 | 23 | impl persist::Value for Receiver { 24 | type Key = ReceiverToken; 25 | 26 | fn key(&self) -> Self::Key { ReceiverToken(self.context.id()) } 27 | } 28 | -------------------------------------------------------------------------------- /payjoin/src/request.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | const V1_REQ_CONTENT_TYPE: &str = "text/plain"; 4 | 5 | #[cfg(feature = "v2")] 6 | const V2_REQ_CONTENT_TYPE: &str = "message/ohttp-req"; 7 | 8 | /// Represents data that needs to be transmitted to the receiver or payjoin directory. 9 | /// Ensure the `Content-Length` is set to the length of `body`. (most libraries do this automatically) 10 | #[non_exhaustive] 11 | #[derive(Debug, Clone)] 12 | pub struct Request { 13 | /// URL to send the request to. 14 | /// 15 | /// This is full URL with scheme etc - you can pass it right to `reqwest` or a similar library. 16 | pub url: Url, 17 | 18 | /// The `Content-Type` header to use for the request. 19 | /// 20 | /// `text/plain` for v1 requests and `message/ohttp-req` for v2 requests. 21 | pub content_type: &'static str, 22 | 23 | /// Bytes to be sent to the receiver. 24 | /// 25 | /// This is properly encoded PSBT payload either in base64 in v1 or an OHTTP encapsulated payload in v2. 26 | pub body: Vec, 27 | } 28 | 29 | impl Request { 30 | /// Construct a new v1 request. 31 | pub(crate) fn new_v1(url: &Url, body: &[u8]) -> Self { 32 | Self { url: url.clone(), content_type: V1_REQ_CONTENT_TYPE, body: body.to_vec() } 33 | } 34 | 35 | /// Construct a new v2 request. 36 | #[cfg(feature = "v2")] 37 | pub(crate) fn new_v2( 38 | url: &Url, 39 | body: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES], 40 | ) -> Self { 41 | Self { url: url.clone(), content_type: V2_REQ_CONTENT_TYPE, body: body.to_vec() } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /payjoin/src/send/multiparty/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use bitcoin::psbt::Error as PsbtError; 4 | 5 | use crate::hpke::HpkeError; 6 | use crate::ohttp::OhttpEncapsulationError; 7 | use crate::send::InternalProposalError; 8 | use crate::uri::url_ext::ParseReceiverPubkeyParamError; 9 | use crate::ImplementationError; 10 | 11 | #[derive(Debug)] 12 | pub struct CreateRequestError(InternalCreateRequestError); 13 | 14 | #[derive(Debug)] 15 | pub(crate) enum InternalCreateRequestError { 16 | #[allow(dead_code)] 17 | Expired(std::time::SystemTime), 18 | MissingOhttpConfig, 19 | ParseReceiverPubkeyParam(ParseReceiverPubkeyParamError), 20 | V2CreateRequest(crate::send::v2::CreateRequestError), 21 | } 22 | 23 | impl From for CreateRequestError { 24 | fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } 25 | } 26 | 27 | impl Display for CreateRequestError { 28 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self.0) } 29 | } 30 | 31 | impl std::error::Error for CreateRequestError { 32 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 33 | match &self.0 { 34 | InternalCreateRequestError::Expired(_) => None, 35 | InternalCreateRequestError::MissingOhttpConfig => None, 36 | InternalCreateRequestError::ParseReceiverPubkeyParam(e) => Some(e), 37 | InternalCreateRequestError::V2CreateRequest(e) => Some(e), 38 | } 39 | } 40 | } 41 | 42 | #[derive(Debug)] 43 | pub struct FinalizedError(InternalFinalizedError); 44 | 45 | #[derive(Debug)] 46 | pub(crate) enum InternalFinalizedError { 47 | Hpke(HpkeError), 48 | InvalidSize, 49 | #[allow(dead_code)] 50 | FinalizePsbt(ImplementationError), 51 | MissingResponse, 52 | Psbt(PsbtError), 53 | #[allow(dead_code)] 54 | UnexpectedStatusCode(http::StatusCode), 55 | Proposal(InternalProposalError), 56 | Ohttp(OhttpEncapsulationError), 57 | } 58 | 59 | impl From for FinalizedError { 60 | fn from(value: InternalFinalizedError) -> Self { FinalizedError(value) } 61 | } 62 | 63 | impl Display for FinalizedError { 64 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self.0) } 65 | } 66 | 67 | impl std::error::Error for FinalizedError { 68 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 69 | match &self.0 { 70 | InternalFinalizedError::Hpke(e) => Some(e), 71 | InternalFinalizedError::InvalidSize => None, 72 | InternalFinalizedError::FinalizePsbt(_) => None, 73 | InternalFinalizedError::MissingResponse => None, 74 | InternalFinalizedError::Psbt(e) => Some(e), 75 | InternalFinalizedError::UnexpectedStatusCode(_) => None, 76 | InternalFinalizedError::Proposal(e) => Some(e), 77 | InternalFinalizedError::Ohttp(e) => Some(e), 78 | } 79 | } 80 | } 81 | 82 | #[derive(Debug)] 83 | pub struct FinalizeResponseError(InternalFinalizeResponseError); 84 | 85 | #[derive(Debug)] 86 | pub(crate) enum InternalFinalizeResponseError { 87 | #[allow(dead_code)] 88 | InvalidSize(usize), 89 | Ohttp(OhttpEncapsulationError), 90 | #[allow(dead_code)] 91 | UnexpectedStatusCode(http::StatusCode), 92 | } 93 | 94 | impl From for FinalizeResponseError { 95 | fn from(value: InternalFinalizeResponseError) -> Self { FinalizeResponseError(value) } 96 | } 97 | 98 | impl Display for FinalizeResponseError { 99 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self.0) } 100 | } 101 | 102 | impl std::error::Error for FinalizeResponseError { 103 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 104 | match &self.0 { 105 | InternalFinalizeResponseError::InvalidSize(_) => None, 106 | InternalFinalizeResponseError::Ohttp(e) => Some(e), 107 | InternalFinalizeResponseError::UnexpectedStatusCode(_) => None, 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /payjoin/src/send/multiparty/persist.rs: -------------------------------------------------------------------------------- 1 | use crate::persist::{self, Persister}; 2 | use crate::send::multiparty::{ImplementationError, NewSender, Sender}; 3 | use crate::send::v2::{self, SenderToken}; 4 | 5 | impl NewSender { 6 | pub fn persist>( 7 | &self, 8 | persister: &mut P, 9 | ) -> Result { 10 | let sender = Sender(v2::Sender { 11 | state: v2::WithReplyKey { v1: self.0.v1.clone(), reply_key: self.0.reply_key.clone() }, 12 | }); 13 | persister.save(sender).map_err(ImplementationError::from) 14 | } 15 | } 16 | 17 | impl persist::Value for Sender { 18 | type Key = SenderToken; 19 | 20 | fn key(&self) -> Self::Key { SenderToken(self.0.endpoint().clone()) } 21 | } 22 | 23 | impl Sender { 24 | pub fn load>( 25 | token: P::Token, 26 | persister: &P, 27 | ) -> Result { 28 | let sender = persister.load(token).map_err(ImplementationError::from)?; 29 | Ok(sender) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /payjoin/src/send/v2/error.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | 3 | use crate::uri::url_ext::ParseReceiverPubkeyParamError; 4 | 5 | /// Error returned when request could not be created. 6 | /// 7 | /// This error can currently only happen due to programmer mistake. 8 | /// `unwrap()`ing it is thus considered OK in Rust but you may achieve nicer message by displaying 9 | /// it. 10 | #[derive(Debug)] 11 | pub struct CreateRequestError(InternalCreateRequestError); 12 | 13 | #[derive(Debug)] 14 | pub(crate) enum InternalCreateRequestError { 15 | Url(crate::into_url::Error), 16 | Hpke(crate::hpke::HpkeError), 17 | OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), 18 | ParseReceiverPubkey(ParseReceiverPubkeyParamError), 19 | MissingOhttpConfig, 20 | Expired(std::time::SystemTime), 21 | } 22 | 23 | impl fmt::Display for CreateRequestError { 24 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 25 | use InternalCreateRequestError::*; 26 | 27 | match &self.0 { 28 | Url(e) => write!(f, "cannot parse url: {e:#?}"), 29 | Hpke(e) => write!(f, "v2 error: {e}"), 30 | OhttpEncapsulation(e) => write!(f, "v2 error: {e}"), 31 | ParseReceiverPubkey(e) => write!(f, "cannot parse receiver public key: {e}"), 32 | MissingOhttpConfig => 33 | write!(f, "no ohttp configuration with which to make a v2 request available"), 34 | Expired(expiry) => write!(f, "session expired at {expiry:?}"), 35 | } 36 | } 37 | } 38 | 39 | impl std::error::Error for CreateRequestError { 40 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 41 | use InternalCreateRequestError::*; 42 | 43 | match &self.0 { 44 | Url(error) => Some(error), 45 | Hpke(error) => Some(error), 46 | OhttpEncapsulation(error) => Some(error), 47 | ParseReceiverPubkey(error) => Some(error), 48 | MissingOhttpConfig => None, 49 | Expired(_) => None, 50 | } 51 | } 52 | } 53 | 54 | impl From for CreateRequestError { 55 | fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } 56 | } 57 | 58 | impl From for CreateRequestError { 59 | fn from(value: crate::into_url::Error) -> Self { 60 | CreateRequestError(InternalCreateRequestError::Url(value)) 61 | } 62 | } 63 | 64 | impl From for CreateRequestError { 65 | fn from(value: ParseReceiverPubkeyParamError) -> Self { 66 | CreateRequestError(InternalCreateRequestError::ParseReceiverPubkey(value)) 67 | } 68 | } 69 | 70 | /// Error returned for v2-specific payload encapsulation errors. 71 | #[derive(Debug)] 72 | pub struct EncapsulationError(InternalEncapsulationError); 73 | 74 | #[derive(Debug)] 75 | pub(crate) enum InternalEncapsulationError { 76 | /// The response size is not the expected size. 77 | InvalidSize(usize), 78 | /// The status code is not the expected status code. 79 | UnexpectedStatusCode(http::StatusCode), 80 | /// The HPKE failed. 81 | Hpke(crate::hpke::HpkeError), 82 | /// The encapsulation failed. 83 | Ohttp(crate::ohttp::OhttpEncapsulationError), 84 | } 85 | 86 | impl fmt::Display for EncapsulationError { 87 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 88 | use InternalEncapsulationError::*; 89 | 90 | match &self.0 { 91 | InvalidSize(size) => write!(f, "invalid size: {size}"), 92 | UnexpectedStatusCode(status) => write!(f, "unexpected status code: {status}"), 93 | Ohttp(error) => write!(f, "OHTTP encapsulation error: {error}"), 94 | Hpke(error) => write!(f, "HPKE error: {error}"), 95 | } 96 | } 97 | } 98 | 99 | impl std::error::Error for EncapsulationError { 100 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 101 | use InternalEncapsulationError::*; 102 | 103 | match &self.0 { 104 | InvalidSize(_) => None, 105 | UnexpectedStatusCode(_) => None, 106 | Ohttp(error) => Some(error), 107 | Hpke(error) => Some(error), 108 | } 109 | } 110 | } 111 | 112 | impl From for EncapsulationError { 113 | fn from(value: InternalEncapsulationError) -> Self { EncapsulationError(value) } 114 | } 115 | 116 | impl From for super::ResponseError { 117 | fn from(value: InternalEncapsulationError) -> Self { 118 | super::ResponseError::Validation( 119 | super::InternalValidationError::V2Encapsulation(value.into()).into(), 120 | ) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /payjoin/src/send/v2/persist.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use url::Url; 4 | 5 | use super::{Sender, WithReplyKey}; 6 | use crate::persist::Value; 7 | 8 | /// Opaque key type for the sender 9 | #[derive(Debug, Clone, PartialEq, Eq)] 10 | pub struct SenderToken(pub(crate) Url); 11 | 12 | impl Display for SenderToken { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } 14 | } 15 | 16 | impl From> for SenderToken { 17 | fn from(sender: Sender) -> Self { SenderToken(sender.endpoint().clone()) } 18 | } 19 | 20 | impl AsRef<[u8]> for SenderToken { 21 | fn as_ref(&self) -> &[u8] { self.0.as_str().as_bytes() } 22 | } 23 | 24 | impl Value for Sender { 25 | type Key = SenderToken; 26 | 27 | fn key(&self) -> Self::Key { SenderToken(self.endpoint().clone()) } 28 | } 29 | -------------------------------------------------------------------------------- /payjoin/src/uri/error.rs: -------------------------------------------------------------------------------- 1 | use url::ParseError; 2 | 3 | #[derive(Debug)] 4 | pub struct PjParseError(pub(crate) InternalPjParseError); 5 | 6 | #[derive(Debug)] 7 | pub(crate) enum InternalPjParseError { 8 | BadPjOs, 9 | DuplicateParams(&'static str), 10 | MissingEndpoint, 11 | NotUtf8, 12 | BadEndpoint(BadEndpointError), 13 | UnsecureEndpoint, 14 | } 15 | 16 | #[derive(Debug)] 17 | pub enum BadEndpointError { 18 | UrlParse(ParseError), 19 | #[cfg(feature = "v2")] 20 | LowercaseFragment, 21 | } 22 | 23 | impl std::fmt::Display for BadEndpointError { 24 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 25 | match self { 26 | BadEndpointError::UrlParse(e) => write!(f, "Invalid URL: {e:?}"), 27 | #[cfg(feature = "v2")] 28 | BadEndpointError::LowercaseFragment => 29 | write!(f, "Some or all of the fragment is lowercase"), 30 | } 31 | } 32 | } 33 | 34 | impl From for PjParseError { 35 | fn from(value: InternalPjParseError) -> Self { PjParseError(value) } 36 | } 37 | 38 | impl std::fmt::Display for PjParseError { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | use InternalPjParseError::*; 41 | match &self.0 { 42 | BadPjOs => write!(f, "Bad pjos parameter"), 43 | DuplicateParams(param) => { 44 | write!(f, "Multiple instances of parameter '{param}'") 45 | } 46 | MissingEndpoint => write!(f, "Missing payjoin endpoint"), 47 | NotUtf8 => write!(f, "Endpoint is not valid UTF-8"), 48 | BadEndpoint(e) => write!(f, "Endpoint is not valid: {e:?}"), 49 | UnsecureEndpoint => { 50 | write!(f, "Endpoint scheme is not secure (https or onion)") 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | ## This is copied from https://github.com/rust-bitcoin/rust-bitcoin/blob/master/rustfmt.toml 2 | 3 | hard_tabs = false 4 | tab_spaces = 4 5 | newline_style = "Auto" 6 | indent_style = "Block" 7 | 8 | max_width = 100 # This is number of characters. 9 | # `use_small_heuristics` is ignored if the granular width config values are explicitly set. 10 | use_small_heuristics = "Max" # "Max" == All granular width settings same as `max_width`. 11 | # # Granular width configuration settings. These are percentages of `max_width`. 12 | # fn_call_width = 60 13 | # attr_fn_like_width = 70 14 | # struct_lit_width = 18 15 | # struct_variant_width = 35 16 | # array_width = 60 17 | # chain_width = 60 18 | # single_line_if_else_max_width = 50 19 | 20 | wrap_comments = false 21 | format_code_in_doc_comments = false 22 | comment_width = 100 # Default 80 23 | normalize_comments = false 24 | normalize_doc_attributes = false 25 | format_strings = false 26 | format_macro_matchers = false 27 | format_macro_bodies = true 28 | hex_literal_case = "Preserve" 29 | empty_item_single_line = true 30 | struct_lit_single_line = true 31 | fn_single_line = true # Default false 32 | where_single_line = false 33 | imports_indent = "Block" 34 | imports_layout = "Mixed" 35 | imports_granularity = "Module" # Default "Preserve" 36 | group_imports = "StdExternalCrate" # Default "Preserve" 37 | reorder_imports = true 38 | reorder_modules = true 39 | reorder_impl_items = false 40 | type_punctuation_density = "Wide" 41 | space_before_colon = false 42 | space_after_colon = true 43 | spaces_around_ranges = false 44 | binop_separator = "Front" 45 | remove_nested_parens = true 46 | combine_control_expr = true 47 | overflow_delimited_expr = false 48 | struct_field_align_threshold = 0 49 | enum_discrim_align_threshold = 0 50 | match_arm_blocks = false # Default true 51 | match_arm_leading_pipes = "Never" 52 | force_multiline_blocks = false 53 | fn_params_layout = "Tall" 54 | brace_style = "SameLineWhere" 55 | control_brace_style = "AlwaysSameLine" 56 | trailing_semicolon = true 57 | trailing_comma = "Vertical" 58 | match_block_trailing_comma = false 59 | blank_lines_upper_bound = 1 60 | blank_lines_lower_bound = 0 61 | edition = "2018" 62 | style_edition = "2018" 63 | inline_attribute_width = 0 64 | format_generated_files = true 65 | merge_derives = true 66 | use_try_shorthand = false 67 | use_field_init_shorthand = false 68 | force_explicit_abi = true 69 | condense_wildcard_suffixes = false 70 | color = "Auto" 71 | unstable_features = false 72 | disable_all_formatting = false 73 | skip_children = false 74 | show_parse_errors = true 75 | error_on_line_overflow = false 76 | error_on_unformatted = false 77 | emit_mode = "Files" 78 | make_backup = false 79 | -------------------------------------------------------------------------------- /static/monad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | --------------------------------------------------------------------------------