Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/java.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Build and Test Java
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 java dev shell, so changes to
# the flake change this workflow's environment.
- flake.nix
- flake.lock
- .github/workflows/java.yml

jobs:
build-java-and-test:
name: "Build and test java"
# This matrix targets Linux and macOS; Windows is not covered here. That's a target, not a
# verification claim by itself - see payjoin-ffi/java/README.md's "Platforms" section for
# what has and hasn't actually been run where.
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-26.04, macos-latest]
env:
RUSTUP_TOOLCHAIN: 1.85.0
steps:
- name: Checkout
uses: actions/checkout@v6
- name: "Install Rust 1.85.0"
uses: dtolnay/rust-toolchain@1.85.0
- name: "Use cache"
uses: Swatinem/rust-cache@v2
with:
shared-key: msrv-workspace
- name: Set up nix
uses: ./.github/actions/setup-nix
- name: "Build and test"
run: nix develop .#java -c bash ./payjoin-ffi/java/contrib/test.sh
- name: "Compile production bindings"
run: |
nix develop .#java -c env PAYJOIN_FFI_FEATURES= bash ./payjoin-ffi/java/scripts/generate_bindings.sh
nix develop .#java -c bash -c 'cd payjoin-ffi/java && ./gradlew --no-daemon compileJava'
32 changes: 32 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,37 @@
BITCOIND_SKIP_DOWNLOAD = 1;
};

# Two things generated Java needs that the kotlin shell above doesn't:
#
# - jdk25, not jdk21: generated bindings use the Foreign Function & Memory API, finalized
# (no longer preview) only from JDK 22 onward - see payjoin-ffi/java/README.md.
# - rustVersions.stable, not rustVersions.msrv: payjoin-ffi/java/scripts/generate_bindings.sh
# builds a pinned uniffi-bindgen-java commit whose own rust-toolchain.toml/README declare
# a newer MSRV (1.87.0) than this workspace's (1.85.0). A `rust-toolchain.toml` file only
# redirects rustup-wrapped `cargo`; inside this shell `cargo` is the nixpkgs derivation
# directly; there is no rustup here for a toolchain file (or RUSTUP_TOOLCHAIN) to
# redirect. rustVersions.stable ("latest stable" - see its definition above) covers both
# MSRVs at once, so this shell deliberately uses one Rust toolchain for both payjoin-ffi
# and the generator rather than juggling two on the same PATH.
javaDevShell = pkgs.mkShell {
name = "java-dev";
packages =
with pkgs;
[
rustVersions.stable
jdk25
python3
bzip2
]
++ lib.optionals pkgs.stdenv.isLinux [
pkg-config
openssl
clang
];
BITCOIND_EXE = pkgs.lib.getExe' pkgs.bitcoind "bitcoind";
BITCOIND_SKIP_DOWNLOAD = 1;
};

# Rust toolchain for the python dev shell: msrv pinned to match
# payjoin-ffi/python build requirements, with per-arch targets added
# so cargo can build artifacts under nix for payjoin-ffi/python/scripts/generate_bindings.sh
Expand Down Expand Up @@ -522,6 +553,7 @@
csharp = csharpDevShell;
dart = dartDevShell;
kotlin = kotlinDevShell;
java = javaDevShell;
};
formatter = treefmtEval.config.build.wrapper;
checks =
Expand Down
15 changes: 15 additions & 0 deletions payjoin-ffi/java/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Generated UniFFI Java (one file per class/interface/enum/record, plus package-info.java)
src/main/java/org/

# Native library copied by scripts/generate_bindings.sh
/lib/*.so
/lib/*.dylib
/lib/*.dll

# Gradle
.gradle/
build/
local.properties

.idea/
.DS_Store
35 changes: 35 additions & 0 deletions payjoin-ffi/java/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Contributing to the Payjoin Java Bindings

Java bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/), generated from `payjoin-ffi`
with `uniffi-bindgen-java`. This document covers building from source and running tests.

## Development

```shell
git clone https://github.com/payjoin/rust-payjoin.git
cd rust-payjoin/payjoin-ffi/java
bash ./scripts/generate_bindings.sh
./gradlew test
```

Generation builds a small locally-patched copy of `uniffi-bindgen-java` from the exact canonical
upstream commit the `0.4.2` tag points to (see README.md "Generator provenance" for why and
`payjoin-ffi/java/patches/` for the patch itself), cached under
`$CARGO_HOME/uniffi-bindgen-java-<rev>-<patch-hash>/`, then runs it against `payjoin-ffi`'s
compiled library using the in-tree `[bindings.java]` section of `payjoin-ffi/uniffi.toml`. By
default, development generation enables `_test-utils`. For production bindings, set
`PAYJOIN_FFI_FEATURES` to empty:

```shell
PAYJOIN_FFI_FEATURES= bash ./scripts/generate_bindings.sh
```

Protocol `close` is renamed to `closeSession` only in `[bindings.java.rename]` in
`payjoin-ffi/uniffi.toml`, so it does not clash with `AutoCloseable.close()` - the same rename
`[bindings.kotlin.rename]` already applies for Kotlin.

With nix, `nix develop .#java` provides Rust (new enough for both `payjoin-ffi` and the pinned
generator - see flake.nix's `javaDevShell` comment for why one toolchain covers both), JDK 25,
Python 3, and `BITCOIND_EXE` (from `nixpkgs`, with `BITCOIND_SKIP_DOWNLOAD=1` so nothing is
downloaded), and is what CI uses. Without nix, see README.md "Requirements" for what needs to be
on `PATH` yourself; `corepc-node` downloads `bitcoind` on first test run in that case.
171 changes: 171 additions & 0 deletions payjoin-ffi/java/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Payjoin Java Bindings

Java bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/), generated from `payjoin-ffi`
with [`uniffi-bindgen-java`](https://github.com/IronCoreLabs/uniffi-bindgen-java). These bindings
implement [BIP 78](https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki) and
[BIP 77](https://github.com/bitcoin/bips/blob/master/bip-0077.md).

## Stability

**Early / not release-ready.** The Java API is generated, not hand-maintained - if something reads
awkwardly from Java, the fix belongs in the generator (or its config), not a patch to the checked
output. Nothing here is published anywhere (see "Generator provenance" below for why, and no,
Maven publication is not part of this).

**Platforms:** CI targets Linux and macOS (`.github/workflows/java.yml`). **Windows is currently
unvalidated** - neither the generator build, the FFM native-library loading path, nor
`payjoin-test-utils`' bitcoind integration has been exercised on Windows for this target. Treat
Windows as untested, not merely "probably fine," until CI or a real run there says otherwise.

Everything else in this document describing a passing result (compiling, tests, generation) was
run locally on macOS/aarch64, not yet through this repository's own CI - see this PR's own
description for exactly what was run and when.

## Requirements

Without nix, generating and testing this target needs, on `PATH`:

* **A Rust toolchain new enough to build the pinned generator** - see "Generator provenance"
below. Its own MSRV is 1.87.0, newer than this workspace's own MSRV (1.85.0) used to build
`payjoin-ffi` itself; `scripts/generate_bindings.sh` does not switch toolchains for you (see
that script's own comment on why not), so make sure whatever `cargo`/`rustc` is active satisfies
the generator's MSRV before running it.
* **JDK 22+**: `javac` and `jar`. Generated bindings use Java's
[Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html)
(Project Panama), not JNA - JDK 22 is the first release where that API is finalized rather than
preview (JEP 454), which is why this floor is higher than Kotlin's (JDK 21+). No
`--enable-preview` flag is needed for the FFM API itself on 22+.
* **Python 3** - `scripts/generate_bindings.sh` uses it to hash the local patch file (for its
generator build cache key) and to parse Cargo's JSON build output and locate the compiled
native library reliably (see "Generating bindings" below).
* **Git** - `scripts/generate_bindings.sh` fetches and verifies the pinned generator commit with
it directly (see "Generator provenance" below), not through Cargo's own `git` dependency
support.
* **The Gradle wrapper** (`./gradlew`, checked in) - no separately-installed Gradle needed.

Inside `nix develop .#java`, all of the above (Rust, JDK 25, Python 3, Git) are already on `PATH`.

At runtime, the JVM must additionally allow restricted native-method access:
`--enable-native-access=ALL-UNNAMED` for classpath-based apps (what `build.gradle.kts`'s `test`
task sets), or `--enable-native-access=your.module.name` for a JPMS module. Native `payjoin_ffi`
is loaded via `System.load`/`System.loadLibrary` (the JDK's own mechanism, not a third-party
library) - see "Native library loading" below.

## Generator provenance

`payjoin-ffi` is on UniFFI 0.31.x. `uniffi-bindgen-java`'s published releases split cleanly on
that line:

* **0.4.2** (latest tagged release) reads UniFFI 0.31 metadata, but its Java error-type templates
emit code that doesn't compile: invalid multiple inheritance
(`extends Foo, AutoCloseable` - Java classes can only extend one class), package-private
`close()` that doesn't satisfy the `AutoCloseable` interface, and an empty field identifier for
unnamed tuple/newtype fields (`this.);` instead of `this.v1;`). See
[IronCoreLabs/uniffi-bindgen-java#68](https://github.com/IronCoreLabs/uniffi-bindgen-java/issues/68),
which tracks a 0.31-compatible release for exactly this - open, unanswered at the time of
writing, and this work does not block on it.
* **0.5.x** (unreleased; `main` is versioned `0.5.0` upstream) fixes exactly this, but requires
UniFFI 0.32, which `payjoin-ffi` is not on and this work does not migrate it to.

Until `payjoin-ffi` moves to UniFFI 0.32 and can consume a released upstream `uniffi-bindgen-java`
0.5.x directly, `scripts/generate_bindings.sh` builds the generator itself from canonical
upstream, patched locally:

* Source: `https://github.com/IronCoreLabs/uniffi-bindgen-java`, pinned to the exact commit the
`0.4.2` tag points to - `559bd72e680e0be7feda6ac3a93819376db030d9` (`Nullness annotations (#62)`).
Pinned by commit SHA, not the tag name, so a future upstream re-tag can't silently change what
this builds.
* Patch: `payjoin-ffi/java/patches/0001-error-autocloseable-and-destroy-fields.patch` - a
`git apply --unidiff-zero`-able diff touching only `src/templates/ErrorTemplate.java` and
`src/templates/macros.java` (13 insertions, 5 deletions). Generated with zero context lines
(`git diff -U0`, safe since it's always applied against this one pinned commit) so upstream's
own incidental whitespace in the surrounding template text never ends up embedded in the patch
file itself. This is a direct backport of the fix already on upstream's unreleased `main` (see
above) for exactly the three `0.4.2` failures listed above, nothing else - not a
payjoin-specific hack in a generic template.
* No fork, nothing vendored: the generator's own source is never committed here, only the small
patch is. `scripts/generate_bindings.sh` fetches the pinned commit into a scratch directory,
applies the patch, builds, and discards the scratch checkout - see that script for the exact
mechanics and why the cache key includes a hash of the patch file.

**Migration plan:** this patched-canonical-source build is intended to be temporary. Once
`payjoin-ffi` moves to UniFFI 0.32 (tracked separately from this work), `scripts/generate_bindings.sh`
should switch to installing a released upstream `uniffi-bindgen-java` 0.5.x tag directly, dropping
both the pinned commit and the local patch. The patch's fix is already on upstream `main` as of
this writing, which is *evidence* that switch should be small - but not a guarantee: whatever
0.5.x actually ships by the time payjoin-ffi is on UniFFI 0.32 could differ from `main` today, and
the UniFFI 0.32 migration itself may touch this directory in ways unrelated to the generator swap.
Re-running `scripts/generate_bindings.sh` and the full Java test suite (unit tests + the BIP77
integration test) after the migration is what should actually confirm it, not an assumption made
here.

## Generating bindings

```shell
cd payjoin-ffi/java
bash ./scripts/generate_bindings.sh
./gradlew test
```

Or `bash ./contrib/test.sh` from this directory (uses `Cargo-recent.lock`, matching the other
binding targets' contrib scripts).

Generated sources are not committed - `scripts/generate_bindings.sh` is the reproducible build
step that (re)creates `src/main/java/org/` and `lib/` before every build or test run, the same
way the Kotlin/Python targets work. `JAVA_BINDGEN_REV`/`JAVA_BINDGEN_GIT_URL` environment
variables override the pinned generator commit/source for local experimentation; leave them unset
for the default, reproducible build, which does not depend on anyone's personal fork.
`patches/0001-error-autocloseable-and-destroy-fields.patch` is still applied on top of whatever
commit is checked out either way - an override only makes sense pointed at another 0.4.2-era
commit the patch still `git apply`s cleanly against (upstream 0.5.0 already contains this fix, so
pointing there fails the patch step rather than silently doing nothing).

By default, development generation enables `_test-utils` (needed for the BIP77 integration test
below). For production bindings, set `PAYJOIN_FFI_FEATURES` to empty:

```shell
PAYJOIN_FFI_FEATURES= bash ./scripts/generate_bindings.sh
```

## Native library loading

Generated code resolves the native library through
`System.getProperty("uniffi.component.payjoin.libraryOverride")` first (an absolute path, loaded
via `System.load`), falling back to `System.loadLibrary("payjoin_ffi")` (searches
`java.library.path`) if that property isn't set - this is UniFFI's own convention, identical in
spirit to the Kotlin bindings' JNA `libraryOverride` property, just backed by the JDK's own FFM
loader instead of JNA. `build.gradle.kts`'s `test` task sets the override to
`lib/libpayjoin_ffi.{dylib,so,dll}` (populated by `scripts/generate_bindings.sh`) so tests find the
library without needing `java.library.path` configured separately.

## Tests

`src/test/java/org/payjoindevkit/` - a focused Java port of the Kotlin/Python FFI test suites
(URI parsing, sender builder construction, persistence, basic validation), not a mechanical
line-for-line port of every existing test.

`BIP77IntegrationTest.java` drives a complete v2↔v2 round trip - local in-process payjoin
directory, local OHTTP relay, real regtest `bitcoind` (all from `payjoin-test-utils`, the same
test infrastructure `payjoin-ffi/kotlin`'s `IntegrationTests.kt` uses), receiver and sender both
through the generated Java API - and asserts the final broadcast transaction spends coins from
both wallets. No public production infrastructure: everything runs locally against in-process
test services.

## Async / callbacks

Generated async methods return `java.util.concurrent.CompletableFuture<T>`, not
`kotlinx.coroutines`/`suspend` (the Kotlin bindings' model) - each also gets a second overload
taking an explicit `java.util.concurrent.Executor` for where callbacks run. Callback interfaces
(session persisters, `IsScriptOwned`, `CanBroadcast`, etc.) are plain Java interfaces invoked
synchronously on the calling thread via an FFM upcall stub - the same threading model UniFFI's
other bindings use, just backed by `java.lang.foreign` instead of JNA.

**Verified:** every async type and method (`JsonReceiverSessionPersisterAsync`,
`JsonSenderSessionPersisterAsync`, every `saveAsync`/`*Async` overload) compiles cleanly as part
of the full 480-file generated API - this was checked directly (`javac` against every generated
source, not sampled). **Not verified:** actual runtime behavior of the async persister path
(`CompletableFuture` completion, the `Executor` overload, cancellation) - this target's tests,
including the full BIP77 v2↔v2 integration test, only exercise the synchronous persister API,
which is what the integration test needs and is now proven correct end to end at runtime. The
async path compiling is evidence it's not structurally broken, not evidence it's runtime-correct;
treat it as an untested surface until someone adds coverage for it.
55 changes: 55 additions & 0 deletions payjoin-ffi/java/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
plugins {
`java-library`
}

repositories {
mavenCentral()
}

dependencies {
// Generated bindings use Java's Foreign Function & Memory API (java.lang.foreign) directly -
// no JNA, no Kotlin coroutines, no runtime dependency of any kind. See README.md.
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

// README.md: java.lang.foreign (Project Panama) was finalized (no longer preview) in JDK 22
// (JEP 454), which is the floor the generator itself declares. `--release 22`, not
// `sourceCompatibility`/`targetCompatibility`: those two only set the bytecode/language level,
// they don't stop javac compiling against APIs added to the JDK *after* 22 when Gradle itself
// happens to run under a newer one (25, here) - `--release` additionally compiles against that
// older release's own API signature, so a build that only works because it's running under 25
// fails loudly instead of shipping something that breaks for a consumer on a real JDK 22.
//
// Trusts whatever JDK started Gradle (must be 22+) rather than a `toolchain{}` block, which
// requests an exact major version and requires network auto-provisioning (a foojay-resolver-style
// plugin, not added here) to find one you don't already have installed - same tradeoff the Kotlin
// bindings' build.gradle.kts already makes for its own JDK 21 floor.
tasks.withType<JavaCompile>().configureEach {
options.release.set(22)
}

val nativeLibraryOverride = layout.projectDirectory.dir("lib").asFile.let { libDir ->
val os = System.getProperty("os.name").lowercase()
val nativeName = when {
os.contains("mac") || os.contains("darwin") -> "libpayjoin_ffi.dylib"
os.contains("win") -> "payjoin_ffi.dll"
else -> "libpayjoin_ffi.so"
}
libDir.resolve(nativeName).takeIf { it.exists() }
}

tasks.test {
useJUnitPlatform()
// The FFM API gates native calls behind the JDK's restricted-methods check (JEP 454) - see
// "JDK/runtime requirements" in README.md. ALL-UNNAMED is correct here because tests run on
// the classpath (unnamed module), not as a named JPMS module.
jvmArgs("--enable-native-access=ALL-UNNAMED")
if (nativeLibraryOverride != null) {
inputs.file(nativeLibraryOverride)
// Same "uniffi.component.<namespace>.libraryOverride" convention the generated
// NamespaceLibrary.findLibraryName() reads - see scripts/generate_bindings.sh.
systemProperty("uniffi.component.payjoin.libraryOverride", nativeLibraryOverride.absolutePath)
}
}
15 changes: 15 additions & 0 deletions payjoin-ffi/java/contrib/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
cd "$REPO_ROOT"
source contrib/lockfile.sh
use_lockfile Cargo-recent.lock

cd "$REPO_ROOT/payjoin-ffi/java"

echo "==> Generating FFI bindings..."
bash ./scripts/generate_bindings.sh

echo "==> Running Java tests..."
./gradlew --no-daemon test
1 change: 1 addition & 0 deletions payjoin-ffi/java/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.gradle.jvmargs=-Xmx1g
Binary file not shown.
8 changes: 8 additions & 0 deletions payjoin-ffi/java/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading