From 1eebed63e1f8673dba9a815e323299d76903c8ed Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 20 Aug 2026 14:49:21 +0200 Subject: [PATCH 1/4] Add cpp feature and bindgen dispatch arm Wire a C++ generator into the uniffi-bindgen dispatcher following the same pattern as the C# and Dart generators: an optional cpp feature pulling uniffi-bindgen-cpp, a dispatch arm selected by UNIFFI_BINDGEN_LANGUAGE, and a bindings.cpp stanza in uniffi.toml. Upstream uniffi-bindgen-cpp targets uniffi 0.29 and has no library entry point, so the dependency is pinned to a fork that rebases the in-progress uniffi 0.30 support (NordSecurity/uniffi-bindgen-cpp#59) onto uniffi_bindgen 0.31.2, exposes pub fn main(), adds a --skip-async mode (async is unsupported, their issue #51), and fixes several generator bugs surfaced by this crate's interface. Both lockfiles are regenerated to include the new dependency tree. --- Cargo-minimal.lock | 118 +++++++++++++++++++++++++++++++--- Cargo-recent.lock | 102 +++++++++++++++++++++++++++++ payjoin-ffi/Cargo.toml | 2 + payjoin-ffi/uniffi-bindgen.rs | 4 ++ payjoin-ffi/uniffi.toml | 3 + 5 files changed, 221 insertions(+), 8 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 4fe791a8e..3aa0e4929 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -61,7 +61,7 @@ checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ "getrandom 0.2.10", "once_cell", - "version_check", + "version_check 0.9.4", ] [[package]] @@ -72,7 +72,7 @@ checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01" dependencies = [ "cfg-if 1.0.0", "once_cell", - "version_check", + "version_check 0.9.4", "zerocopy 0.7.31", ] @@ -184,6 +184,19 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" +[[package]] +name = "askama" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47cbc3cf73fa8d9833727bbee4835ba5c421a0d65b72daf9a7b5d0e0f9cfb57e" +dependencies = [ + "askama_derive 0.12.0", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + [[package]] name = "askama" version = "0.13.0" @@ -210,6 +223,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "askama_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80b5ad1afe82872b7aa3e9de9b206ecb85584aa324f0f60fa4c903ce935936b" +dependencies = [ + "basic-toml", + "mime", + "mime_guess", + "nom", + "proc-macro2", + "quote", + "serde", + "syn 1.0.74", +] + [[package]] name = "askama_derive" version = "0.13.0" @@ -244,6 +273,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + [[package]] name = "askama_parser" version = "0.13.0" @@ -804,9 +839,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.0.8" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fd178c5af4d59e83498ef15cf3f154e1a6f9d091270cb86283c65ef44e9ef0" +checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" dependencies = [ "serde", ] @@ -1625,7 +1660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" dependencies = [ "typenum", - "version_check", + "version_check 0.9.4", ] [[package]] @@ -1883,6 +1918,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05842d0d43232b23ccb7060ecb0f0626922c21f30012e97b767b30afd4a5d4b9" +[[package]] +name = "humansize" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e12090e3b87a266157c30eef7ee8f430f4226feb8dd970cccea2cc67f52f70e" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.8.0" @@ -2153,6 +2197,12 @@ dependencies = [ "cc", ] +[[package]] +name = "libm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "292a948cd991e376cf75541fe5b97a1081d713c618b4f1b9500f8844e49eb565" + [[package]] name = "libredox" version = "0.1.3" @@ -2250,6 +2300,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d729cf45eaf996831fe7143af04190187ab3ee2a72ea96bd00958d1fae822a9d" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.0" @@ -2344,7 +2404,7 @@ checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" dependencies = [ "memchr", "minimal-lexical", - "version_check", + "version_check 0.9.4", ] [[package]] @@ -2639,6 +2699,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "uniffi", + "uniffi-bindgen-cpp", "uniffi-bindgen-cs", "uniffi-dart", "url", @@ -2901,7 +2962,7 @@ dependencies = [ "proc-macro2", "quote", "syn 1.0.74", - "version_check", + "version_check 0.9.4", ] [[package]] @@ -2914,7 +2975,7 @@ dependencies = [ "quote", "syn 1.0.74", "syn-mid", - "version_check", + "version_check 0.9.4", ] [[package]] @@ -4201,6 +4262,12 @@ dependencies = [ "winnow 0.7.13", ] +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "tower" version = "0.5.2" @@ -4370,6 +4437,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ac9567e27ca9fc45bac22f987fd62547b0ac65d2e6502dfc09cdab7dbdba31f" +[[package]] +name = "unicase" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84e5511b2a947f3ae965dcb29b13b7b1691b6e7332cf5dbc1744138d5acb7f6" +dependencies = [ + "version_check 0.1.0", +] + [[package]] name = "unicode-ident" version = "1.0.5" @@ -4430,6 +4506,26 @@ dependencies = [ "uniffi_pipeline", ] +[[package]] +name = "uniffi-bindgen-cpp" +version = "0.8.0+v0.30.0" +source = "git+https://github.com/chavic/uniffi-bindgen-cpp?rev=830943d09ba1840e69e72caba030c03b5287cb40#830943d09ba1840e69e72caba030c03b5287cb40" +dependencies = [ + "anyhow", + "askama 0.12.0", + "camino", + "cargo_metadata 0.19.0", + "clap 4.5.45", + "heck 0.5.0", + "paste", + "serde", + "textwrap 0.16.0", + "toml 0.8.0", + "topological-sort", + "uniffi_bindgen", + "uniffi_meta", +] + [[package]] name = "uniffi-bindgen-cs" version = "0.11.0+v0.31.0" @@ -4688,6 +4784,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df74ff70e2ced9607f67e06640f89a6a6374b459b51bdef290a5cfa657fe4fcc" +[[package]] +name = "version_check" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2edadb5308f31d97771a678c33df28f18d04f61de3fe21b9452f37854eb6c08" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 3f6487d61..6a6abb59c 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -184,6 +184,19 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive 0.12.5", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + [[package]] name = "askama" version = "0.13.1" @@ -210,6 +223,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser 0.2.1", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", +] + [[package]] name = "askama_derive" version = "0.13.1" @@ -244,6 +273,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "askama_parser" version = "0.13.0" @@ -1908,6 +1952,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2293,6 +2346,12 @@ dependencies = [ "cc", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.20" @@ -2392,6 +2451,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2769,6 +2838,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "uniffi", + "uniffi-bindgen-cpp", "uniffi-bindgen-cs", "uniffi-dart", "url", @@ -4461,6 +4531,12 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "tower" version = "0.5.3" @@ -4624,6 +4700,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4674,6 +4756,26 @@ dependencies = [ "uniffi_pipeline", ] +[[package]] +name = "uniffi-bindgen-cpp" +version = "0.8.0+v0.30.0" +source = "git+https://github.com/chavic/uniffi-bindgen-cpp?rev=830943d09ba1840e69e72caba030c03b5287cb40#830943d09ba1840e69e72caba030c03b5287cb40" +dependencies = [ + "anyhow", + "askama 0.12.1", + "camino", + "cargo_metadata 0.19.2", + "clap 4.6.6", + "heck 0.5.0", + "paste", + "serde", + "textwrap", + "toml 0.9.12+spec-1.1.0", + "topological-sort", + "uniffi_bindgen", + "uniffi_meta", +] + [[package]] name = "uniffi-bindgen-cs" version = "0.11.0+v0.31.0" diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 74a09acce..f10645f01 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -7,6 +7,7 @@ exclude = ["tests"] [features] default = [] +cpp = ["dep:uniffi-bindgen-cpp"] csharp = ["dep:uniffi-bindgen-cs"] dart = ["dep:uniffi-dart"] _test-utils = ["payjoin-test-utils", "tokio"] @@ -34,6 +35,7 @@ serde_json = "1.0.149" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"], optional = true } uniffi = { version = "0.31.2", features = ["cli"] } +uniffi-bindgen-cpp = { git = "https://github.com/chavic/uniffi-bindgen-cpp", rev = "830943d09ba1840e69e72caba030c03b5287cb40", optional = true } uniffi-bindgen-cs = { git = "https://github.com/NordSecurity/uniffi-bindgen-cs", tag = "v0.11.0+v0.31.0", optional = true } uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", tag = "v0.2.1+v0.31.2", optional = true } url = "2.5.4" diff --git a/payjoin-ffi/uniffi-bindgen.rs b/payjoin-ffi/uniffi-bindgen.rs index 7408f35d9..bc8fa14ca 100644 --- a/payjoin-ffi/uniffi-bindgen.rs +++ b/payjoin-ffi/uniffi-bindgen.rs @@ -31,6 +31,10 @@ fn uniffi_bindgen() { ) .expect("Failed to generate dart bindings"); } + #[cfg(feature = "cpp")] + Some("cpp") => { + uniffi_bindgen_cpp::main().expect("Failed to generate cpp bindings"); + } #[cfg(feature = "csharp")] Some("csharp") => { uniffi_bindgen_cs::main().expect("Failed to generate csharp bindings"); diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml index 9006539eb..a698e182a 100644 --- a/payjoin-ffi/uniffi.toml +++ b/payjoin-ffi/uniffi.toml @@ -12,6 +12,9 @@ cdylib_name = "payjoin_ffi" cdylib_name = "payjoin_ffi" package_name = "payjoin" +[bindings.cpp] +cdylib_name = "payjoin_ffi" + [bindings.csharp] namespace = "Payjoin" cdylib_name = "payjoin_ffi" From 11cbc3609736d5ac898822685bc4847598667737 Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 20 Aug 2026 14:49:21 +0200 Subject: [PATCH 2/4] Add cpp dev shell to flake Provide the C++ toolchain (cmake, ninja, the stdenv compiler), libcurl and nlohmann_json for the binding tests, and the pinned msrv rust toolchain, matching the shape of the other per-language binding shells. bitcoind is exposed through BITCOIND_EXE for the integration tests. --- flake.nix | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/flake.nix b/flake.nix index 7d86c760e..4047b6d66 100644 --- a/flake.nix +++ b/flake.nix @@ -367,6 +367,27 @@ BITCOIND_SKIP_DOWNLOAD = 1; }; + cppDevShell = pkgs.mkShell { + name = "cpp-dev"; + packages = + with pkgs; + [ + rustVersions.msrv + cmake + ninja + curl + nlohmann_json + bzip2 + ] + ++ lib.optionals pkgs.stdenv.isLinux [ + pkg-config + openssl + clang + ]; + BITCOIND_EXE = pkgs.lib.getExe' pkgs.bitcoind "bitcoind"; + BITCOIND_SKIP_DOWNLOAD = 1; + }; + csharpDevShell = pkgs.mkShell { name = "csharp-dev"; packages = @@ -487,6 +508,7 @@ release = releaseDevShell; python = pythonDevShell; javascript = javascriptDevShell; + cpp = cppDevShell; csharp = csharpDevShell; dart = dartDevShell; }; From 2c0ea785bbcefb9168ffb6d2fa283e82ce928715 Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 20 Aug 2026 14:49:21 +0200 Subject: [PATCH 3/4] Add C++ binding generation and test scaffold Add payjoin-ffi/cpp following the per-language binding layout: scripts/generate_bindings.sh generates C++20 bindings in library mode (skipping the unsupported async exports) and copies the cdylib, CMakeLists.txt builds a consumable payjoin_cpp target plus ctest-registered tests, and contrib/test.sh ties it together against the maintained lockfile. The tests port the python suite: unit tests cover URI parsing, receiver and sender session persistence through an in-memory persister, cancellation, and input validation errors; integration tests drive the full v2-to-v2 payjoin against bitcoind and the payjoin test services with libcurl for OHTTP posts. Register clang-format for the handwritten test sources in treefmt and skip the generated sources in codespell. --- .codespellrc | 2 +- payjoin-ffi/README.md | 1 + payjoin-ffi/cpp/.gitignore | 10 + payjoin-ffi/cpp/CMakeLists.txt | 38 ++ payjoin-ffi/cpp/CONTRIBUTING.md | 44 ++ payjoin-ffi/cpp/README.md | 56 +++ payjoin-ffi/cpp/contrib/test.sh | 22 + payjoin-ffi/cpp/scripts/generate_bindings.sh | 55 +++ payjoin-ffi/cpp/tests/http.hpp | 95 ++++ payjoin-ffi/cpp/tests/integration_tests.cpp | 484 +++++++++++++++++++ payjoin-ffi/cpp/tests/unit_tests.cpp | 186 +++++++ payjoin-ffi/cpp/tests/utils.hpp | 73 +++ treefmt.nix | 2 + 13 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 payjoin-ffi/cpp/.gitignore create mode 100644 payjoin-ffi/cpp/CMakeLists.txt create mode 100644 payjoin-ffi/cpp/CONTRIBUTING.md create mode 100644 payjoin-ffi/cpp/README.md create mode 100755 payjoin-ffi/cpp/contrib/test.sh create mode 100755 payjoin-ffi/cpp/scripts/generate_bindings.sh create mode 100644 payjoin-ffi/cpp/tests/http.hpp create mode 100644 payjoin-ffi/cpp/tests/integration_tests.cpp create mode 100644 payjoin-ffi/cpp/tests/unit_tests.cpp create mode 100644 payjoin-ffi/cpp/tests/utils.hpp diff --git a/.codespellrc b/.codespellrc index 571af6302..fd2439086 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,3 +1,3 @@ [codespell] -skip = .git,target,Cargo.toml,Cargo.lock,Cargo-minimal.lock,Cargo-recent.lock,package-lock.json,./fuzz/corpus +skip = .git,target,Cargo.toml,Cargo.lock,Cargo-minimal.lock,Cargo-recent.lock,package-lock.json,./fuzz/corpus,./payjoin-ffi/cpp/src,./payjoin-ffi/cpp/build ignore-words-list = crate,ser,ot,re-useable diff --git a/payjoin-ffi/README.md b/payjoin-ffi/README.md index 284a76978..f70162916 100644 --- a/payjoin-ffi/README.md +++ b/payjoin-ffi/README.md @@ -16,6 +16,7 @@ The directories below include instructions for using, building, and publishing t | Dart | linux, macOS | [payjoin-ffi/dart](dart) | [pub.dev](https://pub.dev/packages/payjoin) | | JavaScript | linux, macOS | [payjoin-ffi/javascript](javascript) | [npm](https://www.npmjs.com/package/payjoin) | | C# | linux, macOS, windows | [payjoin-ffi/csharp](csharp) | [nuget](https://www.nuget.org/packages/Payjoin) | +| C++ | linux, macOS | [payjoin-ffi/cpp](cpp) | draft, not yet published | ## Minimum Supported Rust Version (MSRV) diff --git a/payjoin-ffi/cpp/.gitignore b/payjoin-ffi/cpp/.gitignore new file mode 100644 index 000000000..ed7c4c9a0 --- /dev/null +++ b/payjoin-ffi/cpp/.gitignore @@ -0,0 +1,10 @@ +# Generated bindings +src/*.hpp +src/*.cpp + +# Native library copied for tests +/lib/*.so +/lib/*.dylib + +# CMake build tree +build/ diff --git a/payjoin-ffi/cpp/CMakeLists.txt b/payjoin-ffi/cpp/CMakeLists.txt new file mode 100644 index 000000000..b4a7095f9 --- /dev/null +++ b/payjoin-ffi/cpp/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.24) +project(payjoin_cpp CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Generated bindings (run scripts/generate_bindings.sh first). +file(GLOB PAYJOIN_GENERATED_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp) +if(NOT PAYJOIN_GENERATED_SOURCES) + message(FATAL_ERROR "No generated sources in src/. Run scripts/generate_bindings.sh first.") +endif() + +# Consumable library target wrapping the payjoin_ffi cdylib. +add_library(payjoin_cpp ${PAYJOIN_GENERATED_SOURCES}) +target_include_directories(payjoin_cpp PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) + +find_library(PAYJOIN_FFI_LIB + NAMES payjoin_ffi + PATHS ${CMAKE_CURRENT_SOURCE_DIR}/lib + NO_DEFAULT_PATH + REQUIRED) +target_link_libraries(payjoin_cpp PUBLIC ${PAYJOIN_FFI_LIB}) + +# Let test binaries locate the cdylib without LD_LIBRARY_PATH. +set(CMAKE_BUILD_RPATH ${CMAKE_CURRENT_SOURCE_DIR}/lib) + +enable_testing() + +add_executable(unit_tests tests/unit_tests.cpp) +target_link_libraries(unit_tests PRIVATE payjoin_cpp) +add_test(NAME unit_tests COMMAND unit_tests) + +find_package(CURL REQUIRED) +find_package(nlohmann_json REQUIRED) +add_executable(integration_tests tests/integration_tests.cpp) +target_link_libraries(integration_tests PRIVATE payjoin_cpp CURL::libcurl nlohmann_json::nlohmann_json) +add_test(NAME integration_tests COMMAND integration_tests) +set_tests_properties(integration_tests PROPERTIES TIMEOUT 600) diff --git a/payjoin-ffi/cpp/CONTRIBUTING.md b/payjoin-ffi/cpp/CONTRIBUTING.md new file mode 100644 index 000000000..c4b25b128 --- /dev/null +++ b/payjoin-ffi/cpp/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing + +Instructions for building and testing the C++ bindings. + +## Build Bindings + +Use the repo's nix dev shell so the toolchain matches CI: + +```shell +git clone https://github.com/payjoin/rust-payjoin.git +cd rust-payjoin +nix develop .#cpp + +cd payjoin-ffi/cpp + +# Generate the bindings +bash ./scripts/generate_bindings.sh +``` + +The generator is the `uniffi-bindgen-cpp` fork pinned in +`payjoin-ffi/Cargo.toml` behind the `cpp` feature and dispatched through +`payjoin-ffi/uniffi-bindgen.rs`, the same pattern the C# and Dart bindings +use. Generated code lands in `src/` and is not checked in; regenerate after +any change to the FFI surface. Async exports are skipped during generation +(see README). + +## Running Tests + +```shell +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Or run everything the way CI does: + +```shell +./contrib/test.sh +``` + +## Formatting + +Handwritten C++ under `tests/` is formatted by clang-format via treefmt; run +`nix fmt` before committing. diff --git a/payjoin-ffi/cpp/README.md b/payjoin-ffi/cpp/README.md new file mode 100644 index 000000000..37bbe6394 --- /dev/null +++ b/payjoin-ffi/cpp/README.md @@ -0,0 +1,56 @@ +# Payjoin C++ Bindings + +Welcome to the C++ language bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/)! + +Payjoin lets the receiver of a Bitcoin transfer contribute inputs to the sender's transaction. The result looks like any other transaction, which preserves privacy by poisoning the common-input-ownership heuristic that chain surveillance depends on, and it lets the receiver batch its own operations into the same transaction. These bindings implement both [BIP 78](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) (synchronous payjoin) and [BIP 77](https://github.com/bitcoin/bips/blob/master/bip-0077.md) (asynchronous payjoin, where sender and receiver exchange the transaction through an untrusted directory and never need to be online at the same time). + +## Draft status + +These bindings are a draft. They are generated with a +[fork of uniffi-bindgen-cpp](https://github.com/chavic/uniffi-bindgen-cpp) +carrying the in-progress uniffi 0.30/0.31 support +([NordSecurity/uniffi-bindgen-cpp#59](https://github.com/NordSecurity/uniffi-bindgen-cpp/pull/59)), +to be swapped for an upstream release once one supports uniffi 0.31. + +Unlike the other language bindings, the async API surface (`save_async`, +`replay_*_event_log_async`, and the async persister interfaces) is not +exposed: uniffi-bindgen-cpp does not support async functions yet +([NordSecurity/uniffi-bindgen-cpp#51](https://github.com/NordSecurity/uniffi-bindgen-cpp/issues/51)), +so generation skips them. The synchronous API is complete. + +## Build + +Requires a C++20 compiler, CMake >= 3.24, and a Rust toolchain. The tests +additionally need libcurl and nlohmann-json (both provided by the repo's nix +dev shell: `nix develop .#cpp`). + +```shell +git clone https://github.com/payjoin/rust-payjoin.git +cd rust-payjoin/payjoin-ffi/cpp + +# Generate the bindings into src/ and copy the native library into lib/ +bash ./scripts/generate_bindings.sh + +# Build the payjoin_cpp library and the tests +cmake -S . -B build +cmake --build build +``` + +Consume the `payjoin_cpp` CMake target: it exposes the generated headers and +links the `payjoin_ffi` cdylib built by cargo. + +## Running Tests + +```shell +ctest --test-dir build --output-on-failure +``` + +The integration tests spin up bitcoind and the payjoin test services, so they +need `BITCOIND_EXE` to point at a bitcoind binary (the nix dev shell sets it). + +## End to end example + +The usage reference is the commented walkthrough in +[`tests/integration_tests.cpp`](tests/integration_tests.cpp): +`test_integration_v2_to_v2` drives a complete payjoin from both sides, +executed by CI on every change so it cannot go stale. diff --git a/payjoin-ffi/cpp/contrib/test.sh b/payjoin-ffi/cpp/contrib/test.sh new file mode 100755 index 000000000..fd9d8f39a --- /dev/null +++ b/payjoin-ffi/cpp/contrib/test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build against the maintained lockfile instead of resolving the dependency +# graph fresh on every run. use_lockfile copies Cargo-recent.lock into place +# and restores the previous state when this script exits. +REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$REPO_ROOT" +source contrib/lockfile.sh +use_lockfile Cargo-recent.lock + +cd "$REPO_ROOT/payjoin-ffi/cpp" + +echo "==> Generating FFI bindings..." +bash ./scripts/generate_bindings.sh + +echo "==> Building C++ tests..." +cmake -S . -B build +cmake --build build -j2 + +echo "==> Running C++ tests..." +ctest --test-dir build --output-on-failure diff --git a/payjoin-ffi/cpp/scripts/generate_bindings.sh b/payjoin-ffi/cpp/scripts/generate_bindings.sh new file mode 100755 index 000000000..db7e69e1d --- /dev/null +++ b/payjoin-ffi/cpp/scripts/generate_bindings.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +OS=$(uname -s) +echo "Running on $OS" + +if [[ $OS == "Darwin" ]]; then + LIBNAME=libpayjoin_ffi.dylib +elif [[ $OS == "Linux" ]]; then + LIBNAME=libpayjoin_ffi.so +else + echo "Unsupported os: $OS" + exit 1 +fi + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Navigate to payjoin-ffi directory (parent of cpp, which is parent of scripts) +cd "$SCRIPT_DIR/../.." + +echo "Generating payjoin C++..." +# Keep parity with other language test scripts: include _test-utils by default. +PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES-_test-utils} +PAYJOIN_FFI_PROFILE=${PAYJOIN_FFI_PROFILE:-dev} +if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then + TARGET_PROFILE_DIR=debug +else + TARGET_PROFILE_DIR=$PAYJOIN_FFI_PROFILE +fi +GENERATOR_FEATURES="cpp" +if [[ -n $PAYJOIN_FFI_FEATURES ]]; then + GENERATOR_FEATURES="$GENERATOR_FEATURES,$PAYJOIN_FFI_FEATURES" +fi + +cargo build --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -j2 + +# Clean output directory to prevent stale definitions +echo "Cleaning cpp/src/ directory..." +mkdir -p cpp/src +rm -f cpp/src/*.hpp cpp/src/*.cpp + +# Use the Cargo-managed C++ generator pinned in payjoin-ffi/Cargo.toml. +# --skip-async drops the async exports (save_async, *Async persister traits): +# uniffi-bindgen-cpp does not support async functions yet. +UNIFFI_BINDGEN_LANGUAGE=cpp cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- \ + --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \ + --out-dir cpp/src/ \ + --skip-async + +# Copy native library to cpp/lib/ directory for testing +echo "Copying native library..." +mkdir -p cpp/lib +cp "../target/$TARGET_PROFILE_DIR/$LIBNAME" "cpp/lib/$LIBNAME" + +echo "All done!" diff --git a/payjoin-ffi/cpp/tests/http.hpp b/payjoin-ffi/cpp/tests/http.hpp new file mode 100644 index 000000000..ea2f7e18d --- /dev/null +++ b/payjoin-ffi/cpp/tests/http.hpp @@ -0,0 +1,95 @@ +#pragma once + +// Minimal libcurl HTTP POST helper for the integration tests, trusting the +// self-signed certificate served by the local payjoin test services (the role +// python's httpx verify context and C#'s Payjoin.Http.cs play). + +#include +#include +#include +#include +#include + +#include + +namespace payjoin_test_http { + +// DER certificate bytes -> PEM string so libcurl can consume it via +// CURLOPT_CAINFO_BLOB. +inline std::string der_to_pem(const std::vector &der) { + static const char table[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string b64; + b64.reserve((der.size() + 2) / 3 * 4); + for (size_t i = 0; i < der.size(); i += 3) { + uint32_t chunk = static_cast(der[i]) << 16; + if (i + 1 < der.size()) + chunk |= static_cast(der[i + 1]) << 8; + if (i + 2 < der.size()) + chunk |= static_cast(der[i + 2]); + b64.push_back(table[(chunk >> 18) & 0x3F]); + b64.push_back(table[(chunk >> 12) & 0x3F]); + b64.push_back(i + 1 < der.size() ? table[(chunk >> 6) & 0x3F] : '='); + b64.push_back(i + 2 < der.size() ? table[chunk & 0x3F] : '='); + } + std::string pem = "-----BEGIN CERTIFICATE-----\n"; + for (size_t i = 0; i < b64.size(); i += 64) { + pem += b64.substr(i, 64); + pem += '\n'; + } + pem += "-----END CERTIFICATE-----\n"; + return pem; +} + +inline size_t write_body(char *ptr, size_t size, size_t nmemb, void *userdata) { + auto *out = static_cast *>(userdata); + out->insert(out->end(), ptr, ptr + size * nmemb); + return size * nmemb; +} + +// POST `body` to `url` with the given content type; `cert_der` is the test +// services' root certificate in DER form. Returns the response body and +// throws on transport errors or non-2xx statuses. +inline std::vector post(const std::string &url, + const std::string &content_type, + const std::vector &body, + const std::vector &cert_der) { + CURL *curl = curl_easy_init(); + if (!curl) + throw std::runtime_error("curl_easy_init failed"); + + std::vector response; + std::string pem = der_to_pem(cert_der); + struct curl_blob ca_blob = {pem.data(), pem.size(), CURL_BLOB_COPY}; + struct curl_slist *headers = nullptr; + headers = + curl_slist_append(headers, ("Content-Type: " + content_type).c_str()); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.data()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast(body.size())); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_body); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); + + CURLcode res = curl_easy_perform(curl); + long status = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + throw std::runtime_error(std::string("curl error: ") + + curl_easy_strerror(res)); + } + if (status < 200 || status >= 300) { + throw std::runtime_error("unexpected HTTP status " + + std::to_string(status) + " from " + url); + } + return response; +} + +} // namespace payjoin_test_http diff --git a/payjoin-ffi/cpp/tests/integration_tests.cpp b/payjoin-ffi/cpp/tests/integration_tests.cpp new file mode 100644 index 000000000..64bfea30b --- /dev/null +++ b/payjoin-ffi/cpp/tests/integration_tests.cpp @@ -0,0 +1,484 @@ +// Integration tests for the C++ bindings, ported from +// payjoin-ffi/python/test/test_payjoin_integration_test.py. +// +// Requires bitcoind (BITCOIND_EXE) and runs the payjoin test services from +// the _test-utils FFI exports, so it must be built from bindings generated +// with the _test-utils feature (the default in scripts/generate_bindings.sh). + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "http.hpp" +#include "payjoin.hpp" +#include "utils.hpp" + +using json = nlohmann::json; + +namespace { + +std::string to_hex(const std::vector &bytes) { + static const char digits[] = "0123456789abcdef"; + std::string out; + out.reserve(bytes.size() * 2); + for (auto b : bytes) { + out.push_back(digits[b >> 4]); + out.push_back(digits[b & 0x0F]); + } + return out; +} + +std::vector> +rpc_params(std::initializer_list params) { + std::vector> out; + for (const auto &p : params) { + out.push_back(p.dump()); + } + return out; +} + +json rpc(const std::shared_ptr &client, + const std::string &method, std::initializer_list params = {}) { + return json::parse(client->call(method, rpc_params(params))); +} + +// Callbacks driving the receiver checklist against bitcoind, ported from the +// python test's MempoolAcceptanceCallback and friends. +class MempoolAcceptanceCallback : public payjoin::CanBroadcast { +public: + explicit MempoolAcceptanceCallback( + std::shared_ptr connection) + : connection_(std::move(connection)) {} + + bool callback(const std::vector &tx) override { + try { + auto res = + rpc(connection_, "testmempoolaccept", {json::array({to_hex(tx)})}); + return res.at(0).at("allowed").get(); + } catch (const std::exception &e) { + std::cerr << "An error occurred: " << e.what() << std::endl; + return false; + } + } + +private: + std::shared_ptr connection_; +}; + +class IsScriptOwnedCallback : public payjoin::IsScriptOwned { +public: + explicit IsScriptOwnedCallback(std::shared_ptr connection) + : connection_(std::move(connection)) {} + + bool callback(const std::vector &script) override { + try { + auto decoded = rpc(connection_, "decodescript", {to_hex(script)}); + std::vector candidates; + auto collect = [&candidates](const json &obj) { + if (obj.contains("address") && obj["address"].is_string()) { + candidates.push_back(obj["address"].get()); + } + if (obj.contains("addresses") && obj["addresses"].is_array()) { + for (const auto &addr : obj["addresses"]) { + if (addr.is_string()) + candidates.push_back(addr.get()); + } + } + }; + collect(decoded); + if (decoded.contains("p2sh") && decoded["p2sh"].is_string()) { + candidates.push_back(decoded["p2sh"].get()); + } + if (decoded.contains("segwit") && decoded["segwit"].is_object()) { + collect(decoded["segwit"]); + } + for (const auto &addr : candidates) { + auto info = rpc(connection_, "getaddressinfo", {addr}); + if (info.value("ismine", false)) + return true; + } + return false; + } catch (const std::exception &e) { + std::cerr << "An error occurred: " << e.what() << std::endl; + return false; + } + } + +private: + std::shared_ptr connection_; +}; + +class IsInputOwnedCallback : public payjoin::IsInputOwned { +public: + explicit IsInputOwnedCallback(std::shared_ptr connection) + : connection_(std::move(connection)) {} + + bool callback(const payjoin::OutPoint &outpoint) override { + try { + auto tx_out = + rpc(connection_, "gettxout", {outpoint.txid, outpoint.vout, true}); + if (tx_out.is_null()) + return false; + auto script_hex = tx_out.at("scriptPubKey").at("hex").get(); + return IsScriptOwnedCallback(connection_).callback(from_hex(script_hex)); + } catch (const std::exception &e) { + std::cerr << "An error occurred: " << e.what() << std::endl; + return false; + } + } + +private: + std::shared_ptr connection_; +}; + +class CheckInputsNotSeenCallback : public payjoin::IsOutputKnown { +public: + bool callback(const payjoin::OutPoint &) override { return false; } +}; + +class ProcessPsbtCallback : public payjoin::ProcessPsbt { +public: + explicit ProcessPsbtCallback(std::shared_ptr connection) + : connection_(std::move(connection)) {} + + std::string callback(const std::string &psbt) override { + auto res = json::parse(connection_->call("walletprocesspsbt", {psbt})); + return res.at("psbt").get(); + } + +private: + std::shared_ptr connection_; +}; + +std::string build_sweep_psbt(const std::shared_ptr &sender, + const std::shared_ptr &pj_uri) { + json outputs = json::object(); + outputs[pj_uri->address()] = 50; + json options = {{"lockUnspents", true}, + {"fee_rate", 10}, + {"subtractFeeFromOutputs", {0}}}; + auto created = rpc(sender, "walletcreatefundedpsbt", + {json::array(), outputs, 0, options}); + auto psbt = created.at("psbt").get(); + auto processed = json::parse(sender->call( + "walletprocesspsbt", + {psbt, json(true).dump(), json("ALL").dump(), json(false).dump()})); + return processed.at("psbt").get(); +} + +std::vector> +get_inputs(const std::shared_ptr &rpc_connection) { + auto utxos = rpc(rpc_connection, "listunspent"); + std::vector> inputs; + for (const auto &utxo : utxos) { + payjoin::TxIn txin{ + std::make_shared(payjoin::OutPoint{ + utxo.at("txid").get(), + utxo.at("vout").get(), + }), + {}, + 0, + {}, + }; + auto amount_sat = static_cast( + std::llround(utxo.at("amount").get() * 100'000'000.0)); + payjoin::PsbtInput psbt_in{ + std::make_shared(payjoin::TxOut{ + amount_sat, + from_hex(utxo.at("scriptPubKey").get()), + }), + std::nullopt, + std::nullopt, + }; + inputs.push_back(payjoin::InputPair::init(txin, psbt_in, std::nullopt)); + } + return inputs; +} + +// Shared bitcoind environment, mirroring the python test's setUpClass. +struct TestEnv { + std::shared_ptr env; + std::shared_ptr receiver; + std::shared_ptr sender; + + TestEnv() { + env = payjoin::init_bitcoind_sender_receiver(); + receiver = env->get_receiver(); + sender = env->get_sender(); + } +}; + +TestEnv &test_env() { + static TestEnv env; + return env; +} + +// Walks the receiver typestate chain until a payjoin proposal is available, +// mirroring the python test's process_receiver_proposal. Returns nullptr when +// polling reached stasis (no sender fallback available yet). +std::shared_ptr process_receiver_proposal( + const std::shared_ptr &session, + const std::shared_ptr &recv_persister, + const std::string &ohttp_relay, const std::vector &cert) { + auto &env = test_env(); + + auto request = session->create_poll_request(ohttp_relay); + auto response = payjoin_test_http::post(request.request->url, + request.request->content_type, + request.request->body, cert); + auto outcome = session->process_response(response, request.client_response) + ->save(recv_persister); + if (std::holds_alternative( + outcome.get_variant())) { + return nullptr; + } + auto proposal = std::get( + outcome.get_variant()) + .inner; + + auto maybe_inputs_owned = + proposal + ->check_broadcast_suitability( + std::nullopt, + std::make_shared(env.receiver)) + ->save(recv_persister); + auto maybe_inputs_seen = + maybe_inputs_owned + ->check_inputs_not_owned( + std::make_shared(env.receiver)) + ->save(recv_persister); + auto outputs_unknown = maybe_inputs_seen + ->check_no_inputs_seen_before( + std::make_shared()) + ->save(recv_persister); + auto wants_outputs = + outputs_unknown + ->identify_receiver_outputs( + std::make_shared(env.receiver)) + ->save(recv_persister); + auto wants_inputs = wants_outputs->commit_outputs()->save(recv_persister); + auto wants_fee_range = + wants_inputs->contribute_inputs(get_inputs(env.receiver)) + ->commit_inputs() + ->save(recv_persister); + auto provisional = + wants_fee_range->apply_fee_range(1, 10)->save(recv_persister); + return provisional + ->finalize_proposal(std::make_shared(env.receiver)) + ->save(recv_persister); +} + +void test_ffi_validation() { + auto &env = test_env(); + const uint64_t too_large_amount = 21'000'000ULL * 100'000'000ULL + 1; + + // Invalid outpoint (txid too long) should fail before amount checks. + { + payjoin::TxIn txin{ + std::make_shared( + payjoin::OutPoint{std::string(128, '0'), 0}), + {}, + 0, + {}, + }; + payjoin::PsbtInput psbt_in{ + std::make_shared(payjoin::TxOut{1, {0x6A}}), + std::nullopt, + std::nullopt, + }; + bool threw = false; + try { + payjoin::InputPair::init(txin, psbt_in, std::nullopt); + } catch (const payjoin::input_pair_error::InvalidOutPoint &) { + threw = true; + } + EXPECT(threw); + } + + // Valid outpoint hits amount overflow validation. + { + payjoin::TxIn txin{ + std::make_shared( + payjoin::OutPoint{std::string(64, '0'), 0}), + {}, + 0, + {}, + }; + payjoin::PsbtInput psbt_in{ + std::make_shared( + payjoin::TxOut{too_large_amount, {0x6A}}), + std::nullopt, + std::nullopt, + }; + bool threw = false; + try { + payjoin::InputPair::init(txin, psbt_in, std::nullopt); + } catch (const payjoin::input_pair_error::FfiValidation &e) { + threw = true; + EXPECT( + dynamic_cast( + e.v1.get()) != nullptr); + } + EXPECT(threw); + } + + // SenderBuilder rejects fee rate overflow. + auto receiver_address = rpc(env.receiver, "getnewaddress").get(); + auto services = payjoin::TestServices::initialize(); + services->wait_for_services_ready(); + auto directory = services->directory_url(); + auto ohttp_keys = services->fetch_ohttp_keys(); + auto recv_persister = std::make_shared(); + auto pj_uri = + payjoin::ReceiverBuilder::init(receiver_address, directory, ohttp_keys) + ->build() + ->save(recv_persister) + ->pj_uri(); + + { + bool threw = false; + try { + payjoin::SenderBuilder::init(payjoin::original_psbt(), pj_uri) + ->build_recommended(UINT64_MAX); + } catch (const payjoin::sender_input_error::FfiValidation &e) { + threw = true; + EXPECT(dynamic_cast(e.v1.get()) != nullptr); + } + EXPECT(threw); + } + + // PjUri rejects amount out of range. + { + bool threw = false; + try { + pj_uri->set_amount_sats(too_large_amount); + } catch (const payjoin::ffi_validation_error::AmountOutOfRange &) { + threw = true; + } + EXPECT(threw); + } +} + +void test_integration_v2_to_v2() { + auto &env = test_env(); + auto receiver_address = rpc(env.receiver, "getnewaddress").get(); + payjoin::init_tracing(); + auto services = payjoin::TestServices::initialize(); + services->wait_for_services_ready(); + auto directory = services->directory_url(); + auto ohttp_relay = services->ohttp_relay_url(); + auto ohttp_keys = services->fetch_ohttp_keys(); + auto cert = services->cert(); + + // ********************** + // Inside the Receiver: + auto recv_persister = std::make_shared(); + auto sender_persister = std::make_shared(); + auto session = + payjoin::ReceiverBuilder::init(receiver_address, directory, ohttp_keys) + ->build() + ->save(recv_persister); + auto no_proposal = + process_receiver_proposal(session, recv_persister, ohttp_relay, cert); + EXPECT(no_proposal == nullptr); + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the + // pj_uri + auto pj_uri = session->pj_uri(); + auto psbt = build_sweep_psbt(env.sender, pj_uri); + auto req_ctx = payjoin::SenderBuilder::init(psbt, pj_uri) + ->build_recommended(1000) + ->save(sender_persister); + auto request_send = req_ctx->create_v2_post_request(ohttp_relay); + auto response = payjoin_test_http::post(request_send.request->url, + request_send.request->content_type, + request_send.request->body, cert); + auto send_ctx = req_ctx->process_response(response, request_send.ohttp_ctx) + ->save(sender_persister); + // POST Original PSBT + + // ********************** + // Inside the Receiver: + + // GET fallback psbt + auto payjoin_proposal = + process_receiver_proposal(session, recv_persister, ohttp_relay, cert); + EXPECT(payjoin_proposal != nullptr); + auto request_recv = payjoin_proposal->create_post_request(ohttp_relay); + response = payjoin_test_http::post(request_recv.request->url, + request_recv.request->content_type, + request_recv.request->body, cert); + payjoin_proposal->process_response(response, request_recv.client_response); + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + // Replay post fallback to get the response + std::optional proposal_psbt; + for (int i = 0; i < 4; ++i) { + auto poll_req = send_ctx->create_poll_request(ohttp_relay); + auto poll_resp = payjoin_test_http::post(poll_req.request->url, + poll_req.request->content_type, + poll_req.request->body, cert); + auto outcome = send_ctx->process_response(poll_resp, poll_req.ohttp_ctx) + ->save(sender_persister); + if (const auto *progress = std::get_if< + payjoin::PollingForProposalTransitionOutcome::kProgress>( + &outcome.get_variant())) { + proposal_psbt = progress->psbt_base64; + break; + } + } + if (!proposal_psbt.has_value()) { + // Receiver still not ready; treat as acceptable in this smoke test. + return; + } + auto payjoin_psbt = + json::parse(env.sender->call("walletprocesspsbt", {*proposal_psbt})) + .at("psbt") + .get(); + auto final_psbt = + json::parse( + env.sender->call("finalizepsbt", {payjoin_psbt, json(false).dump()})) + .at("psbt") + .get(); + auto final_tx_hex = + json::parse( + env.sender->call("finalizepsbt", {final_psbt, json(true).dump()})) + .at("hex") + .get(); + rpc(env.sender, "sendrawtransaction", {final_tx_hex}); + + // Check resulting transaction and balances + auto decoded_psbt = rpc(env.sender, "decodepsbt", {final_psbt}); + auto network_fees = decoded_psbt.at("fee").get(); + auto decoded_tx = rpc(env.sender, "decoderawtransaction", {final_tx_hex}); + // Sender sent the entire value of their utxo to receiver (minus fees) + EXPECT(decoded_tx.at("vin").size() == 2); + EXPECT(decoded_tx.at("vout").size() == 1); + auto receiver_pending = rpc(env.receiver, "getbalances") + .at("mine") + .at("untrusted_pending") + .get(); + EXPECT(std::abs(receiver_pending - (100.0 - network_fees)) < 1e-8); + EXPECT(rpc(env.sender, "getbalance").get() == 0.0); +} + +} // namespace + +int main() { + curl_global_init(CURL_GLOBAL_DEFAULT); + run_test("test_ffi_validation", test_ffi_validation); + run_test("test_integration_v2_to_v2", test_integration_v2_to_v2); + curl_global_cleanup(); + return failed_tests == 0 ? 0 : 1; +} diff --git a/payjoin-ffi/cpp/tests/unit_tests.cpp b/payjoin-ffi/cpp/tests/unit_tests.cpp new file mode 100644 index 000000000..474710fc2 --- /dev/null +++ b/payjoin-ffi/cpp/tests/unit_tests.cpp @@ -0,0 +1,186 @@ +// Unit tests for the C++ bindings, ported from +// payjoin-ffi/python/test/test_payjoin_unit_test.py. +// +// The async persistence variants are not ported: the C++ generator skips +// async exports (see scripts/generate_bindings.sh). + +#include +#include +#include +#include + +#include "payjoin.hpp" +#include "utils.hpp" + +namespace { + +const char *kReceiverAddress = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; +const char *kSenderReceiverAddress = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK"; +const char *kDirectory = "https://example.com"; + +void test_todo_url_encoded() { + auto uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://" + "example.com?ciao"; + EXPECT(payjoin::Url::parse(uri) != nullptr); +} + +void test_valid_url() { + auto uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://" + "example.com?ciao"; + EXPECT(payjoin::Url::parse(uri) != nullptr); +} + +void test_missing_amount() { + auto uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://" + "testnet.demo.btcpayserver.org/BTC/pj"; + EXPECT(payjoin::Url::parse(uri) != nullptr); +} + +void test_valid_uris() { + auto https = payjoin::example_url(); + auto onion = + "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion"; + + std::vector addresses = { + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4", + "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + }; + for (const auto &address : addresses) { + for (const auto &pj : {https, std::string(onion)}) { + auto uri = address + "?amount=1&pj=" + pj; + EXPECT(payjoin::Url::parse(uri) != nullptr); + } + } +} + +void test_receiver_persistence() { + auto persister = std::make_shared(); + payjoin::ReceiverBuilder::init(kReceiverAddress, kDirectory, + test_ohttp_keys()) + ->build() + ->save(persister); + auto result = payjoin::replay_receiver_event_log(persister); + EXPECT(std::holds_alternative( + result->state().get_variant())); +} + +void test_sender_persistence() { + // Create a receiver to just get the pj uri + auto recv_persister = std::make_shared(); + auto receiver = payjoin::ReceiverBuilder::init(kSenderReceiverAddress, + kDirectory, test_ohttp_keys()) + ->build() + ->save(recv_persister); + auto uri = receiver->pj_uri(); + + auto persister = std::make_shared(); + auto psbt = payjoin::original_psbt(); + auto with_reply_key = + payjoin::SenderBuilder::init(psbt, uri)->build_recommended(1000)->save( + persister); + EXPECT(with_reply_key != nullptr); +} + +void test_receiver_cancel() { + auto persister = std::make_shared(); + auto initialized = payjoin::ReceiverBuilder::init( + kReceiverAddress, kDirectory, test_ohttp_keys()) + ->build() + ->save(persister); + auto cancel_transition = initialized->cancel(); + auto fallback = cancel_transition->save(persister); + EXPECT(fallback == nullptr); + auto result = payjoin::replay_receiver_event_log(persister); + EXPECT(std::holds_alternative( + result->state().get_variant())); +} + +void test_sender_cancel() { + // Create a receiver to just get the pj uri + auto recv_persister = std::make_shared(); + auto receiver = payjoin::ReceiverBuilder::init(kSenderReceiverAddress, + kDirectory, test_ohttp_keys()) + ->build() + ->save(recv_persister); + auto uri = receiver->pj_uri(); + + auto persister = std::make_shared(); + auto psbt = payjoin::original_psbt(); + auto with_reply_key = + payjoin::SenderBuilder::init(psbt, uri)->build_recommended(1000)->save( + persister); + auto cancel_transition = with_reply_key->cancel(); + auto pending_fallback = cancel_transition->save(persister); + EXPECT(pending_fallback != nullptr); + EXPECT(!pending_fallback->fallback_tx().empty()); + auto result = payjoin::replay_sender_event_log(persister); + EXPECT(std::holds_alternative( + result->state().get_variant())); + pending_fallback->close()->save(persister); + result = payjoin::replay_sender_event_log(persister); + EXPECT(std::holds_alternative( + result->state().get_variant())); +} + +void test_receiver_builder_rejects_bad_address() { + bool threw = false; + try { + payjoin::ReceiverBuilder::init("not-an-address", kDirectory, + test_ohttp_keys()); + } catch (const std::exception &) { + threw = true; + } + EXPECT(threw); +} + +void test_input_pair_rejects_invalid_outpoint() { + bool threw = false; + try { + payjoin::TxIn txin{ + std::make_shared(payjoin::OutPoint{"deadbeef", 0}), + {}, + 0, + {}, + }; + payjoin::PsbtInput psbtin{std::nullopt, std::nullopt, std::nullopt}; + payjoin::InputPair::init(txin, psbtin, std::nullopt); + } catch (const std::exception &) { + threw = true; + } + EXPECT(threw); +} + +void test_sender_builder_rejects_bad_psbt() { + auto uri = + payjoin::Uri::parse("bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?" + "pj=https://example.com/pj") + ->check_pj_supported(); + bool threw = false; + try { + payjoin::SenderBuilder::init("not-a-psbt", uri); + } catch (const std::exception &) { + threw = true; + } + EXPECT(threw); +} + +} // namespace + +int main() { + run_test("test_todo_url_encoded", test_todo_url_encoded); + run_test("test_valid_url", test_valid_url); + run_test("test_missing_amount", test_missing_amount); + run_test("test_valid_uris", test_valid_uris); + run_test("test_receiver_persistence", test_receiver_persistence); + run_test("test_sender_persistence", test_sender_persistence); + run_test("test_receiver_cancel", test_receiver_cancel); + run_test("test_sender_cancel", test_sender_cancel); + run_test("test_receiver_builder_rejects_bad_address", + test_receiver_builder_rejects_bad_address); + run_test("test_input_pair_rejects_invalid_outpoint", + test_input_pair_rejects_invalid_outpoint); + run_test("test_sender_builder_rejects_bad_psbt", + test_sender_builder_rejects_bad_psbt); + return failed_tests == 0 ? 0 : 1; +} diff --git a/payjoin-ffi/cpp/tests/utils.hpp b/payjoin-ffi/cpp/tests/utils.hpp new file mode 100644 index 000000000..92b6c7132 --- /dev/null +++ b/payjoin-ffi/cpp/tests/utils.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "payjoin.hpp" + +// In-memory persisters backing the JSON session persister callback traits, +// mirroring payjoin-ffi/python/test/utils.py. +class InMemoryReceiverPersister : public payjoin::JsonReceiverSessionPersister { +public: + std::vector events; + bool closed = false; + + void save(const std::string &event) override { events.push_back(event); } + std::vector load() override { return events; } + void close() override { closed = true; } +}; + +class InMemorySenderPersister : public payjoin::JsonSenderSessionPersister { +public: + std::vector events; + bool closed = false; + + void save(const std::string &event) override { events.push_back(event); } + std::vector load() override { return events; } + void close() override { closed = true; } +}; + +inline std::vector from_hex(const std::string &hex) { + std::vector out; + out.reserve(hex.size() / 2); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back( + static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + } + return out; +} + +// The OHTTP key configuration used across the python and C# unit tests. +inline std::shared_ptr test_ohttp_keys() { + return payjoin::OhttpKeys::decode(from_hex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb4" + "83824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d3709570004" + "00010003")); +} + +// Minimal test harness: run a void() callable, report pass/fail, and keep a +// global failure count for the process exit code. +inline int failed_tests = 0; + +template void run_test(const std::string &name, F &&f) { + try { + f(); + std::cout << "PASS " << name << std::endl; + } catch (const std::exception &e) { + ++failed_tests; + std::cout << "FAIL " << name << ": " << e.what() << std::endl; + } catch (...) { + ++failed_tests; + std::cout << "FAIL " << name << ": unknown exception" << std::endl; + } +} + +#define EXPECT(cond) \ + do { \ + if (!(cond)) { \ + throw std::runtime_error("expectation failed: " #cond); \ + } \ + } while (0) diff --git a/treefmt.nix b/treefmt.nix index ee223c814..5ce7a9743 100644 --- a/treefmt.nix +++ b/treefmt.nix @@ -3,6 +3,7 @@ projectRootFile = "flake.nix"; programs = { dart-format.enable = true; + clang-format.enable = true; nixfmt.enable = true; prettier.enable = true; ruff-format.enable = true; @@ -23,6 +24,7 @@ }; settings = { formatter = { + clang-format.includes = [ "payjoin-ffi/cpp/tests/*" ]; dart-format.options = [ "--language-version" "latest" From f99b3031696e7f54e609615b8df6d91b29fb6f75 Mon Sep 17 00:00:00 2001 From: chavic Date: Thu, 20 Aug 2026 14:49:21 +0200 Subject: [PATCH 4/4] Run C++ binding tests in CI Add a cpp workflow mirroring the other language binding workflows and register the cpp binding in the aggregate payjoin-ffi test script. --- .github/workflows/cpp.yml | 31 +++++++++++++++++++++++++++++++ payjoin-ffi/contrib/test.sh | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cpp.yml diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml new file mode 100644 index 000000000..3999c0fea --- /dev/null +++ b/.github/workflows/cpp.yml @@ -0,0 +1,31 @@ +name: Build and Test Cpp +on: + workflow_run: + workflows: ["Flake maintenance"] + types: [requested] + branches: + - "update_flake_lock_action" + pull_request: + paths: + - payjoin-ffi/** + # The jobs run inside the flake's dev shell, so changes to the flake + # change this workflow's environment. + - flake.nix + - flake.lock + +jobs: + build-cpp-and-test: + name: "Build and test cpp" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-26.04, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: Set up nix + uses: ./.github/actions/setup-nix + - name: "Build and test" + run: nix develop .#cpp -c bash ./payjoin-ffi/cpp/contrib/test.sh diff --git a/payjoin-ffi/contrib/test.sh b/payjoin-ffi/contrib/test.sh index 19689fa69..1a6ff07dd 100755 --- a/payjoin-ffi/contrib/test.sh +++ b/payjoin-ffi/contrib/test.sh @@ -2,7 +2,7 @@ set -e cd "$(dirname "$0")/.." cargo test --package payjoin-ffi --verbose --features=_manual-tls,_test-utils -BINDINGS="dart javascript python csharp" +BINDINGS="cpp dart javascript python csharp" pids=() tmpfiles=() for binding in $BINDINGS; do