Skip to content

Add draft C++ language bindings for payjoin-ffi - #1827

Draft
chavic wants to merge 4 commits into
payjoin:masterfrom
chavic:chavic/cpp-bindings
Draft

Add draft C++ language bindings for payjoin-ffi#1827
chavic wants to merge 4 commits into
payjoin:masterfrom
chavic:chavic/cpp-bindings

Conversation

@chavic

@chavic chavic commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Draft C++ bindings for payjoin-ffi, following the same per-language layout as the C# and Dart bindings: an optional cpp cargo feature and dispatch arm in uniffi-bindgen.rs, payjoin-ffi/cpp/ with scripts/generate_bindings.sh + contrib/test.sh, a CMake build exposing a consumable payjoin_cpp target, a .#cpp nix dev shell, and a CI workflow mirroring the other binding workflows.

Generator

There is no uniffi 0.31-compatible C++ generator upstream: NordSecurity/uniffi-bindgen-cpp tops out at v0.9.0+v0.29.4 (metadata contract v29, incompatible with our uniffi 0.31.2 / contract v30), and their uniffi 0.30 migration (NordSecurity/uniffi-bindgen-cpp#59) is unmerged. This PR pins a fork, chavic/uniffi-bindgen-cpp@830943d, which takes that PR and adds:

  • a bump to uniffi_bindgen 0.31.2 (contract v30 is shared between 0.30 and 0.31, so this is compile-only churn)
  • a lib entry point (pub fn main()) mirroring uniffi-bindgen-cs, so it can be consumed as a cargo dep by our dispatcher
  • a --skip-async library mode that drops async exports with a warning instead of failing generation (async is unsupported upstream, #51)
  • fixes for five generator bugs surfaced by payjoin-ffi's interface: object fields in records double-wrapped in shared_ptr, duplicate type definitions from the topological sort, a callback vtable initializer that omitted uniffi_clone and misordered fields (uniffi 0.30 ABI), a stubbed-out error-deref filter, and even foreign handles (uniffi requires odd ones)

The intent is to upstream the fork's commits and re-pin to a NordSecurity release when one supports uniffi 0.31; until then this stays a draft.

Parity caveat

The async API surface (save_async, replay_*_event_log_async, the *PersisterAsync interfaces) is not exposed in C++ — generation skips it. The synchronous API is complete.

Testing

nix develop .#cpp -c bash ./payjoin-ffi/cpp/contrib/test.sh generates the bindings and runs the ported python suite: 11 unit tests (URI parsing, receiver/sender persistence via an in-memory persister subclassing the callback traits, cancel flows, validation errors) and the integration tests (test_ffi_validation, test_integration_v2_to_v2 against bitcoind and the payjoin test services). All pass locally on Linux; the macOS leg relies on CI.

Disclosure: co-authored by Claude Opus

chavic added 4 commits August 20, 2026 14:49
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 payjoin#51), and
fixes several generator bugs surfaced by this crate's interface.

Both lockfiles are regenerated to include the new dependency tree.
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.
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.
Add a cpp workflow mirroring the other language binding workflows
and register the cpp binding in the aggregate payjoin-ffi test
script.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 32370913939

Coverage remained the same at 86.718%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 16458
Covered Lines: 14272
Line Coverage: 86.72%
Coverage Strength: 343.61 hits per line

💛 - Coveralls

@ValeraFinebits ValeraFinebits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a promising draft. I built the bindings and ran the tests under WSL Ubuntu 24.04:

  • C++ unit tests: 11/11 passed
  • C++ integration tests: 2/2 passed

I found three issues:

  1. The integration test returns success when no proposal is received after four polls, skipping signing, broadcast, and final assertions. This should fail after a timeout.
  2. --skip-async generates truncated callback vtables: Rust expects 40 bytes (free/clone/save/load/close), while C++ provides 16 bytes (free/clone). This is an ABI mismatch.
  3. BUILD_TESTING=OFF still requires CURL and nlohmann-json. Test dependencies and targets should be wrapped in if(BUILD_TESTING).

Overall, the synchronous bindings build and run successfully, but I think these issues should be addressed before merging.

@ValeraFinebits

Copy link
Copy Markdown
Contributor

Here are a few additional recommendations:

  1. Export the C++20 requirement through the payjoin_cpp target using target_compile_features(payjoin_cpp PUBLIC cxx_std_20).

  2. Although inherited from the Python test, catching RPC or JSON errors and returning false hides operational failures. The generated C++ trampoline already reports standard exceptions through the UniFFI error channel, so these exceptions should be allowed to propagate.

  3. Make the libcurl write callback noexcept and manage CURL resources with RAII to prevent exceptions from crossing the C boundary and avoid leaks.

  4. Enable strict, target-scoped compiler warnings and treat warnings as errors by default. Generated code can be handled separately until its warnings are cleaned up.

  5. Consider adding a Linux CI job that builds the generated wrapper and runs the full test suite under ASan and UBSan.

The first two affect correctness; the remaining items are non-blocking hardening suggestions for this draft.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants