Skip to content

Draft C++ bindings — working notes - #2

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

Draft C++ bindings — working notes#2
chavic wants to merge 4 commits into
masterfrom
chavic/cpp-bindings

Conversation

@chavic

@chavic chavic commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Working copy of the C++ bindings draft on this fork. The real review happens upstream in payjoin/rust-payjoin#1827 — this PR exists to hold the working notes, context, and the to-continue list in its comments so nothing lives only in a chat session. See the comments below.

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.
@chavic

chavic commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Status snapshot (2026-08-21)

What exists and where:

Thing Location
Upstream draft PR (the real one) payjoin/rust-payjoin#1827
This branch chavic/cpp-bindings, 4 commits on top of upstream 04e7c2db
Generator fork chavic/uniffi-bindgen-cpp, branch payjoin-v0.31, pinned at rev 830943d09ba1840e69e72caba030c03b5287cb40 in payjoin-ffi/Cargo.toml
Local fork clone ~/Workspace/_PayJoin/uniffi-bindgen-cpp (sibling dir of rust-payjoin)

The 4 commits (each passes checks independently):

  1. Add cpp feature and bindgen dispatch arm — Cargo.toml cpp feature + git dep, uniffi-bindgen.rs arm, [bindings.cpp] stanza, both lockfiles regenerated
  2. Add cpp dev shell to flakenix develop .#cpp: msrv rust, cmake, ninja, curl, nlohmann_json, bitcoind via BITCOIND_EXE
  3. Add C++ binding generation and test scaffoldpayjoin-ffi/cpp/ (scripts, CMake payjoin_cpp target, tests ported from the python suite, README/CONTRIBUTING, .gitignore), clang-format in treefmt scoped to payjoin-ffi/cpp/tests/*, codespell skip
  4. Run C++ binding tests in CI.github/workflows/cpp.yml (clone of dart.yml) + cpp prepended to BINDINGS in payjoin-ffi/contrib/test.sh

Verified green locally (Linux):

  • nix develop .#cpp -c bash ./payjoin-ffi/cpp/contrib/test.sh → 11 unit tests + test_ffi_validation + test_integration_v2_to_v2 (real bitcoind, OHTTP relay/directory roundtrip, all four callback traits exercised), ~12s
  • nix fmt -- --ci, codespell, cargo clippy --all-targets --keep-going --all-features -- -D warnings
  • contrib/update-lock-files.sh clean: the fork resolves under -Z minimal-versions and MSRV 1.85

NOT yet verified: the macOS CI leg (AppleClang C++20, dylib rpath) — first data point is payjoin#1827's checks.

@chavic

chavic commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Generator fork: what it contains and why each piece exists

Upstream NordSecurity/uniffi-bindgen-cpp tops out at v0.9.0+v0.29.4 = uniffi metadata contract v29. payjoin-ffi is on uniffi 0.31.2 = contract v30, so stock releases cannot read our library. Contract v30 is shared by uniffi 0.30 and 0.31.x — that equivalence is what makes the whole approach work.

The fork branch payjoin-v0.31 = upstream PR #59 (Larkooo's update-0.30, taken as-is, not rebased) plus these commits:

  1. Bump uniffi_bindgen to 0.31.2 — compile-only churn; 0.30→0.31 kept the bindgen API.
  2. Add library entry point and --skip-async mode[lib] name = "uniffi_bindgen_cpp" + pub fn main() mirroring uniffi-bindgen-cs (upstream is binary-only, so the dispatch-arm pattern needed this). --skip-async reimplements find_cis/generate_bindings with a metadata-level filter dropping async Func/Constructor/Method/TraitMethod items (warning per skip); bails on UDL-defined interfaces (payjoin-ffi is proc-macro only).
  3. Fix smart-ptr handling for object and error fields — two real codegen bugs:
    • needs_smart_ptr_wrap treated any std::shared_ptr-labelled field as recursion-wrapped → make_shared<T>(shared_ptr<T>) double-wrap for object fields in records. Now returns false for natively-shared_ptr types.
    • PR Handle fallable receiver payjoin/rust-payjoin#59 stubbed the deref filter to "" — but error enums are stored as shared_ptr while their converters write/size by value, so field access needs *. Restored error-aware deref (threads ci through ~20 template call sites), made err_tmpl.cpp wrap-aware like enum_tmpl.cpp.
  4. Dedupe sorted type definitions by name — topological sort collected dependency nodes via ci.get_type() which don't compare == to local type instances → every object referenced by a record/enum was emitted twice (duplicate C++ definitions). Deduped by emitted name.
  5. Match callback vtable and handle ABI to uniffi 0.30 — the ABI-critical one:
    • Rust vtable layout is {uniffi_free, uniffi_clone, methods...} (see uniffi_macros-0.31.2/src/export/callback_interface.rs ~line 99). The C++ initializer listed methods first and omitted uniffi_clone entirely → C++20 designator-order compile error, and would misroute every callback. uniffi_clone implemented as handle_map.insert(handle_map.at(h)).
    • Foreign handles must be odd and non-zero (low bit distinguishes them from leaked Rust Arc pointers — uniffi_core-0.31.2/src/ffi/handle.rs). C++ HandleMap issued 0,1,2,… → handle 0 was lifted as a null Rust Arc → segfault on first callback. Now starts at 1, steps by 2.
  6. Match toml version range to uniffi_bindgentoml::Value crosses the BindingGenerator trait boundary, so the fork must resolve the same toml version as uniffi_bindgen under both lockfile resolutions. uniffi_bindgen uses toml = ">=0.8, <=0.9" (resolves 0.8.0 minimal / 0.9.x recent); the fork's toml = "0.9" split the graph under -Z minimal-versions (E0053 trait-type mismatch). Fix: use uniffi_bindgen's exact range. Remember this if bumping either dep.

Base-choice note: PR payjoin#59 branches from f02896c (0.7.3-era, pre-payjoin#56/0.29 and pre-payjoin#64/external-types). The gate-first plan said rebase onto NordSecurity main after feasibility passed — that rebase has not been done; the pin is the un-rebased branch. Conflicts will concentrate in bindgen/src/bindings/cpp/templates/*.

@chavic

chavic commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Gotchas and design notes to keep on hand

Regeneration / pin-bump workflow (any time the fork rev changes):

# 1. edit rev = "…" in payjoin-ffi/Cargo.toml (the uniffi-bindgen-cpp dep)
# 2. regenerate BOTH lockfiles (feeds the msrv + minimal-versions CI jobs):
bash contrib/update-lock-files.sh
# 3. full check:
nix develop .#cpp -c bash ./payjoin-ffi/cpp/contrib/test.sh
nix fmt -- --ci && codespell

Generated-code layout (never commit these; all gitignored):

  • payjoin-ffi/cpp/src/{payjoin.hpp, payjoin.cpp, payjoin_scaffolding.hpp} (~22k lines)
  • payjoin-ffi/cpp/lib/libpayjoin_ffi.{so,dylib}, payjoin-ffi/cpp/build/

Dispatch convention: UNIFFI_BINDGEN_LANGUAGE=cpp cargo run --bin uniffi-bindgen -- --library <cdylib> --out-dir cpp/src/ --skip-async. --library is a clap bool in the generator; the path lands in the positional source arg — same convention the C# script relies on. The env var (not --language) is used because the external generator's clap owns the remaining argv.

Generated API shapes (differ from python, matter when porting more tests):

  • Objects: std::shared_ptr<T>, static constructors are T::init(...) (e.g. ReceiverBuilder::init)
  • Non-flat enums: struct with kVariantName member-structs over a private std::variant; access via .get_variant() + std::holds_alternative / std::get_if
  • Errors: struct SomeError : std::runtime_error with namespaced variants (payjoin::input_pair_error::InvalidOutPoint) thrown by dynamic type via throw_underlying() → catchable by variant. Variant payload fields are v1, v2, …
  • Foreign traits: pure-virtual structs (JsonReceiverSessionPersister with save/load/close), pass as std::make_shared<Subclass>()
  • Option<Arc<T>> ⇒ nullable shared_ptr (e.g. receiver CancelTransition::save returns nullptr for None)
  • payjoin::example_url() returns std::string, not a Url object (tripped us once)
  • Record fields holding records are wrapped: TxIn.previous_output is shared_ptr<OutPoint> (recursion machinery from PR Handle fallable receiver payjoin/rust-payjoin#59's "recursive types" commit)

Test infra notes:

  • Integration tests use services->fetch_ohttp_keys() from _test-utils instead of porting python's OHTTP-keys-over-CONNECT client — deliberate, avoids an HTTP CONNECT implementation in C++
  • tests/http.hpp: libcurl POST; test-services cert is DER → converted to PEM in-memory → CURLOPT_CAINFO_BLOB
  • JSON in tests via nlohmann_json (nix-provided, test-only dep); RpcClient::call(method, vector<optional<string>>) takes each param as a JSON-encoded string (match python's json.dumps per-param convention)
  • Test harness is a bare run_test(name, fn) + EXPECT macro in tests/utils.hpp, exit-code driven, per the no-framework decision

Fragility worth knowing: the fork's wrap/deref decisions are made on string type labels (starts_with("std::shared_ptr<")), not on uniffi_meta::Type variants. Held up against payjoin-ffi's full surface, but a proper fix (dispatch on Type) would be the clean version to upstream.

Cosmetic known-wart: exception what() for some errors surfaces as generic std::exception text in catch-sites typed on std::exception (seen with UrlParseError in the first smoke test). Not blocking; worth a look during fork upstreaming.

@chavic

chavic commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

To continue — ordered checklist

Before undrafting upstream payjoin#1827:

  • Check the macOS CI leg on #1827 — only unverified surface. Likely failure modes: AppleClang C++20 quirks in the 22k-line generated TU, dylib loading (CMake sets CMAKE_BUILD_RPATH to cpp/lib; may need INSTALL_RPATH/@loader_path treatment on macOS)
  • Rebase the fork onto NordSecurity main (picks up Persist data in default directory payjoin/rust-payjoin#64 external types, GET /bip21 payjoin-client endpoint for a new one payjoin/rust-payjoin#61, Rentroduce fmt rustfmt action payjoin/rust-payjoin#57/Track seen inputs in payjoin-client payjoin/rust-payjoin#58 reserved-name fixes we currently lack). Conflicts concentrate in bindgen/src/bindings/cpp/templates/*; resolve toward main's structure, reapply the 0.30-contract changes. Then bump the rev pin here + bash contrib/update-lock-files.sh
  • Make the fork's own fixture suite run: fixtures/Cargo.toml still pins mozilla example crates at tag = "v0.28.3" — bump to v0.31.2 tags and run test_bindings.sh, especially callbacks + trait-methods fixtures. Right now the payjoin test suite is the only validation of the fork
  • Decide maintainer story for the fork pin — upstream may prefer the pin under a payjoin-org fork rather than a personal one (precedent: JS bindings pin github:spacebear21/uniffi-bindgen-react-native#<rev>, so personal pins have precedent)

Fork upstreaming (parallel track):

  • Open PRs to NordSecurity, roughly: (a) the five bug fixes that stand alone against Handle fallable receiver payjoin/rust-payjoin#59 (vtable ABI + odd handles are hard correctness bugs anyone on 0.30 will hit), (b) lib entry point, (c) --skip-async. Coordinate with the stalled Handle fallable receiver payjoin/rust-payjoin#59 — maybe offer to help land it
  • Watch for a NordSecurity release targeting uniffi 0.30/0.31 → re-pin to their tag, drop the fork, undraft
  • Consider real async support (their #51) as the long-term fix for the C++ parity caveat — until then C++ has no save_async/*PersisterAsync

Nice-to-haves flagged during review of our own work:

  • Convert the fork's label-string wrap/deref logic to dispatch on uniffi_meta::Type (robustness; good upstream material)
  • Error what() text polish (generic std::exception for some errors)
  • Publishing story for the C++ package (README row says "draft, not yet published" — cpp has no artifact/pack pipeline; if it ever ships, model on csharp/RELEASING.md)
  • If upstream wants CHANGELOG parity: python/dart keep per-language CHANGELOGs, cpp has none yet

Session-restore one-liners:

git checkout chavic/cpp-bindings                       # this branch
nix develop .#cpp -c bash ./payjoin-ffi/cpp/contrib/test.sh   # full suite (~12s after warm build)
# fork lives at ~/Workspace/_PayJoin/uniffi-bindgen-cpp, branch payjoin-v0.31

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 32458769663

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.69 hits per line

💛 - Coveralls

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.

2 participants