From cd3ad4730c029c252bcfa60886f9ffebd9fb1b8b Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 15 May 2025 16:36:09 -0400 Subject: [PATCH 1/8] Introduce payjoin-ffi Brought over as-is from https://github.com/LtbLightning/payjoin-ffi at commit d3a2b6f25970e217ab540f701e303746aec279a0. Copied the entire repository into the `payjoin-ffi` directory and removed the `.git` directory. The following author list can be obtained with by running `git shortlog -se` at that commit. Co-authored-by: Armin Sabouri Co-authored-by: benalleng Co-authored-by: Bitcoin Zavior <93057399+BitcoinZavior@users.noreply.github.com> Co-authored-by: BitcoinZavior Co-authored-by: Brandon Lucas Co-authored-by: Dan Gould Co-authored-by: DanGould Co-authored-by: Kirill Zhukov Co-authored-by: kumulynja Co-authored-by: River Kanies Co-authored-by: spacebear <144076611+spacebear21@users.noreply.github.com> Co-authored-by: spacebear Co-authored-by: StaxOLotl Co-authored-by: user --- .../.github/workflows/build-python.yml | 122 ++++ payjoin-ffi/.github/workflows/build.yml | 53 ++ payjoin-ffi/.gitignore | 19 + payjoin-ffi/CHANGELOG.md | 84 +++ payjoin-ffi/Cargo.toml | 57 ++ payjoin-ffi/LICENSE-APACHE | 201 +++++++ payjoin-ffi/LICENSE-MIT | 16 + payjoin-ffi/LICENSE.md | 14 + payjoin-ffi/README.md | 55 ++ payjoin-ffi/build.rs | 4 + payjoin-ffi/contrib/lint.sh | 6 + payjoin-ffi/lefthook.yml | 9 + payjoin-ffi/python/.gitignore | 20 + payjoin-ffi/python/CHANGELOG.md | 44 ++ payjoin-ffi/python/MANIFEST.in | 3 + payjoin-ffi/python/README.md | 92 +++ payjoin-ffi/python/pyproject.toml | 7 + payjoin-ffi/python/requirements-dev.txt | 4 + payjoin-ffi/python/requirements.txt | 4 + .../python/scripts/bindgen_generate.sh | 10 + payjoin-ffi/python/scripts/generate_linux.sh | 22 + payjoin-ffi/python/scripts/generate_macos.sh | 32 + payjoin-ffi/python/setup.py | 38 ++ payjoin-ffi/python/src/payjoin/__init__.py | 1 + payjoin-ffi/python/test/__init__.py | 0 .../test/test_payjoin_integration_test.py | 252 ++++++++ .../python/test/test_payjoin_unit_test.py | 115 ++++ payjoin-ffi/rust-toolchain.toml | 3 + payjoin-ffi/rustfmt.toml | 77 +++ payjoin-ffi/src/bitcoin_ffi.rs | 56 ++ payjoin-ffi/src/error.rs | 18 + payjoin-ffi/src/io.rs | 57 ++ payjoin-ffi/src/lib.rs | 29 + payjoin-ffi/src/ohttp.rs | 70 +++ payjoin-ffi/src/output_substitution.rs | 8 + payjoin-ffi/src/payjoin_ffi.udl | 3 + payjoin-ffi/src/receive/error.rs | 112 ++++ payjoin-ffi/src/receive/mod.rs | 540 +++++++++++++++++ payjoin-ffi/src/receive/uni.rs | 551 ++++++++++++++++++ payjoin-ffi/src/request.rs | 34 ++ payjoin-ffi/src/send/error.rs | 92 +++ payjoin-ffi/src/send/mod.rs | 266 +++++++++ payjoin-ffi/src/send/uni.rs | 323 ++++++++++ payjoin-ffi/src/test_utils.rs | 263 +++++++++ payjoin-ffi/src/uri/error.rs | 35 ++ payjoin-ffi/src/uri/mod.rs | 134 +++++ payjoin-ffi/tests/bdk_integration_test.rs | 432 ++++++++++++++ payjoin-ffi/uniffi-bindgen.rs | 4 + payjoin-ffi/uniffi.toml | 9 + 49 files changed, 4400 insertions(+) create mode 100644 payjoin-ffi/.github/workflows/build-python.yml create mode 100644 payjoin-ffi/.github/workflows/build.yml create mode 100644 payjoin-ffi/.gitignore create mode 100644 payjoin-ffi/CHANGELOG.md create mode 100644 payjoin-ffi/Cargo.toml create mode 100644 payjoin-ffi/LICENSE-APACHE create mode 100644 payjoin-ffi/LICENSE-MIT create mode 100644 payjoin-ffi/LICENSE.md create mode 100644 payjoin-ffi/README.md create mode 100644 payjoin-ffi/build.rs create mode 100755 payjoin-ffi/contrib/lint.sh create mode 100644 payjoin-ffi/lefthook.yml create mode 100644 payjoin-ffi/python/.gitignore create mode 100644 payjoin-ffi/python/CHANGELOG.md create mode 100644 payjoin-ffi/python/MANIFEST.in create mode 100644 payjoin-ffi/python/README.md create mode 100644 payjoin-ffi/python/pyproject.toml create mode 100644 payjoin-ffi/python/requirements-dev.txt create mode 100644 payjoin-ffi/python/requirements.txt create mode 100644 payjoin-ffi/python/scripts/bindgen_generate.sh create mode 100755 payjoin-ffi/python/scripts/generate_linux.sh create mode 100644 payjoin-ffi/python/scripts/generate_macos.sh create mode 100644 payjoin-ffi/python/setup.py create mode 100644 payjoin-ffi/python/src/payjoin/__init__.py create mode 100644 payjoin-ffi/python/test/__init__.py create mode 100644 payjoin-ffi/python/test/test_payjoin_integration_test.py create mode 100644 payjoin-ffi/python/test/test_payjoin_unit_test.py create mode 100644 payjoin-ffi/rust-toolchain.toml create mode 100644 payjoin-ffi/rustfmt.toml create mode 100644 payjoin-ffi/src/bitcoin_ffi.rs create mode 100644 payjoin-ffi/src/error.rs create mode 100644 payjoin-ffi/src/io.rs create mode 100644 payjoin-ffi/src/lib.rs create mode 100644 payjoin-ffi/src/ohttp.rs create mode 100644 payjoin-ffi/src/output_substitution.rs create mode 100644 payjoin-ffi/src/payjoin_ffi.udl create mode 100644 payjoin-ffi/src/receive/error.rs create mode 100644 payjoin-ffi/src/receive/mod.rs create mode 100644 payjoin-ffi/src/receive/uni.rs create mode 100644 payjoin-ffi/src/request.rs create mode 100644 payjoin-ffi/src/send/error.rs create mode 100644 payjoin-ffi/src/send/mod.rs create mode 100644 payjoin-ffi/src/send/uni.rs create mode 100644 payjoin-ffi/src/test_utils.rs create mode 100644 payjoin-ffi/src/uri/error.rs create mode 100644 payjoin-ffi/src/uri/mod.rs create mode 100644 payjoin-ffi/tests/bdk_integration_test.rs create mode 100644 payjoin-ffi/uniffi-bindgen.rs create mode 100644 payjoin-ffi/uniffi.toml diff --git a/payjoin-ffi/.github/workflows/build-python.yml b/payjoin-ffi/.github/workflows/build-python.yml new file mode 100644 index 000000000..313b0a0f0 --- /dev/null +++ b/payjoin-ffi/.github/workflows/build-python.yml @@ -0,0 +1,122 @@ +# Copied from [bdk-ffi](https://github.com/bitcoindevkit/bdk-ffi/blob/master/.github/workflows/test-python.yaml) +name: Build and Test Python +on: [push, pull_request] + +jobs: + build-wheels-and-test: + name: "Build and test wheels with Redis" + runs-on: ubuntu-latest + services: + redis: + image: redis:7-alpine + defaults: + run: + working-directory: python + strategy: + matrix: + include: + - python: "3.9" + - python: "3.10" + - python: "3.11" + - python: "3.12" + - python: "3.13" + steps: + - name: "Checkout" + uses: actions/checkout@v4 + + - name: "Install Rust 1.78.0" + uses: actions-rs/toolchain@v1 + with: + toolchain: 1.78.0 + + - name: "Install Python" + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - name: "Install build dependencies" + run: | + sudo apt update + sudo apt install -y build-essential python3-dev + + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + + - name: "Generate payjoin-ffi.py and binaries" + run: | + PYBIN=$(dirname $(which python)) + PYBIN="$PYBIN" bash ./scripts/generate_linux.sh + + - name: "Build wheel" + run: python setup.py bdist_wheel --verbose + + - name: "Install wheel" + run: pip install ./dist/*.whl + + - name: "Run tests" + env: + REDIS_URL: redis://localhost:6379 + run: python -m unittest -v + + build-macos: + name: "Build and test macOS" + runs-on: macos-13 + defaults: + run: + working-directory: python + strategy: + matrix: + python: + - "3.12" + steps: + - name: "Checkout" + uses: actions/checkout@v4 + with: + submodules: true + + - name: "Install Rust 1.78.0" + uses: actions-rs/toolchain@v1 + with: + toolchain: 1.78.0 + + - name: "Install Python" + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - name: Setup Docker on macOS + uses: douglascamata/setup-docker-macos-action@v1.0.0 + + - name: "Install Redis" + run: | + brew update + brew install redis + + - name: "Start Redis" + run: | + redis-server --daemonize yes + for i in {1..10}; do + if redis-cli ping | grep -q PONG; then + echo "Redis is ready" + break + fi + echo "Waiting for Redis..." + sleep 1 + done + + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + + - name: "Generate payjoin-ffi.py and binaries" + run: bash ./scripts/generate_macos.sh + + - name: "Build wheel" + run: python3 setup.py bdist_wheel --verbose + + - name: "Install wheel" + run: pip3 install ./dist/*.whl + + - name: "Run tests" + env: + REDIS_URL: redis://localhost:6379 + run: python3 -m unittest -v diff --git a/payjoin-ffi/.github/workflows/build.yml b/payjoin-ffi/.github/workflows/build.yml new file mode 100644 index 000000000..c4d1f4dd2 --- /dev/null +++ b/payjoin-ffi/.github/workflows/build.yml @@ -0,0 +1,53 @@ +name: CI Checks + +on: [push, pull_request] + +jobs: + Test: + name: Build and Test + strategy: + matrix: + toolchain: [stable, nightly] + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust ${{ matrix.toolchain }} toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.toolchain }} + override: true + profile: minimal + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: Build on Rust ${{ matrix.toolchain }} + run: cargo build --color always --all-targets --features _danger-local-https,_test-utils + - name: Run tests + run: cargo test --features=_danger-local-https,_test-utils + + Format: + runs-on: ubuntu-latest + steps: + - name: "Checkout repo" + uses: actions/checkout@v4 + - name: "Install nightly toolchain" + uses: dtolnay/rust-toolchain@nightly + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - run: rustup component add rustfmt --toolchain nightly-x86_64-unknown-linux-gnu + - name: "Run formatting check" + run: cargo fmt --all -- --check + + Lint: + runs-on: ubuntu-latest + steps: + - name: "Checkout repo" + uses: actions/checkout@v4 + - name: "Install nightly toolchain" + uses: dtolnay/rust-toolchain@nightly + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: "Install clippy" + run: rustup component add clippy --toolchain nightly-x86_64-unknown-linux-gnu + - name: "Run linting" + run: cargo clippy --all-targets --keep-going --all-features -- -D warnings diff --git a/payjoin-ffi/.gitignore b/payjoin-ffi/.gitignore new file mode 100644 index 000000000..17c6c02a0 --- /dev/null +++ b/payjoin-ffi/.gitignore @@ -0,0 +1,19 @@ +# Generated by Cargo +# will have compiled files and executables +target/ + + +# These are backup files generated by rustfmt +**/*.rs.bk +.vscode/settings.json +.idea + +# Python related +__pycache__ + + +/python/.vscode/ +/python/payjoin.egg-info/ +/python/.venv/ +/python/.env +.DS_Store diff --git a/payjoin-ffi/CHANGELOG.md b/payjoin-ffi/CHANGELOG.md new file mode 100644 index 000000000..0b7a61a45 --- /dev/null +++ b/payjoin-ffi/CHANGELOG.md @@ -0,0 +1,84 @@ +## [0.23.0] + +- Update to payjoin-0.23.0 +- Expose many error variants + ([#58](https://github.com/LtbLightning/payjoin-ffi/pull/58)) + ([#71](https://github.com/LtbLightning/payjoin-ffi/pull/71)) +- Bind payjoin-test-utils ([#82](https://github.com/LtbLightning/payjoin-ffi/pull/82)) +- Depend on bitcoin-ffi @ 6b1d1315dff8696b5ffeb3e5669f308ade227749 +- Rename to payjoin-ffi from payjoin_ffi to match bitcoin-ffi + +## [0.22.1] +- Expose label and messge params on Uri. ([#44](https://github.com/LtbLightning/payjoin-ffi/pull/44)) + +## [0.22.0] +- Update `payjoin` to `0.22.0`. (Serialize reply_key with Sender [#41](https://github.com/LtbLightning/payjoin-ffi/pull/41)) + +## [0.21.2] +- Add `pj_endpoint` method to `PjUri` types. ([#40](https://github.com/LtbLightning/payjoin-ffi/pull/40)) + +## [0.21.1] +- Add `to_json` and `from_json` methods to `Sender` and `Receiver` UniFFI types. ([#39](https://github.com/LtbLightning/payjoin-ffi/pull/39)) + +## [0.21.0] +This release updates the bindings libraries to `payjoin` version `0.21.0`. +#### APIs changed +- Major overhaul to attempt a stable BIP 77 protocol implementation. +- v1 support is now only available through the V2 backwards-compatible APIs. +- see [payjoin-0.21.0 changelog](https://github.com/payjoin/rust-payjoin/blob/master/payjoin/CHANGELOG.md#0210) for more details. +- Separate `payjoin_ffi` and `payjoin_ffi::uni` UniFFI types into two layers. + +## [0.20.0] +#### APIs added +- Make backwards-compatible `v2` to `v1` sends possible. +#### APIs changed +- Removed `contribute_non_nitness_input` from `v1` & `v2`. +- Allow receivers to make `payjoins` out of sweep transactions ([#259](https://github.com/payjoin/rust-payjoin/pull/259)). +- 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)) + +## [0.18.0] +This release updates the bindings libraries to `payjoin` version `0.18.0`. +#### APIs changed +- Upgrade `receive/v2` type state machine to resume multiple `payjoins` simultaneously ([#283](https://github.com/payjoin/rust-payjoin/pull/283)) +- Refactor output substitution with new fallable `try_substitute_outputs` ([#277](https://github.com/payjoin/rust-payjoin/pull/277)) +- Replaced `Enroller` with `SessionInitializer`. +- Replaced `Enrolled` with `ActiveSession`. +- Replaced `fallback_target()` with `pj_url`. +#### APIs added +- Exposed `PjUriBuilder` and `PjUri`. +- Exposed `pjUrl_builder()` in `ActiveSession`. +- Exposed `check_pj_supported()` in `PjUri`. +- Exposed `fetch_ohttp_keys()` to fetch the `ohttp` keys from the specified `payjoin` directory. + +## [0.13.0] +### Features & Modules +#### Send module +- ##### V1 + - `RequestBuilder` exposes `from_psbt_and_uri`, `build_with_additional_fee`, `build_recommended`, `build_non_incentivizing`, `always_disable_output_substitution`. + - `RequestContext` exposes `extract_contextV1` & `extract_contextV2`. + - `ContextV1` exposes `process_response`. +- ##### V2 + - `ContextV2` exposes `process_response`. +#### Receive module +- ##### V1 + - `UncheckedProposal` exposes `from_request`, `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability`, `build_non_incentivizing`, + `assume_interactive_receiver` &`always_disable_output_substitution`. + - `MaybeInputsOwned` exposes `check_inputs_not_owned`. + - `MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. + - `MaybeInputsSeen` exposes `check_no_inputs_seen_before`. + - `OutputsUnknown` exposes `identify_receiver_outputs`. + - `ProvisionalProposal` exposes `substitute_output_address`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & + `finalize_proposal`. + - `PayjoinProposal` exposes `is_output_substitution_disabled`, `owned_vouts`, `psbt` & `utxos_to_be_locked`. +- ##### V2 + - `Enroller` exposes `from_directory_config`, `process_response` & `extract_request`. + - `Enrolled` exposes `extract_request`, `process_response` & `fall_back_target`. + - `V2UncheckedProposal` exposes `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability` & `assume_interactive_receiver`. + - `V2MaybeInputsOwned` exposes `check_inputs_not_owned`. + - `V2MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. + - `V2MaybeInputsSeen` exposes `check_no_inputs_seen_before`. + - `V2OutputsUnknown` exposes `identify_receiver_outputs`. + - `V2ProvisionalProposal` exposes `substitute_output_address`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & + `finalize_proposal`. + - `V2PayjoinProposal` exposes `deserialize_res`, `extract_v1_req`, `extract_v2_req`, `is_output_substitution_disabled`, `owned_vouts`, `psbt` & + `utxos_to_be_locked`. \ No newline at end of file diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml new file mode 100644 index 000000000..f32f9df4f --- /dev/null +++ b/payjoin-ffi/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "payjoin-ffi" +version = "0.23.0" +edition = "2021" +license = "MIT OR Apache-2.0" +exclude = ["tests"] + +[features] +_test-utils = ["payjoin-test-utils", "tokio", "bitcoind"] +_danger-local-https = ["payjoin/_danger-local-https"] +uniffi = ["uniffi/cli", "bitcoin-ffi/default"] + +[lib] +name = "payjoin_ffi" +crate-type = ["lib", "staticlib", "cdylib"] + +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" + +[build-dependencies] +uniffi = { version = "0.29.1", features = ["build"] } + +[dependencies] +base64 = "0.22.1" +bitcoind = { version = "0.36.0", features = ["0_21_2"], optional = true } +bitcoin-ffi = { git = "https://github.com/benalleng/bitcoin-ffi.git", rev = "8e3a23b" } +hex = "0.4.3" +lazy_static = "1.5.0" +ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } +payjoin = { version = "0.23.0", features = ["v1", "v2", "io"] } +payjoin-test-utils = { version = "0.0.0", optional = true } +serde = { version = "1.0.200", features = ["derive"] } +serde_json = "1.0.128" +thiserror = "1.0.58" +tokio = { version = "1.38.0", features = ["full"], optional = true } +uniffi = { version = "0.29.1", optional = true } +url = "2.5.0" + +[dev-dependencies] +bdk = { version = "0.29.0", features = ["all-keys", "use-esplora-ureq", "keys-bip39", "rpc"] } +bitcoincore-rpc = "0.19.0" +http = "1" +ohttp-relay = "0.0.8" +rcgen = { version = "0.11" } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +rustls = "0.22.2" +testcontainers = "0.15.0" +testcontainers-modules = { version = "0.1.3", features = ["redis"] } +uniffi = { version = "0.29.1", features = ["bindgen-tests"] } + +[profile.release-smaller] +inherits = "release" +opt-level = 'z' +lto = true +codegen-units = 1 +strip = true diff --git a/payjoin-ffi/LICENSE-APACHE b/payjoin-ffi/LICENSE-APACHE new file mode 100644 index 000000000..3d3328465 --- /dev/null +++ b/payjoin-ffi/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/payjoin-ffi/LICENSE-MIT b/payjoin-ffi/LICENSE-MIT new file mode 100644 index 000000000..9d982a4d6 --- /dev/null +++ b/payjoin-ffi/LICENSE-MIT @@ -0,0 +1,16 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/payjoin-ffi/LICENSE.md b/payjoin-ffi/LICENSE.md new file mode 100644 index 000000000..c3f44cabd --- /dev/null +++ b/payjoin-ffi/LICENSE.md @@ -0,0 +1,14 @@ +This software is licensed under [Apache 2.0](LICENSE-APACHE) or +[MIT](LICENSE-MIT), at your option. + +Some files retain their own copyright notice, however, for full authorship +information, see version control history. + +Except as otherwise noted in individual files, all files in this repository are +licensed under the Apache License, Version 2.0 or the MIT license , at your option. + +You may not use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of this software or any files in this repository except in +accordance with one or both of these licenses. diff --git a/payjoin-ffi/README.md b/payjoin-ffi/README.md new file mode 100644 index 000000000..a6580834d --- /dev/null +++ b/payjoin-ffi/README.md @@ -0,0 +1,55 @@ +# Payjoin Language Bindings + +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-78, sourced from the [Payjoin Dev Kit](https://payjoindevkit.org/). + +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. + +With a commitment to collaboration and interoperability, this repository strives to foster a more inclusive and diverse ecosystem around Payjoin and BIP-78, 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 and BIP-78! + +**Current Status:** +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. + +## Supported Target Languages and Platforms + +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. + +The directories below include instructions for using, building, and publishing the native language bindings for [Payjoin Dev Kit] supported by this project. + +| Language | Platform | Published Package | Building Documentation | API Docs | +|----------|-----------------------|-------------------|------------------------------------|----------| +| Python | linux, macOS | payjoin | [Readme payjoin](python/README.md) | | + +## Minimum Supported Rust Version (MSRV) + +This library should compile with any combination of features with Rust 1.78.0. + +## Using the Libraries + +### Python + +```shell +pip install payjoin + +``` +## Running the Integration Test + + +The integration tests illustrates and verify integration using bitcoin core and bdk. + +```shell + +# Run the integration test +cargo test --package payjoin_ffi --test bdk_integration_test v2_to_v2_full_cycle --features _danger-local-https + + +``` +## References + +[Payjoin Dev Kit](https://payjoindevkit.org/) + +[mozilla/uniffi-rs](https://github.com/mozilla/uniffi-rs) + +## Release Status and Disclaimer + +This project is in active development and currently in its Alpha stage. **Please proceed with caution**, particularly when using real funds. +We encourage thorough review, testing, and contributions to help improve its stability and security before considering production use. diff --git a/payjoin-ffi/build.rs b/payjoin-ffi/build.rs new file mode 100644 index 000000000..c1e2e4f46 --- /dev/null +++ b/payjoin-ffi/build.rs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(feature = "uniffi")] + uniffi::generate_scaffolding("src/payjoin_ffi.udl").unwrap(); +} diff --git a/payjoin-ffi/contrib/lint.sh b/payjoin-ffi/contrib/lint.sh new file mode 100755 index 000000000..a162b4840 --- /dev/null +++ b/payjoin-ffi/contrib/lint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +# Run clippy at top level for crates without feature-specific checks +echo "Running workspace lint..." +cargo +nightly clippy --all-targets --keep-going --all-features -- -D warnings diff --git a/payjoin-ffi/lefthook.yml b/payjoin-ffi/lefthook.yml new file mode 100644 index 000000000..71cbb1576 --- /dev/null +++ b/payjoin-ffi/lefthook.yml @@ -0,0 +1,9 @@ +pre-commit: + parallel: true + commands: + test: + run: cargo tests + fmt: + run: cargo fmt --all -- --check + clippy: + run: cargo clippy -- -D warnings \ No newline at end of file diff --git a/payjoin-ffi/python/.gitignore b/payjoin-ffi/python/.gitignore new file mode 100644 index 000000000..6b6514f90 --- /dev/null +++ b/payjoin-ffi/python/.gitignore @@ -0,0 +1,20 @@ + +dist/ +payjoin.egg-info/ +__pycache__/ +.idea/ +.DS_Store + +*.swp +*.whl +build/ +venv + +# Auto-generated shared libraries +*.dylib +*.so +*.dll + +# Auto-generated bindings python file +src/payjoin/payjoin_ffi.py +src/payjoin/bitcoin.py diff --git a/payjoin-ffi/python/CHANGELOG.md b/payjoin-ffi/python/CHANGELOG.md new file mode 100644 index 000000000..03e2ba2ad --- /dev/null +++ b/payjoin-ffi/python/CHANGELOG.md @@ -0,0 +1,44 @@ +## [0.20.0] +#### APIs added +- Make backwards-compatible `v2` to `v1` sends possible. +#### APIs changed +- Removed `contribute_non_nitness_input` from `v1` & `v2`. +- Allow receivers to make `payjoins` out of sweep transactions ([#259](https://github.com/payjoin/rust-payjoin/pull/259)). +- 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)) + +## [0.18.0] +This release updates the python library to `payjoin` version `0.18.0`. +### Features & Modules +#### Send module +- ##### V1 + - `RequestBuilder` exposes `from_psbt_and_uri`, `build_with_additional_fee`, `build_recommended`, `build_non_incentivizing`, + `always_disable_output_substitution`. + - `RequestContext` exposes `extract_contextV1` & `extract_contextV2`. + - `ContextV1` exposes `process_response`. +- ##### V2 + - `ContextV2` exposes `process_response`. +#### Receive module +- ##### V1 + - `UncheckedProposal` exposes `from_request`, `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability`, `build_non_incentivizing`, + `assume_interactive_receiver` & `always_disable_output_substitution`. + - `MaybeInputsOwned` exposes `check_inputs_not_owned`. + - `MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. + - `MaybeInputsSeen` exposes `check_no_inputs_seen_before`. + - `OutputsUnknown` exposes `identify_receiver_outputs`. + - `ProvisionalProposal` exposes `try_substitute_receiver_output`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & + `finalize_proposal`. + - `PayjoinProposal` exposes `is_output_substitution_disabled`, `owned_vouts`, `psbt` & `utxos_to_be_locked`. +- ##### V2 + - `SessionInitializer` exposes `from_directory_config`, `process_res` & `extract_request`. + - `ActiveSession` exposes `extract_request`, `process_res`, `pj_uri_builder` & `pj_url`. + - `V2UncheckedProposal` exposes `extract_tx_to_schedule_broadcast`, `check_broadcast_suitability` & `assume_interactive_receiver`. + - `V2MaybeInputsOwned` exposes `check_inputs_not_owned`. + - `V2MaybeMixedInputScripts` exposes `check_no_mixed_input_scripts`. + - `V2MaybeInputsSeen` exposes `check_no_inputs_seen_before`. + - `V2OutputsUnknown` exposes `identify_receiver_outputs`. + - `V2ProvisionalProposal` exposes `try_substitute_receiver_output`, `contribute_non_witness_input`, `contribute_witness_input`, `try_preserving_privacy` & + `finalize_proposal`. + - `V2PayjoinProposal` exposes `process_res`, `extract_v1_req`, `extract_v2_req`, `is_output_substitution_disabled`, `owned_vouts`, `psbt` & + `utxos_to_be_locked`. +#### io module +- Exposed `fetch_ohttp_keys()` to fetch the `ohttp` keys from the specified `payjoin` directory. \ No newline at end of file diff --git a/payjoin-ffi/python/MANIFEST.in b/payjoin-ffi/python/MANIFEST.in new file mode 100644 index 000000000..76da9dd66 --- /dev/null +++ b/payjoin-ffi/python/MANIFEST.in @@ -0,0 +1,3 @@ +include ./src/payjoin/libpayjoin_ffi.dylib +include ./src/payjoin/payjoin_ffi.dll +include ./src/payjoin/libpayjoin_ffi.so \ No newline at end of file diff --git a/payjoin-ffi/python/README.md b/payjoin-ffi/python/README.md new file mode 100644 index 000000000..1faa3d8e4 --- /dev/null +++ b/payjoin-ffi/python/README.md @@ -0,0 +1,92 @@ +# Payjoin + +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. + +## Install from PyPI + +Grab the latest release with a simple: + +```shell +pip install payjoin +``` + +## Running Unit Tests +Follow these steps to clone the repository and run the unit tests: + +```shell + +git clone https://github.com/LtbLightning/payjoin-ffi.git +cd python + +# Setup a python virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install --requirement requirements.txt --requirement requirements-dev.txt + +# Generate the bindings (use the script appropriate for your platform) +PYBIN="./venv/bin/" bash ./scripts/generate_.sh + +# Build the wheel +python setup.py bdist_wheel --verbose + +# Force reinstall payjoin +pip install ./dist/payjoin-.whl --force-reinstall + +# Run unit tests +python -m unittest --verbose test/payjoin_unit_test.py + +``` + +## Running the Integration Test + +Before diving into the integration test, you'll need to set up Bitcoin Core on the regtest network. If you don't have Bitcoin Core installed locally, check out [this installation guide](https://learn.saylor.org/mod/page/view.php?id=36347). Alternatively, you can use `Nigiri Bitcoin`, a tool designed to streamline the process of running local instances of Bitcoin and Liquid networks for development and testing. Follow the instructions [here](https://github.com/vulpemventures/nigiri) to install it on your machine. + +Now, proceed with the integration test: + +```shell + +git clone https://github.com/LtbLightning/payjoin-ffi.git +cd python + +# Setup a python virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install --requirement requirements.txt --requirement requirements-dev.txt + +# Generate the bindings (use the script appropriate for your platform) +PYBIN="./venv/bin/" bash ./scripts/generate_.sh + +# Build the wheel +python setup.py bdist_wheel --verbose + +# Force reinstall payjoin +pip install ./dist/payjoin-.whl --force-reinstall + +# Run the integration test +python -m unittest --verbose test/payjoin_integration_test.py + +``` + +## Building the Package + +```shell +# Setup a python virtual environment +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install --requirement requirements.txt + +# Generate the bindings (use the script appropriate for your platform) +PYBIN="./venv/bin/" bash ./scripts/generate_.sh + +# Build the wheel +python setup.py --verbose bdist_wheel + +``` +We hope everything worked smoothly! Now go forth test, and may your test results be as reliable as the Bitcoin blockchain itself! +₿🔒🤝 diff --git a/payjoin-ffi/python/pyproject.toml b/payjoin-ffi/python/pyproject.toml new file mode 100644 index 000000000..2012f16d6 --- /dev/null +++ b/payjoin-ffi/python/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["setuptools", "wheel", "setuptools-rust"] + +[tool.pytest.ini_options] +pythonpath = [ + "." +] \ No newline at end of file diff --git a/payjoin-ffi/python/requirements-dev.txt b/payjoin-ffi/python/requirements-dev.txt new file mode 100644 index 000000000..b8f78f5d7 --- /dev/null +++ b/payjoin-ffi/python/requirements-dev.txt @@ -0,0 +1,4 @@ +python-bitcoinlib==0.12.2 +toml==0.10.2 +yapf==0.43.0 +httpx==0.28.1 diff --git a/payjoin-ffi/python/requirements.txt b/payjoin-ffi/python/requirements.txt new file mode 100644 index 000000000..8b1ee9e08 --- /dev/null +++ b/payjoin-ffi/python/requirements.txt @@ -0,0 +1,4 @@ +semantic-version==2.9.0 +typing_extensions==4.0.1 +setuptools==67.4.0 +wheel==0.38.4 \ No newline at end of file diff --git a/payjoin-ffi/python/scripts/bindgen_generate.sh b/payjoin-ffi/python/scripts/bindgen_generate.sh new file mode 100644 index 000000000..e32043c5e --- /dev/null +++ b/payjoin-ffi/python/scripts/bindgen_generate.sh @@ -0,0 +1,10 @@ + + +#!/bin/bash +chmod +x ./scripts/generate_linux.sh +chmod +x ./scripts/generate_macos.sh + + +# Run each script +scripts/generate_linux.sh +scripts/generate_macos.sh diff --git a/payjoin-ffi/python/scripts/generate_linux.sh b/payjoin-ffi/python/scripts/generate_linux.sh new file mode 100755 index 000000000..510bb282c --- /dev/null +++ b/payjoin-ffi/python/scripts/generate_linux.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +${PYBIN}/python --version +${PYBIN}/pip install -r requirements.txt -r requirements-dev.txt +LIBNAME=libpayjoin_ffi.so +LINUX_TARGET=x86_64-unknown-linux-gnu + +echo "Generating payjoin_ffi.py..." +cd ../ +# This is a test script the actual release should not include the test utils feature +cargo build --profile release --features uniffi,_test-utils +cargo run --profile release --features uniffi,_test-utils --bin uniffi-bindgen generate --library target/release/$LIBNAME --language python --out-dir python/src/payjoin/ + +echo "Generating native binaries..." +rustup target add $LINUX_TARGET +# This is a test script the actual release should not include the test utils feature +cargo build --profile release-smaller --target $LINUX_TARGET --features uniffi,_test-utils + +echo "Copying linux payjoin_ffi.so" +cp target/$LINUX_TARGET/release-smaller/$LIBNAME python/src/payjoin/$LIBNAME + +echo "All done!" diff --git a/payjoin-ffi/python/scripts/generate_macos.sh b/payjoin-ffi/python/scripts/generate_macos.sh new file mode 100644 index 000000000..c8edf0c92 --- /dev/null +++ b/payjoin-ffi/python/scripts/generate_macos.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail +python3 --version +pip install -r requirements.txt -r requirements-dev.txt +LIBNAME=libpayjoin_ffi.dylib + +echo "Generating payjoin_ffi.py..." +cd ../ +# This is a test script the actual release should not include the test utils feature +cargo build --features uniffi,_test-utils --profile release +cargo run --features uniffi,_test-utils --profile release --bin uniffi-bindgen generate --library target/release/$LIBNAME --language python --out-dir python/src/payjoin/ + +echo "Generating native binaries..." +rustup target add aarch64-apple-darwin x86_64-apple-darwin + +# This is a test script the actual release should not include the test utils feature +cargo build --profile release-smaller --target aarch64-apple-darwin --features uniffi,_test-utils +echo "Done building aarch64-apple-darwin" + +# This is a test script the actual release should not include the test utils feature +cargo build --profile release-smaller --target x86_64-apple-darwin --features uniffi,_test-utils +echo "Done building x86_64-apple-darwin" + +echo "Building macos fat library" + +lipo -create -output python/src/payjoin/$LIBNAME \ + target/aarch64-apple-darwin/release-smaller/$LIBNAME \ + target/x86_64-apple-darwin/release-smaller/$LIBNAME + + +echo "All done!" diff --git a/payjoin-ffi/python/setup.py b/payjoin-ffi/python/setup.py new file mode 100644 index 000000000..3b0acb427 --- /dev/null +++ b/payjoin-ffi/python/setup.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +import os +from setuptools import setup, find_packages +import toml + +# Read version from Cargo.toml +cargo_toml_path = os.path.join(os.path.dirname(__file__), '..', 'Cargo.toml') +cargo_toml = toml.load(cargo_toml_path) +version = cargo_toml['package']['version'] + +LONG_DESCRIPTION = """# payjoin +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 BIP178, sourced from the [Payjoin Dev Kit](https://payjoindevkit.org/). + +## Install the package +```shell +pip install payjoin +``` + +## Usage +```python +import payjoin as payjoin +""" + +setup( + name="payjoin", + description="The Python language bindings for the Payjoin Dev Kit", + long_description=LONG_DESCRIPTION, + long_description_content_type="text/markdown", + include_package_data=True, + zip_safe=False, + packages=["payjoin"], + package_dir={"payjoin": "./src/payjoin"}, + version=version, + license="MIT or Apache 2.0", + has_ext_modules=lambda: True, +) diff --git a/payjoin-ffi/python/src/payjoin/__init__.py b/payjoin-ffi/python/src/payjoin/__init__.py new file mode 100644 index 000000000..8055d1a08 --- /dev/null +++ b/payjoin-ffi/python/src/payjoin/__init__.py @@ -0,0 +1 @@ +from payjoin.payjoin_ffi import * \ No newline at end of file diff --git a/payjoin-ffi/python/test/__init__.py b/payjoin-ffi/python/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py new file mode 100644 index 000000000..9735ad136 --- /dev/null +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -0,0 +1,252 @@ +import base64 +from binascii import unhexlify +import os +import sys +import httpx +import json + +from payjoin import * +from typing import Optional +import payjoin.bitcoin as bitcoinffi + +# The below sys path setting is required to use the 'payjoin' module in the 'src' directory +# This script is in the 'tests' directory and the 'payjoin' module is in the 'src' directory +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")) +) + +import hashlib +import unittest +from pprint import * +from bitcoin import SelectParams +from bitcoin.core.script import ( + CScript, + OP_0, + SignatureHash, +) +from bitcoin.wallet import * +from bitcoin.rpc import Proxy, hexlify_str, JSONRPCError + +SelectParams("regtest") + +class InMemoryReceiverPersister(ReceiverPersister): + def __init__(self): + super().__init__() + self.receivers = {} + + def save(self, receiver: Receiver) -> ReceiverToken: + self.receivers[str(receiver.key())] = receiver.to_json() + + return receiver.key() + + def load(self, token: ReceiverToken) -> Receiver: + token = str(token) + if token not in self.receivers.keys(): + raise ValueError(f"Token not found: {token}") + return Receiver.from_json(self.receivers[token]) + +class InMemorySenderPersister(SenderPersister): + def __init__(self): + super().__init__() + self.senders = {} + + def save(self, sender: Sender) -> SenderToken: + self.senders[str(sender.key())] = sender.to_json() + return sender.key() + + def load(self, token: SenderToken) -> Sender: + token = str(token) + if token not in self.senders.keys(): + raise ValueError(f"Token not found: {token}") + return Sender.from_json(self.senders[token]) + +class TestPayjoin(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + cls.env = init_bitcoind_sender_receiver() + cls.bitcoind = cls.env.get_bitcoind() + cls.receiver = cls.env.get_receiver() + cls.sender = cls.env.get_sender() + + async def test_integration_v2_to_v2(self): + try: + receiver_address = bitcoinffi.Address(json.loads(self.receiver.call("getnewaddress", [])), bitcoinffi.Network.REGTEST) + init_tracing() + services = TestServices.initialize() + + services.wait_for_services_ready() + directory = services.directory_url() + ohttp_keys = services.fetch_ohttp_keys() + + # ********************** + # Inside the Receiver: + new_receiver = NewReceiver(receiver_address, directory.as_string(), ohttp_keys, None) + persister = InMemoryReceiverPersister() + token = new_receiver.persist(persister) + session: Receiver = Receiver.load(token, persister) + print(f"session: {session.to_json()}") + # Poll receive request + ohttp_relay = services.ohttp_relay_url() + request: RequestResponse = session.extract_req(ohttp_relay.as_string()) + agent = httpx.AsyncClient() + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + response_body = session.process_res(response.content, request.client_response) + # No proposal yet since sender has not responded + self.assertIsNone(response_body) + + # ********************** + # Inside the Sender: + # Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + pj_uri = session.pj_uri() + psbt = build_sweep_psbt(self.sender, pj_uri) + new_sender = SenderBuilder(psbt, pj_uri).build_recommended(1000) + persister = InMemorySenderPersister() + token = new_sender.persist(persister) + req_ctx: Sender = Sender.load(token, persister) + request: RequestV2PostContext = req_ctx.extract_v2(ohttp_relay) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + send_ctx: V2GetContext = request.context.process_response(response.content) + # POST Original PSBT + + # ********************** + # Inside the Receiver: + + # GET fallback psbt + request: RequestResponse = session.extract_req(ohttp_relay.as_string()) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + # POST payjoin + proposal = session.process_res(response.content, request.client_response) + payjoin_proposal = handle_directory_payjoin_proposal(self.receiver, proposal) + request: RequestResponse = payjoin_proposal.extract_req(ohttp_relay.as_string()) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + payjoin_proposal.process_res(response.content, request.client_response) + + # ********************** + # Inside the Sender: + # Sender checks, signs, finalizes, extracts, and broadcasts + # Replay post fallback to get the response + request: RequestOhttpContext = send_ctx.extract_req(ohttp_relay.as_string()) + response = await agent.post( + url=request.request.url.as_string(), + headers={"Content-Type": request.request.content_type}, + content=request.request.body + ) + checked_payjoin_proposal_psbt: Optional[str] = send_ctx.process_response(response.content, request.ohttp_ctx) + self.assertIsNotNone(checked_payjoin_proposal_psbt) + payjoin_psbt = json.loads(self.sender.call("walletprocesspsbt", [checked_payjoin_proposal_psbt]))["psbt"] + final_psbt = json.loads(self.sender.call("finalizepsbt", [payjoin_psbt, json.dumps(False)]))["psbt"] + payjoin_tx = bitcoinffi.Psbt.deserialize_base64(final_psbt).extract_tx() + self.sender.call("sendrawtransaction", [json.dumps(payjoin_tx.serialize().hex())]) + + # Check resulting transaction and balances + network_fees = bitcoinffi.Psbt.deserialize_base64(final_psbt).fee().to_btc() + # Sender sent the entire value of their utxo to receiver (minus fees) + self.assertEqual(len(payjoin_tx.input()), 2); + self.assertEqual(len(payjoin_tx.output()), 1); + self.assertEqual(float(json.loads(self.receiver.call("getbalances", []))["mine"]["untrusted_pending"]), 100 - network_fees) + self.assertEqual(float(self.sender.call("getbalance", [])), 0) + return + except Exception as e: + print("Caught:", e) + raise + +def handle_directory_payjoin_proposal(receiver: Proxy, proposal: UncheckedProposal) -> PayjoinProposal: + maybe_inputs_owned = proposal.check_broadcast_suitability(None, MempoolAcceptanceCallback(receiver)) + maybe_inputs_seen = maybe_inputs_owned.check_inputs_not_owned(IsScriptOwnedCallback(receiver)) + outputs_unknown = maybe_inputs_seen.check_no_inputs_seen_before(CheckInputsNotSeenCallback(receiver)) + wants_outputs = outputs_unknown.identify_receiver_outputs(IsScriptOwnedCallback(receiver)) + wants_inputs = wants_outputs.commit_outputs() + provisional_proposal = wants_inputs.contribute_inputs(get_inputs(receiver)).commit_inputs() + return provisional_proposal.finalize_proposal(ProcessPsbtCallback(receiver), 1, 10) + +def build_sweep_psbt(sender: Proxy, pj_uri: PjUri) -> bitcoinffi.Psbt: + outputs = {} + outputs[pj_uri.address()] = 50 + psbt = json.loads(sender.call( + "walletcreatefundedpsbt", + [json.dumps([]), + json.dumps(outputs), + json.dumps(0), + json.dumps({"lockUnspents": True, "fee_rate": 10, "subtract_fee_from_outputs": [0]}) + ]))["psbt"] + return json.loads(sender.call("walletprocesspsbt", [psbt, json.dumps(True), json.dumps("ALL"), json.dumps(False)]))["psbt"] + +def get_inputs(rpc_connection: Proxy) -> list[InputPair]: + utxos = json.loads(rpc_connection.call("listunspent", [])) + inputs = [] + for utxo in utxos[:1]: + txin = bitcoinffi.TxIn( + previous_output=bitcoinffi.OutPoint(txid=utxo["txid"], vout=utxo["vout"]), + script_sig=bitcoinffi.Script(bytes()), + sequence=0, + witness=[] + ) + raw_tx = json.loads(rpc_connection.call("gettransaction", [json.dumps(utxo["txid"]), json.dumps(True), json.dumps(True)])) + prev_out = raw_tx["decoded"]["vout"][utxo["vout"]] + prev_spk = bitcoinffi.Script(bytes.fromhex(prev_out["scriptPubKey"]["hex"])) + prev_amount = bitcoinffi.Amount.from_btc(prev_out["value"]) + tx_out = bitcoinffi.TxOut(value=prev_amount, script_pubkey=prev_spk) + psbt_in = PsbtInput(witness_utxo=tx_out, redeem_script=None, witness_script=None) + inputs.append(InputPair(txin=txin, psbtin=psbt_in)) + + return inputs + +class MempoolAcceptanceCallback(CanBroadcast): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, tx): + try: + res = json.loads(self.connection.call("testmempoolaccept", [json.dumps([bytes(tx).hex()])]))[0][ + "allowed" + ] + return res + except Exception as e: + print(f"An error occurred: {e}") + return None + +class IsScriptOwnedCallback(IsScriptOwned): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, script): + try: + address = bitcoinffi.Address.from_script(bitcoinffi.Script(script), bitcoinffi.Network.REGTEST) + return json.loads(self.connection.call("getaddressinfo", [str(address)]))["ismine"] + except Exception as e: + print(f"An error occurred: {e}") + return None + +class CheckInputsNotSeenCallback(IsOutputKnown): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, _outpoint): + return False + +class ProcessPsbtCallback(ProcessPsbt): + def __init__(self, connection: Proxy): + self.connection = connection + + def callback(self, psbt: str): + res = json.loads(self.connection.call("walletprocesspsbt", [psbt])) + return res['psbt'] + +if __name__ == "__main__": + unittest.main() diff --git a/payjoin-ffi/python/test/test_payjoin_unit_test.py b/payjoin-ffi/python/test/test_payjoin_unit_test.py new file mode 100644 index 000000000..6d996d5f7 --- /dev/null +++ b/payjoin-ffi/python/test/test_payjoin_unit_test.py @@ -0,0 +1,115 @@ +import unittest +import payjoin as payjoin +import payjoin.bitcoin + +class TestURIs(unittest.TestCase): + def test_todo_url_encoded(self): + uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao" + self.assertTrue(payjoin.Url.parse(uri), "pj url should be url encoded") + + def test_valid_url(self): + uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao" + self.assertTrue(payjoin.Url.parse(uri), "pj is not a valid url") + + def test_missing_amount(self): + uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj" + self.assertTrue(payjoin.Url.parse(uri), "missing amount should be ok") + + def test_valid_uris(self): + https = str(payjoin.example_url()) + onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion" + + base58 = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX" + bech32_upper = "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4" + bech32_lower = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4" + + for address in [base58, bech32_upper, bech32_lower]: + for pj in [https, onion]: + uri = f"{address}?amount=1&pj={pj}" + try: + payjoin.Url.parse(uri) + except Exception as e: + self.fail(f"Failed to create a valid Uri for {uri}. Error: {e}") + + +class ScriptOwnershipCallback(payjoin.IsScriptOwned): + def __init__(self, value): + self.value = value + + def callback(self, script): + return self.value + + +class OutputOwnershipCallback(payjoin.IsOutputKnown): + def __init__(self, value): + self.value = value + + def callback(self, outpoint: payjoin.bitcoin.OutPoint): + return False + +class InMemoryReceiverPersister(payjoin.payjoin_ffi.ReceiverPersister): + def __init__(self): + self.receivers = {} + + def save(self, receiver: payjoin.Receiver) -> payjoin.ReceiverToken: + self.receivers[str(receiver.key())] = receiver.to_json() + + return receiver.key() + + def load(self, token: payjoin.ReceiverToken) -> payjoin.Receiver: + token = str(token) + if token not in self.receivers.keys(): + raise ValueError(f"Token not found: {token}") + return payjoin.Receiver.from_json(self.receivers[token]) + + +class TestRecieverPersistence(unittest.TestCase): + def test_receiver_persistence(self): + persister = InMemoryReceiverPersister() + address = payjoin.bitcoin.Address("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", payjoin.bitcoin.Network.SIGNET) + new_receiver = payjoin.NewReceiver( + address, + "https://example.com", + payjoin.OhttpKeys.from_string("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), + None + ) + token = new_receiver.persist(persister) + payjoin.Receiver.load(token, persister) + +class InMemorySenderPersister(payjoin.payjoin_ffi.SenderPersister): + def __init__(self): + self.senders = {} + + def save(self, sender: payjoin.Sender) -> payjoin.SenderToken: + self.senders[str(sender.key())] = sender.to_json() + return sender.key() + + def load(self, token: payjoin.SenderToken) -> payjoin.Sender: + token = str(token) + if token not in self.senders.keys(): + raise ValueError(f"Token not found: {token}") + return payjoin.Sender.from_json(self.senders[token]) + +class TestSenderPersistence(unittest.TestCase): + def test_sender_persistence(self): + # Create a receiver to just get the pj uri + persister = InMemoryReceiverPersister() + address = payjoin.bitcoin.Address("2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", payjoin.bitcoin.Network.TESTNET) + new_receiver = payjoin.NewReceiver( + address, + "https://example.com", + payjoin.OhttpKeys.from_string("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"), + None + ) + token = new_receiver.persist(persister) + reciever = payjoin.Receiver.load(token, persister) + uri = reciever.pj_uri() + + persister = InMemorySenderPersister() + psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=" + new_sender = payjoin.SenderBuilder(psbt, uri).build_recommended(1000) + token = new_sender.persist(persister) + payjoin.Sender.load(token, persister) + +if __name__ == "__main__": + unittest.main() diff --git a/payjoin-ffi/rust-toolchain.toml b/payjoin-ffi/rust-toolchain.toml new file mode 100644 index 000000000..2ee1b1796 --- /dev/null +++ b/payjoin-ffi/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2024-07-24" +components = ["rustfmt"] diff --git a/payjoin-ffi/rustfmt.toml b/payjoin-ffi/rustfmt.toml new file mode 100644 index 000000000..fe9dc15c6 --- /dev/null +++ b/payjoin-ffi/rustfmt.toml @@ -0,0 +1,77 @@ +## This is copied from https://github.com/rust-bitcoin/rust-bitcoin/blob/master/rustfmt.toml + +hard_tabs = false +tab_spaces = 4 +newline_style = "Auto" +indent_style = "Block" + +max_width = 100 # This is number of characters. +# `use_small_heuristics` is ignored if the granular width config values are explicitly set. +use_small_heuristics = "Max" # "Max" == All granular width settings same as `max_width`. +# # Granular width configuration settings. These are percentages of `max_width`. +# fn_call_width = 60 +# attr_fn_like_width = 70 +# struct_lit_width = 18 +# struct_variant_width = 35 +# array_width = 60 +# chain_width = 60 +# single_line_if_else_max_width = 50 + +wrap_comments = false +format_code_in_doc_comments = true +comment_width = 100 # Default 80 +normalize_comments = false +normalize_doc_attributes = false +format_strings = false +format_macro_matchers = false +format_macro_bodies = true +hex_literal_case = "Preserve" +empty_item_single_line = true +struct_lit_single_line = true +fn_single_line = false # Default false +where_single_line = false +imports_indent = "Block" +imports_layout = "Mixed" +imports_granularity = "Module" # Default "Preserve" +group_imports = "StdExternalCrate" # Default "Preserve" +reorder_imports = true +reorder_modules = true +reorder_impl_items = false +type_punctuation_density = "Wide" +space_before_colon = false +space_after_colon = true +spaces_around_ranges = false +binop_separator = "Front" +remove_nested_parens = true +combine_control_expr = true +overflow_delimited_expr = false +struct_field_align_threshold = 0 +enum_discrim_align_threshold = 0 +match_arm_leading_pipes = "Never" +force_multiline_blocks = true +fn_params_layout = "Tall" +brace_style = "SameLineWhere" +control_brace_style = "AlwaysSameLine" +trailing_semicolon = true +trailing_comma = "Vertical" +match_block_trailing_comma = false +blank_lines_upper_bound = 1 +blank_lines_lower_bound = 0 +edition = "2018" +version = "One" +inline_attribute_width = 0 +format_generated_files = true +merge_derives = true +use_try_shorthand = false +use_field_init_shorthand = false +force_explicit_abi = true +condense_wildcard_suffixes = false +color = "Auto" +unstable_features = false +disable_all_formatting = false +skip_children = false +show_parse_errors = true +error_on_line_overflow = false +error_on_unformatted = false +emit_mode = "Files" +make_backup = false diff --git a/payjoin-ffi/src/bitcoin_ffi.rs b/payjoin-ffi/src/bitcoin_ffi.rs new file mode 100644 index 000000000..07ec9d715 --- /dev/null +++ b/payjoin-ffi/src/bitcoin_ffi.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +#[cfg(not(feature = "uniffi"))] +pub use bitcoin_ffi::*; +use payjoin::bitcoin; + +#[cfg(feature = "uniffi")] +mod uni { + pub use bitcoin_ffi::*; +} + +#[cfg(feature = "uniffi")] +pub use uni::*; + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PsbtInput { + pub witness_utxo: Option, + pub redeem_script: Option>, + pub witness_script: Option>, +} + +impl PsbtInput { + pub fn new( + witness_utxo: Option, + redeem_script: Option>, + witness_script: Option>, + ) -> Self { + Self { witness_utxo, redeem_script, witness_script } + } +} + +impl From for PsbtInput { + fn from(psbt_input: bitcoin::psbt::Input) -> Self { + Self { + witness_utxo: psbt_input.witness_utxo.map(|s| s.into()), + redeem_script: psbt_input.redeem_script.clone().map(|s| Arc::new(s.into())), + witness_script: psbt_input.witness_script.clone().map(|s| Arc::new(s.into())), + } + } +} + +impl From for bitcoin::psbt::Input { + fn from(psbt_input: PsbtInput) -> Self { + Self { + witness_utxo: psbt_input.witness_utxo.map(|s| s.into()), + redeem_script: psbt_input + .redeem_script + .map(|s| Arc::try_unwrap(s).unwrap_or_else(|arc| (*arc).clone()).into()), + witness_script: psbt_input + .witness_script + .map(|s| Arc::try_unwrap(s).unwrap_or_else(|arc| (*arc).clone()).into()), + ..Default::default() + } + } +} diff --git a/payjoin-ffi/src/error.rs b/payjoin-ffi/src/error.rs new file mode 100644 index 000000000..9c11b4830 --- /dev/null +++ b/payjoin-ffi/src/error.rs @@ -0,0 +1,18 @@ +#[derive(Debug, thiserror::Error)] +#[error("Error de/serializing JSON object: {0}")] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct SerdeJsonError(#[from] serde_json::Error); + +#[derive(Debug, thiserror::Error)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] +pub enum ForeignError { + #[error("Internal error: {0}")] + InternalError(String), +} + +#[cfg(feature = "uniffi")] +impl From for ForeignError { + fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::InternalError("Unexpected Uniffi callback error".to_string()) + } +} diff --git a/payjoin-ffi/src/io.rs b/payjoin-ffi/src/io.rs new file mode 100644 index 000000000..f36a4ebc9 --- /dev/null +++ b/payjoin-ffi/src/io.rs @@ -0,0 +1,57 @@ +pub use error::IoError; + +use crate::ohttp::OhttpKeys; + +pub mod error { + #[derive(Debug, PartialEq, Eq, thiserror::Error)] + #[error("IO error: {message}")] + #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] + pub struct IoError { + message: String, + } + impl From for IoError { + fn from(value: payjoin::io::Error) -> Self { + IoError { message: format!("{value:?}") } + } + } +} + +/// Fetch the ohttp keys from the specified payjoin directory via proxy. +/// +/// * `ohttp_relay`: The http CONNNECT method proxy to request the ohttp keys from a payjoin +/// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to +/// the payjoin directory. +/// +/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This +/// directory stores and forwards payjoin client payloads. +pub async fn fetch_ohttp_keys( + ohttp_relay: &str, + payjoin_directory: &str, +) -> Result { + payjoin::io::fetch_ohttp_keys(ohttp_relay, payjoin_directory) + .await + .map(|e| e.into()) + .map_err(|e| e.into()) +} + +/// Fetch the ohttp keys from the specified payjoin directory via proxy. +/// +/// * `ohttp_relay`: The http CONNNECT method proxy to request the ohttp keys from a payjoin +/// directory. Proxying requests for ohttp keys ensures a client IP address is never revealed to +/// the payjoin directory. +/// +/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This +/// directory stores and forwards payjoin client payloads. +/// +/// * `cert_der`: The DER-encoded certificate to use for local HTTPS connections. +#[cfg(feature = "_danger-local-https")] +pub async fn fetch_ohttp_keys_with_cert( + ohttp_relay: &str, + payjoin_directory: &str, + cert_der: Vec, +) -> Result { + payjoin::io::fetch_ohttp_keys_with_cert(ohttp_relay, payjoin_directory, cert_der) + .await + .map(|e| e.into()) + .map_err(|e| e.into()) +} diff --git a/payjoin-ffi/src/lib.rs b/payjoin-ffi/src/lib.rs new file mode 100644 index 000000000..c6c037217 --- /dev/null +++ b/payjoin-ffi/src/lib.rs @@ -0,0 +1,29 @@ +#![crate_name = "payjoin_ffi"] + +pub mod bitcoin_ffi; +pub mod error; +pub mod io; +pub mod ohttp; +pub mod output_substitution; +pub mod receive; +pub mod request; +pub mod send; +#[cfg(feature = "_test-utils")] +pub mod test_utils; +pub mod uri; + +pub use payjoin::persist::NoopPersister; + +pub use crate::bitcoin_ffi::*; +pub use crate::ohttp::*; +pub use crate::output_substitution::*; +#[cfg(feature = "uniffi")] +pub use crate::receive::uni::*; +pub use crate::request::Request; +#[cfg(feature = "uniffi")] +pub use crate::send::uni::*; +#[cfg(feature = "_test-utils")] +pub use crate::test_utils::*; +pub use crate::uri::{PjUri, Uri, Url}; +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); diff --git a/payjoin-ffi/src/ohttp.rs b/payjoin-ffi/src/ohttp.rs new file mode 100644 index 000000000..1a8a95998 --- /dev/null +++ b/payjoin-ffi/src/ohttp.rs @@ -0,0 +1,70 @@ +pub use error::OhttpError; + +pub mod error { + #[derive(Debug, PartialEq, Eq, thiserror::Error)] + #[error("OHTTP error: {message}")] + #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] + pub struct OhttpError { + message: String, + } + impl From for OhttpError { + fn from(value: ohttp::Error) -> Self { + OhttpError { message: format!("{value:?}") } + } + } + impl From for OhttpError { + fn from(value: String) -> Self { + OhttpError { message: value } + } + } +} + +impl From for OhttpKeys { + fn from(value: payjoin::OhttpKeys) -> Self { + Self(value) + } +} +impl From for payjoin::OhttpKeys { + fn from(value: OhttpKeys) -> Self { + value.0 + } +} +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +#[derive(Debug, Clone)] +pub struct OhttpKeys(payjoin::OhttpKeys); + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl OhttpKeys { + /// Decode an OHTTP KeyConfig + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn decode(bytes: Vec) -> Result { + payjoin::OhttpKeys::decode(bytes.as_slice()).map(Into::into).map_err(Into::into) + } + + /// Create an OHTTP KeyConfig from a string + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn from_string(s: String) -> Result { + let res = payjoin::OhttpKeys::from_str(s.as_str()) + .map_err(|e| OhttpError::from(e.to_string()))?; + Ok(Self(res)) + } +} + +use std::str::FromStr; +use std::sync::Mutex; + +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct ClientResponse(Mutex>); + +impl From<&ClientResponse> for ohttp::ClientResponse { + fn from(value: &ClientResponse) -> Self { + let mut data_guard = value.0.lock().unwrap(); + Option::take(&mut *data_guard).expect("ClientResponse moved out of memory") + } +} + +impl From for ClientResponse { + fn from(value: ohttp::ClientResponse) -> Self { + Self(Mutex::new(Some(value))) + } +} diff --git a/payjoin-ffi/src/output_substitution.rs b/payjoin-ffi/src/output_substitution.rs new file mode 100644 index 000000000..d25709e29 --- /dev/null +++ b/payjoin-ffi/src/output_substitution.rs @@ -0,0 +1,8 @@ +pub type OutputSubstitution = payjoin::OutputSubstitution; + +#[cfg(feature = "uniffi")] +#[cfg_attr(feature = "uniffi", uniffi::remote(Enum))] +enum OutputSubstitution { + Enabled, + Disabled, +} diff --git a/payjoin-ffi/src/payjoin_ffi.udl b/payjoin-ffi/src/payjoin_ffi.udl new file mode 100644 index 000000000..22a40a151 --- /dev/null +++ b/payjoin-ffi/src/payjoin_ffi.udl @@ -0,0 +1,3 @@ +namespace payjoin_ffi { + +}; diff --git a/payjoin-ffi/src/receive/error.rs b/payjoin-ffi/src/receive/error.rs new file mode 100644 index 000000000..93796388c --- /dev/null +++ b/payjoin-ffi/src/receive/error.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use payjoin::receive; + +/// The top-level error type for the payjoin receiver +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] +pub enum Error { + /// Errors that can be replied to the sender + #[error("Replyable error: {0}")] + ReplyToSender(Arc), + /// V2-specific errors that are infeasable to reply to the sender + #[error("Unreplyable error: {0}")] + V2(Arc), + /// Catch-all for unhandled error variants + #[error("An unexpected error occurred")] + Unexpected, +} + +impl From for Error { + fn from(value: receive::Error) -> Self { + match value { + receive::Error::ReplyToSender(e) => Error::ReplyToSender(Arc::new(ReplyableError(e))), + receive::Error::V2(e) => Error::V2(Arc::new(SessionError(e))), + _ => Error::Unexpected, + } + } +} + +/// The replyable error type for the payjoin receiver, representing failures need to be +/// returned to the sender. +/// +/// The error handling is designed to: +/// 1. Provide structured error responses for protocol-level failures +/// 2. Hide implementation details of external errors for security +/// 3. Support proper error propagation through the receiver stack +/// 4. Provide errors according to BIP-78 JSON error specifications for return +/// after conversion into [`JsonReply`] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct ReplyableError(#[from] receive::ReplyableError); + +/// The standard format for errors that can be replied as JSON. +/// +/// The JSON output includes the following fields: +/// ```json +/// { +/// "errorCode": "specific-error-code", +/// "message": "Human readable error message" +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct JsonReply(receive::JsonReply); + +impl From for receive::JsonReply { + fn from(value: JsonReply) -> Self { + value.0 + } +} + +impl From for JsonReply { + fn from(value: ReplyableError) -> Self { + Self(value.0.into()) + } +} + +/// Error arising due to the specific receiver implementation +/// +/// e.g. database errors, network failures, wallet errors +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct ImplementationError(#[from] receive::ImplementationError); + +impl From for ImplementationError { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +/// Error that may occur during a v2 session typestate change +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct SessionError(#[from] receive::v2::SessionError); + +/// Error that may occur when output substitution fails. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct OutputSubstitutionError(#[from] receive::OutputSubstitutionError); + +/// Error that may occur when coin selection fails. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct SelectionError(#[from] receive::SelectionError); + +/// Error that may occur when input contribution fails. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct InputContributionError(#[from] receive::InputContributionError); + +/// Error validating a PSBT Input +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct PsbtInputError(#[from] receive::PsbtInputError); diff --git a/payjoin-ffi/src/receive/mod.rs b/payjoin-ffi/src/receive/mod.rs new file mode 100644 index 000000000..2308ba685 --- /dev/null +++ b/payjoin-ffi/src/receive/mod.rs @@ -0,0 +1,540 @@ +use std::str::FromStr; +use std::time::Duration; + +pub use error::{ + Error, ImplementationError, InputContributionError, JsonReply, OutputSubstitutionError, + PsbtInputError, ReplyableError, SelectionError, SessionError, +}; +use payjoin::bitcoin::psbt::Psbt; +use payjoin::bitcoin::FeeRate; +use payjoin::persist::{Persister, Value}; +use payjoin::receive::v2::ReceiverToken; + +use crate::bitcoin_ffi::{Address, OutPoint, Script, TxOut}; +pub use crate::error::SerdeJsonError; +use crate::ohttp::OhttpKeys; +use crate::uri::error::IntoUrlError; +use crate::{ClientResponse, OutputSubstitution, Request}; + +pub mod error; +#[cfg(feature = "uniffi")] +pub mod uni; + +#[derive(Debug)] +pub struct NewReceiver(payjoin::receive::v2::NewReceiver); + +impl From for payjoin::receive::v2::NewReceiver { + fn from(value: NewReceiver) -> Self { + value.0 + } +} + +impl From for NewReceiver { + fn from(value: payjoin::receive::v2::NewReceiver) -> Self { + Self(value) + } +} + +impl NewReceiver { + /// Creates a new [`NewReceiver`] with the provided parameters. + /// + /// # Parameters + /// - `address`: The Bitcoin address for the payjoin session. + /// - `directory`: The URL of the store-and-forward payjoin directory. + /// - `ohttp_keys`: The OHTTP keys used for encrypting and decrypting HTTP requests and responses. + /// - `expire_after`: The duration after which the session expires. + /// + /// # Returns + /// A new instance of [`NewReceiver`]. + /// + /// # References + /// - [BIP 77: Payjoin Version 2: Serverless Payjoin](https://github.com/bitcoin/bips/pull/1483) + pub fn new( + address: Address, + directory: String, + ohttp_keys: OhttpKeys, + expire_after: Option, + ) -> Result { + payjoin::receive::v2::NewReceiver::new( + address.into(), + directory, + ohttp_keys.into(), + expire_after.map(Duration::from_secs), + ) + .map(Into::into) + .map_err(Into::into) + } + + /// Saves the new [`Receiver`] using the provided persister and returns the storage token. + pub fn persist>( + &self, + persister: &mut P, + ) -> Result { + self.0.persist(persister).map_err(ImplementationError::from) + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Receiver(payjoin::receive::v2::Receiver); + +impl From for payjoin::receive::v2::Receiver { + fn from(value: Receiver) -> Self { + value.0 + } +} + +impl From for Receiver { + fn from(value: payjoin::receive::v2::Receiver) -> Self { + Self(value) + } +} + +impl Receiver { + /// Loads a [`Receiver`] from the provided persister using the storage token. + pub fn load>( + token: P::Token, + persister: &P, + ) -> Result { + Ok(Receiver::from(persister.load(token).unwrap())) + } + + pub fn extract_req(&self, ohttp_relay: String) -> Result<(Request, ClientResponse), Error> { + self.0 + .clone() + .extract_req(ohttp_relay) + .map(|(req, ctx)| (req.into(), ctx.into())) + .map_err(Into::into) + } + + ///The response can either be an UncheckedProposal or an ACCEPTED message indicating no UncheckedProposal is available yet. + pub fn process_res( + &self, + body: &[u8], + ctx: &ClientResponse, + ) -> Result, Error> { + >::into(self.clone()) + .process_res(body, ctx.into()) + .map(|e| e.map(|o| o.into())) + .map_err(Into::into) + } + + /// Build a V2 Payjoin URI from the receiver's context + pub fn pj_uri(&self) -> crate::PjUri { + >::into(self.clone()).pj_uri().into() + } + + ///The per-session public key to use as an identifier + pub fn id(&self) -> String { + >::into(self.clone()).id().to_string() + } + + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0).map_err(Into::into) + } + + pub fn from_json(json: &str) -> Result { + serde_json::from_str::(json) + .map_err(Into::into) + .map(Into::into) + } + + pub fn key(&self) -> ReceiverToken { + self.0.key() + } +} + +#[derive(Clone)] +pub struct UncheckedProposal(payjoin::receive::v2::UncheckedProposal); + +impl From for UncheckedProposal { + fn from(value: payjoin::receive::v2::UncheckedProposal) -> Self { + Self(value) + } +} + +impl From for payjoin::receive::v2::UncheckedProposal { + fn from(value: UncheckedProposal) -> Self { + value.0 + } +} + +impl UncheckedProposal { + ///The Sender’s Original PSBT + pub fn extract_tx_to_schedule_broadcast(&self) -> Vec { + payjoin::bitcoin::consensus::encode::serialize( + &self.0.clone().extract_tx_to_schedule_broadcast(), + ) + } + + pub fn check_broadcast_suitability( + &self, + min_fee_rate: Option, + can_broadcast: impl Fn(&Vec) -> Result, + ) -> Result { + self.0 + .clone() + .check_broadcast_suitability( + min_fee_rate.map(FeeRate::from_sat_per_kwu), + |transaction| { + Ok(can_broadcast(&payjoin::bitcoin::consensus::encode::serialize(transaction))?) + }, + ) + .map(Into::into) + .map_err(Into::into) + } + + /// Call this method if the only way to initiate a Payjoin with this receiver + /// requires manual intervention, as in most consumer wallets. + /// + /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. + /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. + pub fn assume_interactive_receiver(&self) -> MaybeInputsOwned { + self.0.clone().assume_interactive_receiver().into() + } + + /// Extract an OHTTP Encapsulated HTTP POST request to return + /// a Receiver Error Response + pub fn extract_err_req( + &self, + err: &JsonReply, + ohttp_relay: String, + ) -> Result<(Request, ClientResponse), SessionError> { + self.0 + .clone() + .extract_err_req(&err.clone().into(), ohttp_relay) + .map(|(req, ctx)| (req.into(), ctx.into())) + .map_err(Into::into) + } + + /// Process an OHTTP Encapsulated HTTP POST Error response + /// to ensure it has been posted properly + pub fn process_err_res( + &self, + body: &[u8], + context: &ClientResponse, + ) -> Result<(), SessionError> { + self.0.clone().process_err_res(body, context.into()).map_err(Into::into) + } +} +#[derive(Clone)] +pub struct MaybeInputsOwned(payjoin::receive::v2::MaybeInputsOwned); + +impl From for MaybeInputsOwned { + fn from(value: payjoin::receive::v2::MaybeInputsOwned) -> Self { + Self(value) + } +} + +impl MaybeInputsOwned { + pub fn check_inputs_not_owned( + &self, + is_owned: impl Fn(&Vec) -> Result, + ) -> Result { + self.0 + .clone() + .check_inputs_not_owned(|input| Ok(is_owned(&input.to_bytes())?)) + .map_err(Into::into) + .map(Into::into) + } +} + +#[derive(Clone)] +pub struct MaybeInputsSeen(payjoin::receive::v2::MaybeInputsSeen); + +impl From for MaybeInputsSeen { + fn from(value: payjoin::receive::v2::MaybeInputsSeen) -> Self { + Self(value) + } +} + +impl MaybeInputsSeen { + pub fn check_no_inputs_seen_before( + &self, + is_known: impl Fn(&OutPoint) -> Result, + ) -> Result { + self.0 + .clone() + .check_no_inputs_seen_before(|outpoint| Ok(is_known(&(*outpoint).into())?)) + .map_err(Into::into) + .map(Into::into) + } +} + +/// The receiver has not yet identified which outputs belong to the receiver. +/// +/// Only accept PSBTs that send us money. +/// Identify those outputs with `identify_receiver_outputs()` to proceed +#[derive(Clone)] +pub struct OutputsUnknown(payjoin::receive::v2::OutputsUnknown); + +impl From for OutputsUnknown { + fn from(value: payjoin::receive::v2::OutputsUnknown) -> Self { + Self(value) + } +} + +impl OutputsUnknown { + /// Find which outputs belong to the receiver + pub fn identify_receiver_outputs( + &self, + is_receiver_output: impl Fn(&Vec) -> Result, + ) -> Result { + self.0 + .clone() + .identify_receiver_outputs(|input| Ok(is_receiver_output(&input.to_bytes())?)) + .map_err(Into::into) + .map(Into::into) + } +} + +pub struct WantsOutputs(payjoin::receive::v2::WantsOutputs); + +impl From for WantsOutputs { + fn from(value: payjoin::receive::v2::WantsOutputs) -> Self { + Self(value) + } +} + +impl WantsOutputs { + pub fn output_substitution(&self) -> OutputSubstitution { + self.0.output_substitution() + } + + pub fn replace_receiver_outputs( + &self, + replacement_outputs: Vec, + drain_script: &Script, + ) -> Result { + let replacement_outputs: Vec = + replacement_outputs.iter().map(|o| o.clone().into()).collect(); + self.0 + .clone() + .replace_receiver_outputs(replacement_outputs, &drain_script.0) + .map(Into::into) + .map_err(Into::into) + } + + pub fn substitute_receiver_script( + &self, + output_script: &Script, + ) -> Result { + self.0 + .clone() + .substitute_receiver_script(&output_script.0) + .map(Into::into) + .map_err(Into::into) + } + + pub fn commit_outputs(&self) -> WantsInputs { + self.0.clone().commit_outputs().into() + } +} + +pub struct WantsInputs(payjoin::receive::v2::WantsInputs); + +impl From for WantsInputs { + fn from(value: payjoin::receive::v2::WantsInputs) -> Self { + Self(value) + } +} +impl WantsInputs { + /// Select receiver input such that the payjoin avoids surveillance. + /// Return the input chosen that has been applied to the Proposal. + /// + /// Proper coin selection allows payjoin to resemble ordinary transactions. + /// To ensure the resemblance, a number of heuristics must be avoided. + /// + /// UIH "Unnecessary input heuristic" is one class of them to avoid. We define + /// UIH1 and UIH2 according to the BlockSci practice + /// BlockSci UIH1 and UIH2: + // if min(out) < min(in) then UIH1 else UIH2 + // https://eprint.iacr.org/2022/589.pdf + pub fn try_preserving_privacy( + &self, + candidate_inputs: Vec, + ) -> Result { + match self.0.clone().try_preserving_privacy(candidate_inputs.into_iter().map(Into::into)) { + Ok(t) => Ok(t.into()), + Err(e) => Err(e.into()), + } + } + + pub fn contribute_inputs( + &self, + replacement_inputs: Vec, + ) -> Result { + self.0 + .clone() + .contribute_inputs(replacement_inputs.into_iter().map(Into::into)) + .map(Into::into) + .map_err(Into::into) + } + + pub fn commit_inputs(&self) -> ProvisionalProposal { + self.0.clone().commit_inputs().into() + } +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct InputPair(payjoin::receive::InputPair); + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl InputPair { + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn new( + txin: bitcoin_ffi::TxIn, + psbtin: crate::bitcoin_ffi::PsbtInput, + ) -> Result { + Ok(Self(payjoin::receive::InputPair::new(txin.into(), psbtin.into())?)) + } +} + +impl From for payjoin::receive::InputPair { + fn from(value: InputPair) -> Self { + value.0 + } +} + +impl From for InputPair { + fn from(value: payjoin::receive::InputPair) -> Self { + Self(value) + } +} + +pub struct ProvisionalProposal(pub payjoin::receive::v2::ProvisionalProposal); + +impl From for ProvisionalProposal { + fn from(value: payjoin::receive::v2::ProvisionalProposal) -> Self { + Self(value) + } +} + +impl ProvisionalProposal { + pub fn finalize_proposal( + &self, + process_psbt: impl Fn(String) -> Result, + min_feerate_sat_per_vb: Option, + max_effective_fee_rate_sat_per_vb: Option, + ) -> Result { + self.0 + .clone() + .finalize_proposal( + |pre_processed| { + let psbt = process_psbt(pre_processed.to_string())?; + Ok(Psbt::from_str(&psbt)?) + }, + min_feerate_sat_per_vb.and_then(FeeRate::from_sat_per_vb), + max_effective_fee_rate_sat_per_vb.and_then(FeeRate::from_sat_per_vb), + ) + .map(Into::into) + .map_err(Into::into) + } +} + +#[derive(Clone)] +pub struct PayjoinProposal(pub payjoin::receive::v2::PayjoinProposal); + +impl From for payjoin::receive::v2::PayjoinProposal { + fn from(value: PayjoinProposal) -> Self { + value.0 + } +} + +impl From for PayjoinProposal { + fn from(value: payjoin::receive::v2::PayjoinProposal) -> Self { + Self(value) + } +} + +impl PayjoinProposal { + pub fn utxos_to_be_locked(&self) -> Vec { + let mut outpoints: Vec = Vec::new(); + for o in + >::into(self.clone()) + .utxos_to_be_locked() + { + outpoints.push((*o).into()); + } + outpoints + } + + pub fn psbt(&self) -> String { + >::into(self.clone()) + .psbt() + .clone() + .to_string() + } + + /// Extract an OHTTP Encapsulated HTTP POST request for the Proposal PSBT + pub fn extract_req(&self, ohttp_relay: String) -> Result<(Request, ClientResponse), Error> { + self.0 + .clone() + .extract_req(ohttp_relay) + .map_err(Into::into) + .map(|(req, ctx)| (req.into(), ctx.into())) + } + + ///Processes the response for the final POST message from the receiver client in the v2 Payjoin protocol. + /// + /// This function decapsulates the response using the provided OHTTP context. If the response status is successful, it indicates that the Payjoin proposal has been accepted. Otherwise, it returns an error with the status code. + /// + /// After this function is called, the receiver can either wait for the Payjoin transaction to be broadcast or choose to broadcast the original PSBT. + pub fn process_res(&self, body: &[u8], ohttp_context: &ClientResponse) -> Result<(), Error> { + >::into(self.clone()) + .process_res(body, ohttp_context.into()) + .map_err(|e| e.into()) + } +} + +// #[cfg(test)] +// #[cfg(not(feature = "uniffi"))] +// mod test { +// use std::sync::Arc; + +// use super::*; + +// fn get_proposal_from_test_vector() -> Result { +// // OriginalPSBT Test Vector from BIP +// // | InputScriptType | Orginal PSBT Fee rate | maxadditionalfeecontribution | additionalfeeoutputindex| +// // |-----------------|-----------------------|------------------------------|-------------------------| +// // | P2SH-P2WPKH | 2 sat/vbyte | 0.00000182 | 0 | +// let original_psbt = +// "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; +// let body = original_psbt.as_bytes(); + +// let headers = Headers::from_vec(body.to_vec()); +// UncheckedProposal::from_request( +// body.to_vec(), +// "?maxadditionalfeecontribution=182?additionalfeeoutputindex=0".to_string(), +// Arc::new(headers), +// ) +// } + +// #[test] +// fn can_get_proposal_from_request() { +// let proposal = get_proposal_from_test_vector(); +// assert!(proposal.is_ok(), "OriginalPSBT should be a valid request"); +// } + +// #[test] +// fn unchecked_proposal_unlocks_after_checks() { +// let proposal = get_proposal_from_test_vector().unwrap(); +// let _payjoin = proposal +// .assume_interactive_receiver() +// .clone() +// .check_inputs_not_owned(|_| Ok(true)) +// .expect("No inputs should be owned") +// .check_no_inputs_seen_before(|_| Ok(false)) +// .expect("No inputs should be seen before") +// .identify_receiver_outputs(|script| { +// let network = payjoin::bitcoin::Network::Bitcoin; +// let script = payjoin::bitcoin::ScriptBuf::from_bytes(script.to_vec()); +// Ok(payjoin::bitcoin::Address::from_script(&script, network).unwrap() +// == payjoin::bitcoin::Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") +// .map(|x| x.require_network(network).unwrap()) +// .unwrap()) +// }) +// .expect("Receiver output should be identified"); +// } +// } diff --git a/payjoin-ffi/src/receive/uni.rs b/payjoin-ffi/src/receive/uni.rs new file mode 100644 index 000000000..486b6ebae --- /dev/null +++ b/payjoin-ffi/src/receive/uni.rs @@ -0,0 +1,551 @@ +use std::sync::Arc; + +use super::InputPair; +use crate::bitcoin_ffi::{Address, OutPoint, Script, TxOut}; +use crate::error::ForeignError; +pub use crate::receive::{ + Error, ImplementationError, InputContributionError, JsonReply, OutputSubstitutionError, + ReplyableError, SelectionError, SerdeJsonError, SessionError, +}; +use crate::uri::error::IntoUrlError; +use crate::{ClientResponse, OhttpKeys, OutputSubstitution, Request}; + +#[derive(Debug, uniffi::Object)] +pub struct NewReceiver(pub super::NewReceiver); + +impl From for super::NewReceiver { + fn from(value: NewReceiver) -> Self { + value.0 + } +} + +impl From for NewReceiver { + fn from(value: super::NewReceiver) -> Self { + Self(value) + } +} + +#[uniffi::export] +impl NewReceiver { + /// Creates a new [`NewReceiver`] with the provided parameters. + /// + /// # Parameters + /// - `address`: The Bitcoin address for the payjoin session. + /// - `directory`: The URL of the store-and-forward payjoin directory. + /// - `ohttp_keys`: The OHTTP keys used for encrypting and decrypting HTTP requests and responses. + /// - `expire_after`: The duration after which the session expires. + /// + /// # Returns + /// A new instance of [`NewReceiver`]. + /// + /// # References + /// - [BIP 77: Payjoin Version 2: Serverless Payjoin](https://github.com/bitcoin/bips/pull/1483) + #[uniffi::constructor] + pub fn new( + address: Arc
, + directory: String, + ohttp_keys: Arc, + expire_after: Option, + ) -> Result { + super::NewReceiver::new((*address).clone(), directory, (*ohttp_keys).clone(), expire_after) + .map(Into::into) + } + + /// Saves the new [`Receiver`] using the provided persister and returns the storage token. + pub fn persist( + &self, + persister: Arc, + ) -> Result { + let mut adapter = CallbackPersisterAdapter::new(persister); + self.0.persist(&mut adapter) + } +} + +#[derive(Clone, Debug, uniffi::Object)] +#[uniffi::export(Display)] +pub struct ReceiverToken(#[allow(dead_code)] Arc); + +impl std::fmt::Display for ReceiverToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for ReceiverToken { + fn from(value: payjoin::receive::v2::Receiver) -> Self { + ReceiverToken(Arc::new(value.into())) + } +} + +impl From for ReceiverToken { + fn from(value: payjoin::receive::v2::ReceiverToken) -> Self { + ReceiverToken(Arc::new(value)) + } +} + +impl From for payjoin::receive::v2::ReceiverToken { + fn from(value: ReceiverToken) -> Self { + (*value.0).clone() + } +} + +#[derive(Clone, Debug, uniffi::Object)] +pub struct Receiver(super::Receiver); + +impl From for super::Receiver { + fn from(value: Receiver) -> Self { + value.0 + } +} + +impl From for Receiver { + fn from(value: super::Receiver) -> Self { + Self(value) + } +} + +#[uniffi::export] +impl Receiver { + /// Loads a [`Receiver`] from the provided persister using the storage token. + #[uniffi::constructor] + pub fn load( + token: Arc, + persister: Arc, + ) -> Result { + Ok(super::Receiver::from( + (*persister.load(token).map_err(|e| ImplementationError::from(e.to_string()))?).clone(), + ) + .into()) + } + + /// The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory. + /// This identifies a session at the payjoin directory server. + pub fn pj_uri(&self) -> crate::PjUri { + self.0.pj_uri() + } + + pub fn extract_req(&self, ohttp_relay: String) -> Result { + self.0 + .extract_req(ohttp_relay) + .map(|(request, ctx)| RequestResponse { request, client_response: Arc::new(ctx) }) + } + + ///The response can either be an UncheckedProposal or an ACCEPTED message indicating no UncheckedProposal is available yet. + pub fn process_res( + &self, + body: &[u8], + context: Arc, + ) -> Result>, Error> { + >::into(self.clone()) + .process_res(body, context.as_ref()) + .map(|e| e.map(|x| Arc::new(x.into()))) + } + + ///The per-session public key to use as an identifier + pub fn id(&self) -> String { + self.0.id() + } + + pub fn to_json(&self) -> Result { + self.0.to_json() + } + + #[uniffi::constructor] + pub fn from_json(json: &str) -> Result { + super::Receiver::from_json(json).map(Into::into) + } + + pub fn key(&self) -> ReceiverToken { + self.0.key().into() + } +} + +#[derive(uniffi::Record)] +pub struct RequestResponse { + pub request: Request, + pub client_response: Arc, +} + +#[uniffi::export(with_foreign)] +pub trait CanBroadcast: Send + Sync { + fn callback(&self, tx: Vec) -> Result; +} + +/// The sender’s original PSBT and optional parameters +/// +/// This type is used to proces the request. It is returned by UncheckedProposal::from_request(). +/// +/// If you are implementing an interactive payment processor, you should get extract the original transaction with get_transaction_to_schedule_broadcast() and schedule, followed by checking that the transaction can be broadcast with check_can_broadcast. Otherwise it is safe to call assume_interactive_receive to proceed with validation. +#[derive(Clone, uniffi::Object)] +pub struct UncheckedProposal(super::UncheckedProposal); + +impl From for UncheckedProposal { + fn from(value: super::UncheckedProposal) -> Self { + Self(value) + } +} + +#[uniffi::export] +impl UncheckedProposal { + /// The Sender’s Original PSBT + pub fn extract_tx_to_schedule_broadcast(&self) -> Vec { + self.0.extract_tx_to_schedule_broadcast() + } + + /// Call after checking that the Original PSBT can be broadcast. + /// + /// Receiver MUST check that the Original PSBT from the sender can be broadcast, i.e. testmempoolaccept bitcoind rpc returns { “allowed”: true,.. } for get_transaction_to_check_broadcast() before calling this method. + /// + /// Do this check if you generate bitcoin uri to receive Payjoin on sender request without manual human approval, like a payment processor. Such so called “non-interactive” receivers are otherwise vulnerable to probing attacks. If a sender can make requests at will, they can learn which bitcoin the receiver owns at no cost. Broadcasting the Original PSBT after some time in the failure case makes incurs sender cost and prevents probing. + /// + /// Call this after checking downstream. + pub fn check_broadcast_suitability( + &self, + min_fee_rate: Option, + can_broadcast: Arc, + ) -> Result, ReplyableError> { + self.0 + .clone() + .check_broadcast_suitability(min_fee_rate, |transaction| { + can_broadcast + .callback(transaction.to_vec()) + .map_err(|e| ImplementationError::from(e.to_string())) + }) + .map(|e| Arc::new(e.into())) + } + + /// Call this method if the only way to initiate a Payjoin with this receiver + /// requires manual intervention, as in most consumer wallets. + /// + /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. + /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. + pub fn assume_interactive_receiver(&self) -> Arc { + Arc::new(self.0.assume_interactive_receiver().into()) + } + + /// Extract an OHTTP Encapsulated HTTP POST request to return + /// a Receiver Error Response + pub fn extract_err_req( + &self, + err: Arc, + ohttp_relay: String, + ) -> Result { + self.0 + .extract_err_req(&err, ohttp_relay) + .map(|(req, ctx)| RequestResponse { request: req, client_response: Arc::new(ctx) }) + } + + /// Process an OHTTP Encapsulated HTTP POST Error response + /// to ensure it has been posted properly + pub fn process_err_res( + &self, + body: &[u8], + context: Arc, + ) -> Result<(), SessionError> { + self.0.clone().process_err_res(body, &context) + } +} + +/// Type state to validate that the Original PSBT has no receiver-owned inputs. +/// Call check_no_receiver_owned_inputs() to proceed. +#[derive(Clone, uniffi::Object)] +pub struct MaybeInputsOwned(super::MaybeInputsOwned); + +impl From for MaybeInputsOwned { + fn from(value: super::MaybeInputsOwned) -> Self { + Self(value) + } +} + +#[uniffi::export(with_foreign)] +pub trait IsScriptOwned: Send + Sync { + fn callback(&self, script: Vec) -> Result; +} + +#[uniffi::export] +impl MaybeInputsOwned { + ///Check that the Original PSBT has no receiver-owned inputs. Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs. + /// An attacker could try to spend receiver's own inputs. This check prevents that. + pub fn check_inputs_not_owned( + &self, + is_owned: Arc, + ) -> Result, ReplyableError> { + self.0 + .check_inputs_not_owned(|input| { + is_owned + .callback(input.to_vec()) + .map_err(|e| ImplementationError::from(e.to_string())) + }) + .map(|t| Arc::new(t.into())) + } +} + +#[uniffi::export(with_foreign)] +pub trait IsOutputKnown: Send + Sync { + fn callback(&self, outpoint: OutPoint) -> Result; +} + +/// Typestate to validate that the Original PSBT has no inputs that have been seen before. +/// +/// Call check_no_inputs_seen to proceed. +#[derive(Clone, uniffi::Object)] +pub struct MaybeInputsSeen(super::MaybeInputsSeen); + +impl From for MaybeInputsSeen { + fn from(value: super::MaybeInputsSeen) -> Self { + Self(value) + } +} + +#[uniffi::export] +impl MaybeInputsSeen { + /// Make sure that the original transaction inputs have never been seen before. This prevents probing attacks. This prevents reentrant Payjoin, where a sender proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin. + pub fn check_no_inputs_seen_before( + &self, + is_known: Arc, + ) -> Result, ReplyableError> { + self.0 + .clone() + .check_no_inputs_seen_before(|outpoint| { + is_known + .callback(outpoint.clone()) + .map_err(|e| ImplementationError::from(e.to_string())) + }) + .map(|t| Arc::new(t.into())) + } +} + +/// The receiver has not yet identified which outputs belong to the receiver. +/// +/// Only accept PSBTs that send us money. Identify those outputs with identify_receiver_outputs() to proceed +#[derive(Clone, uniffi::Object)] +pub struct OutputsUnknown(super::OutputsUnknown); + +impl From for OutputsUnknown { + fn from(value: super::OutputsUnknown) -> Self { + Self(value) + } +} + +#[uniffi::export] +impl OutputsUnknown { + /// Find which outputs belong to the receiver + pub fn identify_receiver_outputs( + &self, + is_receiver_output: Arc, + ) -> Result, ReplyableError> { + self.0 + .clone() + .identify_receiver_outputs(|output_script| { + is_receiver_output + .callback(output_script.to_vec()) + .map_err(|e| ImplementationError::from(e.to_string())) + }) + .map(|t| Arc::new(t.into())) + } +} + +#[derive(uniffi::Object)] +pub struct WantsOutputs(super::WantsOutputs); + +impl From for WantsOutputs { + fn from(value: super::WantsOutputs) -> Self { + Self(value) + } +} +#[uniffi::export] +impl WantsOutputs { + pub fn output_substitution(&self) -> OutputSubstitution { + self.0.output_substitution() + } + + pub fn replace_receiver_outputs( + &self, + replacement_outputs: Vec, + drain_script: Arc