Skip to content

Add UniFFI Java bindings for payjoin-ffi - #1882

Open
ram0verflow wants to merge 5 commits into
payjoin:masterfrom
ram0verflow:java-bindings-v2
Open

ram0verflow wants to merge 5 commits into
payjoin:masterfrom
ram0verflow:java-bindings-v2

Conversation

@ram0verflow

@ram0verflow ram0verflow commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Addresses #1860.

Adds generated Java bindings for payjoin-ffi under payjoin-ffi/java.

The bindings are generated with uniffi-bindgen-java and use Java's Foreign Function & Memory API, requiring JDK 22+ without adding a Java runtime dependency such as JNA.

Generator

payjoin-ffi currently uses UniFFI 0.31.x.

uniffi-bindgen-java 0.4.2 can read that metadata, but its error templates emit invalid Java for several payjoin-ffi types. The relevant fixes already exist on the newer upstream generator line, which requires UniFFI 0.32.

For now, generation uses canonical IronCoreLabs/uniffi-bindgen-java at the exact commit referenced by its signed 0.4.2 tag:

559bd72e680e0be7feda6ac3a93819376db030d9

A small patch kept in payjoin-ffi/java/patches/ backports the required template fixes: 13 insertions / 5 deletions across ErrorTemplate.java and macros.java.

The generator source is not vendored and there is no dependency on a contributor-owned fork. The generation script fetches the pinned upstream commit into a temporary checkout, applies the patch, builds the generator, and discards the checkout.

IronCoreLabs/uniffi-bindgen-java#68 tracks the 0.31-compatible generator issue.

When payjoin-ffi eventually moves to UniFFI 0.32, the intention is to replace this temporary patched-generator path with an appropriate upstream release and validate that migration by regenerating and running this Java test suite.

Generated Java sources and native libraries are not committed.

Tests

The Java target includes coverage for:

  • BIP21 / Payjoin URI parsing, including percent-encoded Payjoin endpoints
  • sender builder construction and exact FFI validation variants
  • synchronous sender/receiver persistence and event-log replay
  • Rust-driven protocol cancellation and closeSession
  • persistence callback failure propagation
  • asynchronous persistence using CompletableFuture
  • explicit Executor dispatch
  • async callbacks remaining pending until the Java callback completes

It also includes a full local BIP77 v2↔v2 integration test using the existing payjoin-test-utils infrastructure:

Java sender → local OHTTP relay/directory → Java receiver → proposal → sender finalize/broadcast → regtest bitcoind

The test asserts that the resulting transaction spends inputs from both sender and receiver wallets.

Locally on macOS/aarch64:

  • 34 Java tests pass
  • development generation with _test-utils passes
  • production generation with PAYJOIN_FFI_FEATURES= produces and compiles the production API
  • the BIP77 v2↔v2 round trip passes against local test infrastructure
  • generated Java compiles with --release 22

CI targets Linux and macOS. Windows is currently unvalidated for this Java target.

Scope

This PR does not include:

  • Maven publication
  • wallet integration
  • UniFFI 0.32 migration
  • Windows CI

Those can be handled separately once the Java binding target itself is established.

AI assistance

Claude Code (Opus5) substantially assisted with implementation, tests, documentation, and branch preparation. ChatGPT (GPT-6-Astra) assisted with architecture review, reviewing the branch against the Kotlin binding review history, and drafting this PR description. I reviewed the resulting changes and exercised the generated bindings and BIP77 integration locally.

Pull Request Checklist

Add a [bindings.java] section to uniffi.toml so uniffi-bindgen-java
can find its package name and cdylib name the same way the existing
Kotlin/Python sections already do.

The close/closeSession rename mirrors [bindings.kotlin.rename]
exactly, for the same reason: AutoCloseable.close() would otherwise
collide with the protocol's own close() on these types. Keeping the
two rename tables identical also keeps the Java and Kotlin binding
APIs shaped the same way for the same underlying types.
Gradle project scaffolding for the Java target: build.gradle.kts,
settings.gradle.kts, gradle.properties, the wrapper, and
scripts/generate_bindings.sh, which (re)generates src/main/java/
and lib/ before every build, the same way the Kotlin/Python
targets' own scripts work. No generated sources are committed.

Generated bindings use Java's Foreign Function & Memory API
(java.lang.foreign, finalized in JDK 22 - JEP 454) rather than JNA,
so this target has zero runtime dependencies. build.gradle.kts uses
`--release 22` rather than sourceCompatibility/targetCompatibility
so a build running under a newer JDK still fails loudly if it
accidentally depends on a post-22 API a real JDK 22 consumer
wouldn't have.

payjoin-ffi's UniFFI 0.31.x metadata needs uniffi-bindgen-java's
still-unreleased 0.5.0 fixes for its Java error-type templates (see
IronCoreLabs/uniffi-bindgen-java#68); the latest tagged release,
0.4.2, emits Java that doesn't compile for this crate. Rather than
depend on anyone's fork or wait on that release,
generate_bindings.sh builds patched-canonical-source instead: the
exact upstream commit the 0.4.2 tag points to, with the fix
backported as the small, git-apply'able patch under patches/. Kotlin
generates through uniffi's own official, in-tree CLI (payjoin-ffi's
own uniffi-bindgen binary target, sharing the workspace's build
cache); uniffi-bindgen-java is a third-party crate with no such
official Java backend to call into instead, so this target has to
build its own generator from source, cached by pinned-commit and
patch hash so that cost is paid once, not on every run.

The patch is generated with `git diff -U0` (zero context lines) and
applied with `git apply --unidiff-zero`: since it's always applied
against this one pinned commit, dropping context is safe and keeps
upstream's own incidental whitespace in the surrounding template
text out of the patch file. See README.md's "Generator provenance"
section for the full reasoning, the exact pinned commit, and the
migration plan once payjoin-ffi moves to UniFFI 0.32.
A focused Java port of the Kotlin bindings' unit test suites: URI
parsing, sender/receiver builder validation, session persistence,
and session cancellation - not a mechanical line-for-line port of
every existing test.

Validation tests for InputPair assert the exact nested exception
variant (e.g. InputPairException.InvalidOutPoint vs.
FfiValidationException.AmountOutOfRange), not just the shared
superclass: a too-long txid and a valid txid with an out-of-range
amount both throw InputPairException, but for different underlying
reasons, and only the exact variant tells them apart.

Cancellation is driven through the real protocol transitions
(cancel() -> save() -> ... -> closeSession() -> save()) rather than
by calling the persister's closeSession() helper directly, so the
tests actually prove Rust invokes it, not just that the helper
itself works. The receiver and sender paths turn out to be
asymmetric: the sender always already holds a signed original PSBT
and so always gets a real pending-fallback object back, while a
receiver that has never received anything has no fallback and
closes immediately from cancel().save() itself - both are asserted
directly rather than assumed.

PersistenceFailureTest and the async half of AsyncPersistenceTest
extend this to storage failures and the CompletableFuture-based
async persister API: a failure on a specific persister operation
(save/load/close) propagates as a real, specifically-typed
exception, and a gated persister proves the returned future
genuinely stays pending until the persister's own future resolves,
not fire-and-forget.
A close Java port of the Kotlin bindings' IntegrationTests.kt: a
full BIP 77 v2<->v2 round trip against payjoin-test-utils' in-process
directory, OHTTP relay, and regtest bitcoind, receiver and sender
both driven through the generated Java API, ending in a broadcast
transaction that spends coins from both wallets. No public
production infrastructure.

TestHttp is a near-direct port of the Kotlin equivalent. JsonRpc is
a small hand-rolled recursive-descent parser for bitcoind's RPC
responses, since the JDK has no built-in JSON parser and adding a
JSON library dependency isn't warranted for this test's simple,
fixed response shapes.

Every generated type here genuinely implements AutoCloseable, so
resource cleanup uses plain try-with-resources throughout (unlike
the Kotlin bindings, where some types are only Disposable). The
three RPC-backed callbacks (mempool acceptance, input/script
ownership) only return a legitimate false for a cleanly-parsed
negative answer from bitcoind; an RPC or transport failure instead
throws, so a broken check fails the test instead of silently
reading as a negative answer.
flake.nix: a java devShell alongside the existing kotlin one, adding
what generated Java needs beyond it - jdk25 (the Foreign Function &
Memory API is only finalized from JDK 22 onward) and Rust new enough
for both payjoin-ffi's own MSRV and the pinned generator's slightly
newer one, deliberately one toolchain rather than two on the same
PATH. A rust-toolchain.toml file has no effect inside a nix devShell
(there is no rustup here for it to redirect), which is why this
needed a real toolchain choice rather than relying on one.

.github/workflows/java.yml mirrors kotlin.yml's structure exactly:
build and test through the nix devShell on Linux and macOS, plus a
separate step compiling production bindings (PAYJOIN_FFI_FEATURES
unset to empty) to catch anything that only the test-utils feature
set was masking. This matrix targets Linux and macOS, not a claim
that either has actually run this yet - see payjoin-ffi/java/
README.md's "Platforms" section for what has and hasn't actually
been verified where.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 34954587323

Coverage remained the same at 86.646%

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: 16549
Covered Lines: 14339
Line Coverage: 86.65%
Coverage Strength: 341.85 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