From ea4040a2f84a0819f6bf238c7ea1e0447c33c598 Mon Sep 17 00:00:00 2001 From: ram0verflow Date: Tue, 15 Sep 2026 00:25:46 +0530 Subject: [PATCH 1/5] payjoin-ffi: configure Java bindings 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. --- payjoin-ffi/uniffi.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml index f2be18d49..50ebbcdf3 100644 --- a/payjoin-ffi/uniffi.toml +++ b/payjoin-ffi/uniffi.toml @@ -12,6 +12,22 @@ cdylib_name = "payjoin_ffi" "JsonSenderSessionPersister.close" = "closeSession" "JsonSenderSessionPersisterAsync.close" = "closeSession" +[bindings.java] +package_name = "org.payjoindevkit" +cdylib_name = "payjoin_ffi" + +# Same reasoning as [bindings.kotlin.rename] above: AutoCloseable.close() drops the native +# handle, Payjoin also exports protocol close() on these types, so only the protocol methods +# are renamed to avoid a collision. Same package name, so keeping this identical to +# [bindings.kotlin.rename] also keeps the two bindings' API shapes consistent. +[bindings.java.rename] +"ReceiverPendingFallback.close" = "closeSession" +"SenderPendingFallback.close" = "closeSession" +"JsonReceiverSessionPersister.close" = "closeSession" +"JsonReceiverSessionPersisterAsync.close" = "closeSession" +"JsonSenderSessionPersister.close" = "closeSession" +"JsonSenderSessionPersisterAsync.close" = "closeSession" + [bindings.python] cdylib_name = "payjoin_ffi" From a67c1a73ba0f9a7a3cb06741d0a94829f22d2b6e Mon Sep 17 00:00:00 2001 From: ram0verflow Date: Tue, 15 Sep 2026 15:05:26 +0530 Subject: [PATCH 2/5] Add Java binding project 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. --- payjoin-ffi/java/.gitignore | 15 ++ payjoin-ffi/java/CONTRIBUTING.md | 35 +++ payjoin-ffi/java/README.md | 171 ++++++++++++ payjoin-ffi/java/build.gradle.kts | 55 ++++ payjoin-ffi/java/contrib/test.sh | 15 ++ payjoin-ffi/java/gradle.properties | 1 + .../java/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + payjoin-ffi/java/gradlew | 248 ++++++++++++++++++ payjoin-ffi/java/gradlew.bat | 93 +++++++ ...ror-autocloseable-and-destroy-fields.patch | 32 +++ payjoin-ffi/java/scripts/generate_bindings.sh | 146 +++++++++++ payjoin-ffi/java/settings.gradle.kts | 1 + 13 files changed, 820 insertions(+) create mode 100644 payjoin-ffi/java/.gitignore create mode 100644 payjoin-ffi/java/CONTRIBUTING.md create mode 100644 payjoin-ffi/java/README.md create mode 100644 payjoin-ffi/java/build.gradle.kts create mode 100755 payjoin-ffi/java/contrib/test.sh create mode 100644 payjoin-ffi/java/gradle.properties create mode 100644 payjoin-ffi/java/gradle/wrapper/gradle-wrapper.jar create mode 100644 payjoin-ffi/java/gradle/wrapper/gradle-wrapper.properties create mode 100755 payjoin-ffi/java/gradlew create mode 100644 payjoin-ffi/java/gradlew.bat create mode 100644 payjoin-ffi/java/patches/0001-error-autocloseable-and-destroy-fields.patch create mode 100755 payjoin-ffi/java/scripts/generate_bindings.sh create mode 100644 payjoin-ffi/java/settings.gradle.kts diff --git a/payjoin-ffi/java/.gitignore b/payjoin-ffi/java/.gitignore new file mode 100644 index 000000000..77e8da053 --- /dev/null +++ b/payjoin-ffi/java/.gitignore @@ -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 diff --git a/payjoin-ffi/java/CONTRIBUTING.md b/payjoin-ffi/java/CONTRIBUTING.md new file mode 100644 index 000000000..dc567097e --- /dev/null +++ b/payjoin-ffi/java/CONTRIBUTING.md @@ -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--/`, 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. diff --git a/payjoin-ffi/java/README.md b/payjoin-ffi/java/README.md new file mode 100644 index 000000000..56ce6c5ec --- /dev/null +++ b/payjoin-ffi/java/README.md @@ -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`, 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. diff --git a/payjoin-ffi/java/build.gradle.kts b/payjoin-ffi/java/build.gradle.kts new file mode 100644 index 000000000..35568a523 --- /dev/null +++ b/payjoin-ffi/java/build.gradle.kts @@ -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().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..libraryOverride" convention the generated + // NamespaceLibrary.findLibraryName() reads - see scripts/generate_bindings.sh. + systemProperty("uniffi.component.payjoin.libraryOverride", nativeLibraryOverride.absolutePath) + } +} diff --git a/payjoin-ffi/java/contrib/test.sh b/payjoin-ffi/java/contrib/test.sh new file mode 100755 index 000000000..ec49e2846 --- /dev/null +++ b/payjoin-ffi/java/contrib/test.sh @@ -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 diff --git a/payjoin-ffi/java/gradle.properties b/payjoin-ffi/java/gradle.properties new file mode 100644 index 000000000..fad0c0940 --- /dev/null +++ b/payjoin-ffi/java/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx1g diff --git a/payjoin-ffi/java/gradle/wrapper/gradle-wrapper.jar b/payjoin-ffi/java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/payjoin-ffi/java/gradle/wrapper/gradle-wrapper.properties b/payjoin-ffi/java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..26dec6cde --- /dev/null +++ b/payjoin-ffi/java/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/payjoin-ffi/java/gradlew b/payjoin-ffi/java/gradlew new file mode 100755 index 000000000..adff685a0 --- /dev/null +++ b/payjoin-ffi/java/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/payjoin-ffi/java/gradlew.bat b/payjoin-ffi/java/gradlew.bat new file mode 100644 index 000000000..c4bdd3ab8 --- /dev/null +++ b/payjoin-ffi/java/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/payjoin-ffi/java/patches/0001-error-autocloseable-and-destroy-fields.patch b/payjoin-ffi/java/patches/0001-error-autocloseable-and-destroy-fields.patch new file mode 100644 index 000000000..5bbdcef85 --- /dev/null +++ b/payjoin-ffi/java/patches/0001-error-autocloseable-and-destroy-fields.patch @@ -0,0 +1,32 @@ +diff --git a/src/templates/ErrorTemplate.java b/src/templates/ErrorTemplate.java +index fe13136..dbf9e5d 100644 +--- a/src/templates/ErrorTemplate.java ++++ b/src/templates/ErrorTemplate.java +@@ -16 +16,2 @@ public class {{ type_name }} extends java.lang.Exception { +- public static class {{ variant|error_variant_name }} extends {{ type_name }}{% if contains_object_references %}, AutoCloseable{% endif %} { ++ {#- A flat variant carries only a message, so it never owns an object to close. -#} ++ public static class {{ variant|error_variant_name }} extends {{ type_name }} { +@@ -27 +28 @@ public class {{ type_name }} extends java.lang.Exception { +-public class {{ type_name }} extends java.lang.Exception { ++public class {{ type_name }} extends java.lang.Exception{% if contains_object_references %} implements AutoCloseable{% endif %} { +@@ -31,0 +33,7 @@ public class {{ type_name }} extends java.lang.Exception { ++ {% if contains_object_references %} ++ {#- Callers catch and hold the base type, so try-with-resources has to work there; the ++ object-holding variants override this. -#} ++ @Override ++ public void close() {} ++ {% endif %} ++ +@@ -35 +43 @@ public class {{ type_name }} extends java.lang.Exception { +- public static class {{ variant_name }} extends {{ type_name }}{% if contains_object_references %}, AutoCloseable{% endif %} { ++ public static class {{ variant_name }} extends {{ type_name }} { +@@ -68 +76 @@ public class {{ type_name }} extends java.lang.Exception { +- void close() { ++ public void close() { +diff --git a/src/templates/macros.java b/src/templates/macros.java +index bf7bd03..426104b 100644 +--- a/src/templates/macros.java ++++ b/src/templates/macros.java +@@ -223 +223 @@ v{{- field_num -}} +- this.{{ field.name()|var_name }}{%- if !loop.last %}, {% endif -%} ++ this.{% call field_name(field, loop.index) %}{%- if !loop.last %}, {% endif -%} diff --git a/payjoin-ffi/java/scripts/generate_bindings.sh b/payjoin-ffi/java/scripts/generate_bindings.sh new file mode 100755 index 000000000..737200283 --- /dev/null +++ b/payjoin-ffi/java/scripts/generate_bindings.sh @@ -0,0 +1,146 @@ +#!/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 +elif [[ $OS == MINGW* || $OS == MSYS* || $OS == CYGWIN* ]]; then + LIBNAME=payjoin_ffi.dll +else + echo "Unsupported os: $OS" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required (to hash the local patch and to parse cargo's build output)" >&2 + exit 1 +fi +if ! command -v git >/dev/null 2>&1; then + echo "git is required to fetch and verify the pinned uniffi-bindgen-java commit" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../.." + +# Generator source and patch - see README.md "Generator provenance" for the full story. Short +# version: uniffi-bindgen-java's newest tagged release (0.4.2) reads payjoin-ffi's current +# UniFFI 0.31 metadata, but its error-type templates emit Java that doesn't compile for this +# crate. The fix already exists on upstream's unreleased main (versioned 0.5.0), but that line +# requires UniFFI 0.32, which payjoin-ffi is not on. So: build canonical upstream 0.4.2, with the +# two-file fix backported as a local patch, and use that until payjoin-ffi moves to UniFFI 0.32 +# and can consume a real upstream 0.5.x release directly. +# +# Pinned by commit SHA (not the `0.4.2` tag name) so a future upstream re-tag can't silently +# change what this builds. `git tag -l 0.4.2 -n1` / `git rev-parse 0.4.2^{commit}` on the +# canonical repo is how this SHA was resolved from the tag. +GENERATOR_GIT_URL=${JAVA_BINDGEN_GIT_URL:-https://github.com/IronCoreLabs/uniffi-bindgen-java} +GENERATOR_REV=${JAVA_BINDGEN_REV:-559bd72e680e0be7feda6ac3a93819376db030d9} +GENERATOR_PATCH="$SCRIPT_DIR/../patches/0001-error-autocloseable-and-destroy-fields.patch" + +# Building this generator needs a Rust toolchain new enough for its own MSRV (1.87.0 per its +# README/Cargo.toml), which is newer than this workspace's own MSRV (1.85.0). Inside +# `nix develop .#java`, `cargo` is already that new (see flake.nix's javaDevShell comment) - +# no toolchain switching needed here. Outside nix, whatever `cargo` is on PATH is used as-is; if +# it's older than 1.87.0, install/select a newer one before running this script (e.g. `rustup +# install 1.87.0` and `rustup override set 1.87.0` in this directory, or `cargo +1.87.0 ...` by +# hand) - this script does not attempt to switch toolchains for you. +# +# The cache key includes a hash of the patch file, not just the pinned rev, so editing the patch +# produces a fresh build instead of reusing a binary built from the old one. Hashed with python3 +# (already required below, and everywhere else this script runs) rather than shasum, so this +# script doesn't add a second undeclared platform command to the requirements in README.md. +PATCH_HASH="$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest()[:12])' "$GENERATOR_PATCH")" +GENERATOR_ROOT="${CARGO_HOME:-$HOME/.cargo}/uniffi-bindgen-java-${GENERATOR_REV}-${PATCH_HASH}" +GENERATOR_BIN="$GENERATOR_ROOT/bin/uniffi-bindgen-java" + +if [[ ! -x "$GENERATOR_BIN" ]]; then + echo "Building patched uniffi-bindgen-java ($GENERATOR_REV, patch $PATCH_HASH)..." + # A scratch checkout, not a persistent mutable one: cloned fresh, patched, built, and + # discarded every time the cache misses, so there is never a partially-patched or + # stale-relative-to-the-patch-file checkout lying around to reason about. + GENERATOR_SRC="$(mktemp -d)" + trap 'rm -rf "$GENERATOR_SRC"' EXIT + git init --quiet "$GENERATOR_SRC" + git -C "$GENERATOR_SRC" fetch --quiet --depth 1 "$GENERATOR_GIT_URL" "$GENERATOR_REV" + git -C "$GENERATOR_SRC" checkout --quiet FETCH_HEAD + ACTUAL_REV="$(git -C "$GENERATOR_SRC" rev-parse HEAD)" + if [[ "$ACTUAL_REV" != "$GENERATOR_REV" ]]; then + echo "error: fetched $ACTUAL_REV, expected $GENERATOR_REV" >&2 + exit 1 + fi + # --unidiff-zero: the patch is generated with zero context lines (-U0) - safe here since it's + # applied against this exact pinned commit every time, and it keeps upstream's own trailing + # whitespace in surrounding template lines out of the patch file (git apply's default context + # matching needs at least one line of context per hunk without this flag). + git -C "$GENERATOR_SRC" apply --unidiff-zero "$GENERATOR_PATCH" + cargo install --path "$GENERATOR_SRC" --locked --root "$GENERATOR_ROOT" uniffi-bindgen-java + rm -rf "$GENERATOR_SRC" + trap - EXIT +else + echo "Using cached uniffi-bindgen-java at $GENERATOR_BIN" +fi + +echo "Generating payjoin Java..." +PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES-_test-utils} +PAYJOIN_FFI_PROFILE=${PAYJOIN_FFI_PROFILE:-dev} +# Empty FEATURE_ARGS + `set -u` is unbound on macOS bash 3.2. Pass --features only when set. +run_cargo() { + local cmd=$1 + shift + if [[ -n $PAYJOIN_FFI_FEATURES ]]; then + cargo "$cmd" --features "$PAYJOIN_FFI_FEATURES" "$@" + else + cargo "$cmd" "$@" + fi +} + +# The reported compiler-artifact path already reflects CARGO_TARGET_DIR, Cargo's [build] +# target-dir, CARGO_BUILD_TARGET, and the active profile - so it's read from Cargo's own JSON +# output instead of reconstructed from any of those independently, which would drift under any +# one of them. json-render-diagnostics keeps human-readable compiler errors on stderr (a plain +# `--message-format=json` would swallow a real Rust compile error's message and location here, +# leaving only the missing-artifact failure below to explain why). +NATIVE_LIB="$( + run_cargo build --message-format=json-render-diagnostics --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi | + python3 -c ' +import json, os, sys + +libname = sys.argv[1] +found = None +for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("reason") != "compiler-artifact": + continue + for filename in msg.get("filenames") or []: + if os.path.basename(filename) == libname: + found = filename +if not found: + sys.stderr.write("cargo build did not report %s\n" % libname) + sys.exit(1) +print(found) +' "$LIBNAME" +)" + +OUT_DIR="java/src/main/java" +mkdir -p "$OUT_DIR" +rm -rf "$OUT_DIR/org" + +# Run from payjoin-ffi/ (not java/) so the generator's cargo-metadata lookup finds +# payjoin-ffi/uniffi.toml's [bindings.java] section the same way the Kotlin/Python scripts do. +"$GENERATOR_BIN" generate --out-dir "$OUT_DIR" "$NATIVE_LIB" + +mkdir -p java/lib +cp "$NATIVE_LIB" "java/lib/$LIBNAME" + +echo "All done!" diff --git a/payjoin-ffi/java/settings.gradle.kts b/payjoin-ffi/java/settings.gradle.kts new file mode 100644 index 000000000..b32565955 --- /dev/null +++ b/payjoin-ffi/java/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "payjoin-java" From 828e60792b5382f15ecc934c6771a1ea79082c55 Mon Sep 17 00:00:00 2001 From: ram0verflow Date: Tue, 15 Sep 2026 15:05:40 +0530 Subject: [PATCH 3/5] Add Java validation and persistence tests 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. --- .../payjoindevkit/AsyncPersistenceTest.java | 208 ++++++++++++ .../java/org/payjoindevkit/CancelTest.java | 98 ++++++ .../org/payjoindevkit/InMemoryPersisters.java | 304 ++++++++++++++++++ .../payjoindevkit/PersistenceFailureTest.java | 143 ++++++++ .../org/payjoindevkit/PersistenceTest.java | 70 ++++ .../java/org/payjoindevkit/TestFixtures.java | 12 + .../test/java/org/payjoindevkit/UriTest.java | 61 ++++ .../org/payjoindevkit/ValidationTest.java | 168 ++++++++++ 8 files changed, 1064 insertions(+) create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/AsyncPersistenceTest.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/CancelTest.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/InMemoryPersisters.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceFailureTest.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceTest.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/TestFixtures.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/UriTest.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/ValidationTest.java diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/AsyncPersistenceTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/AsyncPersistenceTest.java new file mode 100644 index 000000000..75e7a9120 --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/AsyncPersistenceTest.java @@ -0,0 +1,208 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runtime coverage of the async ({@code CompletableFuture}) persister path - not exercised by + * {@link PersistenceTest} or {@link CancelTest}, which only drive the synchronous persister API + * (see README.md "Async / callbacks"). Every test here has an explicit timeout so a real generator + * regression in the async plumbing fails the test instead of hanging CI. + *

+ * {@link #receiverAsyncSaveStaysPendingUntilReleased} and {@link + * #senderAsyncCloseStaysPendingUntilReleased} are the "callback stays pending" tests from Kotlin + * PR #1875's {@code ControlledAsyncPersister.kt}/{@code gated()} helper, adapted to {@code + * CompletableFuture} (Java has no {@code CompletableDeferred}/{@code supervisorScope}) - they + * prove the returned future genuinely isn't complete until the Java-side persister future + * resolves, i.e. the FFI layer isn't silently treating the async callback as fire-and-forget. One + * save path and one close path is covered, split across receiver and sender rather than + * duplicating both for both sides. + */ +class AsyncPersistenceTest { + private static final long TIMEOUT_SECONDS = 10; + + @Test + void receiverAsyncSaveAndReplayRoundTrip() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.InMemoryReceiverPersisterAsync persister = new InMemoryPersisters.InMemoryReceiverPersisterAsync(); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + Initialized initialized = initial.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + initialized.close(); + } + } + try (ReplayResult replay = + Payjoin.replayReceiverEventLogAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + assertInstanceOf(ReceiveSession.Initialized.class, replay.state()); + } + } + + @Test + void senderAsyncSaveAndReplayRoundTrip() throws Exception { + InMemoryPersisters.InMemorySenderPersisterAsync persister = new InMemoryPersisters.InMemorySenderPersisterAsync(); + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + WithReplyKey withReplyKey = initial.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + withReplyKey.close(); + } + } + try (SenderReplayResult replay = + Payjoin.replaySenderEventLogAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + assertInstanceOf(SendSession.WithReplyKey.class, replay.state()); + } + } + + @Test + void receiverAsyncCancelClosesThroughProtocolTransition() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.InMemoryReceiverPersisterAsync persister = new InMemoryPersisters.InMemoryReceiverPersisterAsync(); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + Initialized initialized = initial.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try (CancelTransition cancel = initialized.cancel()) { + assertFalse(persister.isClosed()); + ReceiverPendingFallback pending = cancel.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + // See CancelTest's class doc comment: a never-interacted receiver has no + // fallback tx, so this saveAsync() call is itself what closes the session. + assertNull(pending, "a receiver that never received the sender's payload has no fallback"); + } + } + } + assertTrue(persister.isClosed(), "the persister must be closed by the cancel() transition itself"); + try (ReplayResult replay = + Payjoin.replayReceiverEventLogAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + assertInstanceOf(ReceiveSession.Closed.class, replay.state()); + } + } + + @Test + void senderAsyncCancelClosesThroughProtocolTransition() throws Exception { + InMemoryPersisters.InMemorySenderPersisterAsync persister = new InMemoryPersisters.InMemorySenderPersisterAsync(); + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + WithReplyKey withReplyKey = initial.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try (SenderCancelTransition cancel = withReplyKey.cancel()) { + SenderPendingFallback pending = cancel.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + assertFalse(persister.isClosed()); + try (BroadcastedTransition closeTransition = pending.closeSession()) { + closeTransition.saveAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } finally { + pending.close(); + } + } + } + } + assertTrue(persister.isClosed()); + try (SenderReplayResult replay = + Payjoin.replaySenderEventLogAsync(persister).get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + assertInstanceOf(SendSession.Closed.class, replay.state()); + } + } + + @Test + void receiverAsyncSaveAcceptsExplicitExecutor() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.InMemoryReceiverPersisterAsync persister = new InMemoryPersisters.InMemoryReceiverPersisterAsync(); + AtomicInteger executions = new AtomicInteger(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Executor countingExecutor = command -> { + executions.incrementAndGet(); + executor.execute(command); + }; + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + Initialized initialized = + initial.saveAsync(persister, countingExecutor).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + initialized.close(); + } + } + } finally { + executor.shutdown(); + } + assertTrue(executions.get() > 0, + "the Executor overload should be used somewhere in dispatching the async continuation"); + } + + @Test + void receiverAsyncSaveStaysPendingUntilReleased() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.GatedReceiverPersisterAsync gate = + new InMemoryPersisters.GatedReceiverPersisterAsync(InMemoryPersisters.Operation.SAVE); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + CompletableFuture future = initial.saveAsync(gate); + gate.awaitEntered(TIMEOUT_SECONDS * 1000); + assertFalse(future.isDone(), "save() must not complete before the persister's future does"); + + gate.release(); + Initialized initialized = future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + initialized.close(); + } + } + } + + @Test + void senderAsyncCloseStaysPendingUntilReleased() throws Exception { + InMemoryPersisters.InMemorySenderPersisterAsync setup = new InMemoryPersisters.InMemorySenderPersisterAsync(); + WithReplyKey withReplyKey; + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + withReplyKey = initial.saveAsync(setup).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + + InMemoryPersisters.GatedSenderPersisterAsync closeGate = + new InMemoryPersisters.GatedSenderPersisterAsync(InMemoryPersisters.Operation.CLOSE); + try (SenderCancelTransition cancel = withReplyKey.cancel()) { + SenderPendingFallback pending = cancel.saveAsync(setup).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try (BroadcastedTransition closeTransition = pending.closeSession()) { + try { + CompletableFuture future = closeTransition.saveAsync(closeGate); + closeGate.awaitEntered(TIMEOUT_SECONDS * 1000); + assertFalse(future.isDone(), "closeSession() must not complete before the persister's future does"); + assertFalse(closeGate.isClosed()); + + closeGate.release(); + future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } finally { + pending.close(); + } + } + } + assertTrue(closeGate.isClosed()); + } + + private static PjUri v2PjUri() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister persister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder receiverBuilder = new ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = receiverBuilder.build()) { + Initialized initialized = initial.save(persister); + try { + return initialized.pjUri(); + } finally { + initialized.close(); + } + } + } + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/CancelTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/CancelTest.java new file mode 100644 index 000000000..ce3e1b28c --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/CancelTest.java @@ -0,0 +1,98 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Session cancellation, driven end to end through the real protocol transitions rather than by + * calling the persister's {@code closeSession()} helper directly - a Kotlin PR #1869 review + * finding (chavic): a test that only calls the persister helper proves the helper works, not that + * Rust actually invokes it when a session is cancelled. + *

+ * The receiver and sender paths are asymmetric here, confirmed at runtime rather than assumed: the + * sender always already holds a signed original PSBT from the moment it's built, so + * {@code cancel().save()} always hands back a real {@code SenderPendingFallback} with something + * broadcastable, and closing goes through an explicit {@code closeSession()} transition. A receiver + * that has never received the sender's original payload has nothing to fall back to, so for it + * {@code cancel().save()} returns {@code null} and closes the session immediately, in the same call + * - there is no intermediate pending-fallback object to close in that case. Both tests still assert + * the persister is closed only by a real Rust-driven {@code save()} call, never a bare helper call. + */ +class CancelTest { + @Test + void receiverCancelClosesThroughProtocolTransition() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister persister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + Initialized initialized = initial.save(persister); + try (CancelTransition cancel = initialized.cancel()) { + assertFalse(persister.isClosed(), + "reaching the cancel transition must not close the persister yet"); + ReceiverPendingFallback pending = cancel.save(persister); + // See the class doc comment: a never-interacted receiver has no fallback tx, + // so this save() call is itself what closes the session. + assertNull(pending, + "a receiver that never received the sender's payload has no fallback"); + } + } + } + + assertTrue(persister.isClosed(), "the persister must be closed by the cancel() transition itself"); + try (ReplayResult replay = Payjoin.replayReceiverEventLog(persister)) { + assertInstanceOf(ReceiveSession.Closed.class, replay.state()); + } + } + + @Test + void senderCancelClosesThroughProtocolTransition() throws Exception { + InMemoryPersisters.InMemorySenderPersister persister = new InMemoryPersisters.InMemorySenderPersister(); + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + WithReplyKey withReplyKey = initial.save(persister); + try (SenderCancelTransition cancel = withReplyKey.cancel()) { + SenderPendingFallback pending = cancel.save(persister); + try { + assertFalse(persister.isClosed(), + "reaching the pending-fallback state must not close the persister yet"); + assertTrue(pending.fallbackTx().length > 0, + "the sender's pending-fallback state carries a real, broadcastable fallback tx"); + + try (BroadcastedTransition closeTransition = pending.closeSession()) { + closeTransition.save(persister); + } + } finally { + pending.close(); + } + } + } + } + + assertTrue(persister.isClosed(), + "the persister must be closed only after the closeSession() transition is saved"); + try (SenderReplayResult replay = Payjoin.replaySenderEventLog(persister)) { + assertInstanceOf(SendSession.Closed.class, replay.state()); + } + } + + private static PjUri v2PjUri() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister persister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder receiverBuilder = new ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = receiverBuilder.build()) { + Initialized initialized = initial.save(persister); + try { + return initialized.pjUri(); + } finally { + initialized.close(); + } + } + } + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/InMemoryPersisters.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/InMemoryPersisters.java new file mode 100644 index 000000000..ecca4b7f3 --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/InMemoryPersisters.java @@ -0,0 +1,304 @@ +package org.payjoindevkit; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Minimal in-memory persisters shared by the tests in this package. Mirrors Kotlin's {@code InMemoryPersisters.kt}. */ +final class InMemoryPersisters { + private InMemoryPersisters() { + } + + /** Which persister operation a {@link ControlledReceiverPersister}/{@link ControlledSenderPersister} + * or {@link GatedReceiverPersisterAsync}/{@link GatedSenderPersisterAsync} singles out. */ + enum Operation { + SAVE, LOAD, CLOSE + } + + private abstract static class MemoryEventLog { + private final List events = new CopyOnWriteArrayList<>(); + private volatile boolean closed; + + // public: overrides a public interface method (JsonReceiverSessionPersister/JsonSenderSessionPersister) + public void save(String event) { + events.add(event); + } + + public List load() { + return List.copyOf(events); + } + + public void closeSession() { + closed = true; + } + + boolean isClosed() { + return closed; + } + } + + static final class InMemoryReceiverPersister extends MemoryEventLog implements JsonReceiverSessionPersister { + } + + static final class InMemorySenderPersister extends MemoryEventLog implements JsonSenderSessionPersister { + } + + /** + * A persister whose {@code save}/{@code load}/{@code closeSession} fails on exactly one named + * operation, everything else behaving like a normal in-memory log. Mirrors Kotlin PR #1875's + * {@code ControlledPersister.kt} - it proves a storage failure on a specific operation + * propagates through the generated FFI boundary as a real {@link ForeignException} instead of + * being swallowed. A standalone class (not a {@link MemoryEventLog} subclass): overriding + * {@code save}/{@code load}/{@code closeSession} to add a checked {@code throws + * ForeignException} is only legal if the method they override doesn't already forbid it, which + * {@code MemoryEventLog}'s do. + */ + static final class ControlledReceiverPersister implements JsonReceiverSessionPersister { + private final List events = new CopyOnWriteArrayList<>(); + private final Operation failure; + private volatile boolean closed; + + ControlledReceiverPersister(Operation failure) { + this.failure = failure; + } + + @Override + public void save(String event) throws ForeignException { + if (failure == Operation.SAVE) { + throw new ForeignException.InternalException("storage save failed"); + } + events.add(event); + } + + @Override + public List load() throws ForeignException { + if (failure == Operation.LOAD) { + throw new ForeignException.InternalException("storage load failed"); + } + return List.copyOf(events); + } + + @Override + public void closeSession() throws ForeignException { + if (failure == Operation.CLOSE) { + throw new ForeignException.InternalException("storage close failed"); + } + closed = true; + } + + boolean isClosed() { + return closed; + } + } + + /** Sender-side counterpart of {@link ControlledReceiverPersister} - see that class for why it + * doesn't extend {@link MemoryEventLog}. */ + static final class ControlledSenderPersister implements JsonSenderSessionPersister { + private final List events = new CopyOnWriteArrayList<>(); + private final Operation failure; + private volatile boolean closed; + + ControlledSenderPersister(Operation failure) { + this.failure = failure; + } + + @Override + public void save(String event) throws ForeignException { + if (failure == Operation.SAVE) { + throw new ForeignException.InternalException("storage save failed"); + } + events.add(event); + } + + @Override + public List load() throws ForeignException { + if (failure == Operation.LOAD) { + throw new ForeignException.InternalException("storage load failed"); + } + return List.copyOf(events); + } + + @Override + public void closeSession() throws ForeignException { + if (failure == Operation.CLOSE) { + throw new ForeignException.InternalException("storage close failed"); + } + closed = true; + } + + boolean isClosed() { + return closed; + } + } + + /** A plain async in-memory persister - the {@code *Async} counterpart of + * {@link InMemoryReceiverPersister}, every operation completing immediately. */ + static final class InMemoryReceiverPersisterAsync implements JsonReceiverSessionPersisterAsync { + private final List events = new CopyOnWriteArrayList<>(); + private volatile boolean closed; + + @Override + public CompletableFuture save(String event) { + events.add(event); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> load() { + return CompletableFuture.completedFuture(List.copyOf(events)); + } + + @Override + public CompletableFuture closeSession() { + closed = true; + return CompletableFuture.completedFuture(null); + } + + boolean isClosed() { + return closed; + } + } + + /** Sender-side counterpart of {@link InMemoryReceiverPersisterAsync}. */ + static final class InMemorySenderPersisterAsync implements JsonSenderSessionPersisterAsync { + private final List events = new CopyOnWriteArrayList<>(); + private volatile boolean closed; + + @Override + public CompletableFuture save(String event) { + events.add(event); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> load() { + return CompletableFuture.completedFuture(List.copyOf(events)); + } + + @Override + public CompletableFuture closeSession() { + closed = true; + return CompletableFuture.completedFuture(null); + } + + boolean isClosed() { + return closed; + } + } + + /** + * An async persister whose one named operation doesn't complete until the test releases it - + * proves the generated async path genuinely awaits the Java-side {@link CompletableFuture} + * rather than treating callback dispatch as fire-and-forget. Mirrors Kotlin PR #1875's + * {@code gated(operation, block)} helper in {@code ControlledPersister.kt}, adapted to + * {@code CompletableFuture} since Java has no {@code CompletableDeferred}/{@code + * supervisorScope}: {@link #awaitEntered} blocks (with a timeout) until the gated operation has + * actually been invoked, and {@link #release} lets it finish. + */ + static final class GatedReceiverPersisterAsync implements JsonReceiverSessionPersisterAsync { + private final List events = new CopyOnWriteArrayList<>(); + private final Operation gated; + private final CompletableFuture entered = new CompletableFuture<>(); + private final CompletableFuture gate = new CompletableFuture<>(); + private volatile boolean closed; + + GatedReceiverPersisterAsync(Operation gated) { + this.gated = gated; + } + + void awaitEntered(long timeoutMillis) throws Exception { + entered.get(timeoutMillis, java.util.concurrent.TimeUnit.MILLISECONDS); + } + + void release() { + gate.complete(null); + } + + boolean isClosed() { + return closed; + } + + @Override + public CompletableFuture save(String event) { + if (gated == Operation.SAVE) { + entered.complete(null); + return gate.thenRun(() -> events.add(event)); + } + events.add(event); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> load() { + if (gated == Operation.LOAD) { + entered.complete(null); + return gate.thenApply(ignored -> List.copyOf(events)); + } + return CompletableFuture.completedFuture(List.copyOf(events)); + } + + @Override + public CompletableFuture closeSession() { + if (gated == Operation.CLOSE) { + entered.complete(null); + return gate.thenRun(() -> closed = true); + } + closed = true; + return CompletableFuture.completedFuture(null); + } + } + + /** Sender-side counterpart of {@link GatedReceiverPersisterAsync}. */ + static final class GatedSenderPersisterAsync implements JsonSenderSessionPersisterAsync { + private final List events = new CopyOnWriteArrayList<>(); + private final Operation gated; + private final CompletableFuture entered = new CompletableFuture<>(); + private final CompletableFuture gate = new CompletableFuture<>(); + private volatile boolean closed; + + GatedSenderPersisterAsync(Operation gated) { + this.gated = gated; + } + + void awaitEntered(long timeoutMillis) throws Exception { + entered.get(timeoutMillis, java.util.concurrent.TimeUnit.MILLISECONDS); + } + + void release() { + gate.complete(null); + } + + boolean isClosed() { + return closed; + } + + @Override + public CompletableFuture save(String event) { + if (gated == Operation.SAVE) { + entered.complete(null); + return gate.thenRun(() -> events.add(event)); + } + events.add(event); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture> load() { + if (gated == Operation.LOAD) { + entered.complete(null); + return gate.thenApply(ignored -> List.copyOf(events)); + } + return CompletableFuture.completedFuture(List.copyOf(events)); + } + + @Override + public CompletableFuture closeSession() { + if (gated == Operation.CLOSE) { + entered.complete(null); + return gate.thenRun(() -> closed = true); + } + closed = true; + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceFailureTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceFailureTest.java new file mode 100644 index 000000000..634cc724d --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceFailureTest.java @@ -0,0 +1,143 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Storage failures on a specific persister operation (save/load/close) must propagate through the + * generated FFI boundary as a real, specifically-typed exception - not be swallowed, and not + * surface only as the generic superclass. A focused Java port of Kotlin PR #1875's + * {@code PersistenceCallbackTests.kt} (synchronous half only - see {@link AsyncPersistenceTest} + * for the async equivalents), using {@link InMemoryPersisters.ControlledReceiverPersister} / + * {@link InMemoryPersisters.ControlledSenderPersister} to fail exactly one named operation. + *

+ * The exact exception type differs by call site, and that's the point being tested: the very + * first {@code save()} on a freshly built transition throws {@link ForeignException} directly + * (there is no protocol state yet to wrap it in), while every later transition wraps a storage + * failure in the protocol-specific {@code ReceiverPersistedException}/{@code + * SenderPersistedException}'s {@code Storage} variant, and a replay load failure surfaces as + * {@code ReceiverReplayException}/{@code SenderReplayException}. + */ +class PersistenceFailureTest { + @Test + void receiverInitialSaveFailurePropagates() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.ControlledReceiverPersister persister = + new InMemoryPersisters.ControlledReceiverPersister(InMemoryPersisters.Operation.SAVE); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + ForeignException.InternalException ex = assertThrows(ForeignException.InternalException.class, + () -> initial.save(persister)); + assertTrue(ex.v1().contains("storage save failed"), ex.v1()); + } + } + } + + @Test + void receiverCloseFailurePropagatesAsPersistedException() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.ControlledReceiverPersister persister = + new InMemoryPersisters.ControlledReceiverPersister(InMemoryPersisters.Operation.CLOSE); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + Initialized initialized = initial.save(persister); + try (CancelTransition cancel = initialized.cancel()) { + // A never-interacted receiver has no fallback tx, so cancel.save() itself is + // the transition that closes the session (see CancelTest's class doc comment) - + // that's the call that needs the persister's closeSession() to succeed here, + // not a later PendingFallbackTransition as the sender's equivalent test needs. + ReceiverPersistedException.Storage ex = assertThrows( + ReceiverPersistedException.Storage.class, () -> cancel.save(persister)); + assertTrue(ex.v1().toString().contains("storage close failed"), ex.v1().toString()); + } + } + } + assertFalse(persister.isClosed(), "the failed close must not mark the persister closed"); + } + + @Test + void receiverLoadFailurePropagatesOnReplay() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + InMemoryPersisters.ControlledReceiverPersister persister = + new InMemoryPersisters.ControlledReceiverPersister(InMemoryPersisters.Operation.LOAD); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + initial.save(persister).close(); + } + } + ReceiverReplayException ex = + assertThrows(ReceiverReplayException.class, () -> Payjoin.replayReceiverEventLog(persister)); + assertTrue(ex.toString().contains("storage load failed"), ex.toString()); + } + + @Test + void senderInitialSaveFailurePropagates() throws Exception { + InMemoryPersisters.ControlledSenderPersister persister = + new InMemoryPersisters.ControlledSenderPersister(InMemoryPersisters.Operation.SAVE); + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + ForeignException.InternalException ex = assertThrows(ForeignException.InternalException.class, + () -> initial.save(persister)); + assertTrue(ex.v1().contains("storage save failed"), ex.v1()); + } + } + } + + @Test + void senderCloseFailurePropagatesAsPersistedException() throws Exception { + InMemoryPersisters.ControlledSenderPersister persister = + new InMemoryPersisters.ControlledSenderPersister(InMemoryPersisters.Operation.CLOSE); + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + WithReplyKey withReplyKey = initial.save(persister); + try (SenderCancelTransition cancel = withReplyKey.cancel()) { + SenderPendingFallback pending = cancel.save(persister); + try (BroadcastedTransition closeTransition = pending.closeSession()) { + SenderPersistedException.Storage ex = assertThrows( + SenderPersistedException.Storage.class, () -> closeTransition.save(persister)); + assertTrue(ex.v1().toString().contains("storage close failed"), ex.v1().toString()); + } finally { + pending.close(); + } + } + } + } + assertFalse(persister.isClosed(), "the failed close must not mark the persister closed"); + } + + @Test + void senderLoadFailurePropagatesOnReplay() throws Exception { + InMemoryPersisters.ControlledSenderPersister persister = + new InMemoryPersisters.ControlledSenderPersister(InMemoryPersisters.Operation.LOAD); + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + initial.save(persister).close(); + } + } + SenderReplayException ex = + assertThrows(SenderReplayException.class, () -> Payjoin.replaySenderEventLog(persister)); + assertTrue(ex.toString().contains("storage load failed"), ex.toString()); + } + + private static PjUri v2PjUri() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister persister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder receiverBuilder = new ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = receiverBuilder.build()) { + Initialized initialized = initial.save(persister); + try { + return initialized.pjUri(); + } finally { + initialized.close(); + } + } + } + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceTest.java new file mode 100644 index 000000000..994f23a2a --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/PersistenceTest.java @@ -0,0 +1,70 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Session persistence: the synchronous persister API, replay of the event log, and + * {@code closeSession} semantics. A focused Java port of the Kotlin bindings' + * {@code PersistenceTests.kt} - the synchronous persister only (see README.md "Async / + * callbacks" for why the async persister variant isn't separately covered here). + */ +class PersistenceTest { + @Test + void receiverPersistence() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister persister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder receiverBuilder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = receiverBuilder.build()) { + Initialized initialized = initial.save(persister); + initialized.close(); + } + } + + try (ReplayResult replay = Payjoin.replayReceiverEventLog(persister)) { + assertInstanceOf(ReceiveSession.Initialized.class, replay.state()); + } + assertFalse(persister.isClosed()); + persister.closeSession(); + assertTrue(persister.isClosed()); + } + + @Test + void senderPersistenceReplaysToWithReplyKey() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister receiverPersister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + PjUri uri; + try (ReceiverBuilder receiverBuilder = new ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = receiverBuilder.build()) { + Initialized initialized = initial.save(receiverPersister); + try { + uri = initialized.pjUri(); + } finally { + initialized.close(); + } + } + } + + InMemoryPersisters.InMemorySenderPersister senderPersister = new InMemoryPersisters.InMemorySenderPersister(); + try (uri; SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + try (InitialSendTransition initial = builder.buildRecommended(1_000L)) { + WithReplyKey withReplyKey = initial.save(senderPersister); + withReplyKey.close(); + } + } + + try (SenderReplayResult replay = Payjoin.replaySenderEventLog(senderPersister)) { + assertInstanceOf(SendSession.WithReplyKey.class, replay.state()); + } + assertFalse(senderPersister.isClosed()); + senderPersister.closeSession(); + assertTrue(senderPersister.isClosed()); + receiverPersister.closeSession(); + assertTrue(receiverPersister.isClosed()); + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/TestFixtures.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/TestFixtures.java new file mode 100644 index 000000000..69ce790ab --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/TestFixtures.java @@ -0,0 +1,12 @@ +package org.payjoindevkit; + +import java.util.HexFormat; + +/** Shared fixture data for the unit tests in this package. Mirrors Kotlin's {@code TestFixtures.kt}. */ +final class TestFixtures { + private TestFixtures() { + } + + static final byte[] OHTTP_KEYS_DATA = HexFormat.of().parseHex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003"); +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/UriTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/UriTest.java new file mode 100644 index 000000000..1acbc16f9 --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/UriTest.java @@ -0,0 +1,61 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * BIP21/BIP77 URI parsing and payjoin-support detection. A focused Java port of the Kotlin + * bindings' {@code UriTests.kt} - not a mechanical line-for-line port of every test there. + */ +class UriTest { + @Test + void urlEncodedPayjoinParameter() throws Exception { + String endpoint = "https://example.com/pj?ciao=1"; + String encodedPj = "https%3A%2F%2Fexample.com%2Fpj%3Fciao%3D1"; + String uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=" + encodedPj; + try (Uri parsed = Uri.parse(uri)) { + assertEquals("12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", parsed.address()); + assertEquals(100_000_000L, parsed.amountSats()); + try (PjUri pjUri = parsed.checkPjSupported()) { + assertEquals(endpoint, pjUri.pjEndpoint()); + } + } + } + + @Test + void missingAmountShouldBeOk() throws Exception { + String uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj"; + try (Uri parsed = Uri.parse(uri)) { + assertNotNull(parsed); + } + } + + @Test + void validUrisWithDifferentAddressesAndEndpoints() throws Exception { + String https = Payjoin.exampleUrl(); + String onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion"; + String[] addresses = { + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4", + "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + }; + for (String address : addresses) { + for (String pj : new String[] {https, onion}) { + try (Uri parsed = Uri.parse(address + "?amount=1&pj=" + pj)) { + assertNotNull(parsed); + } + } + } + } + + @Test + void uriParseSmoke() throws Exception { + try (Uri uri = Uri.parse("bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")) { + assertNotNull(uri.address()); + } + assertThrows(UriParseException.class, () -> Uri.parse("not-a-uri")); + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/ValidationTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/ValidationTest.java new file mode 100644 index 000000000..75fa52dcd --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/ValidationTest.java @@ -0,0 +1,168 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Basic input validation already exercised in the Kotlin/Python FFI suites. A focused Java port + * of the Kotlin bindings' {@code ValidationTests.kt} (amount/fee-rate range checks, InputPair + * outpoint/amount/script/weight validation) - not every case there, and none of this re-tests + * BIP77 protocol behavior, which the integration test covers instead. + *

+ * The InputPair tests below exist because of a Kotlin PR #1869 review finding (chavic): asserting + * only the superclass {@code InputPairException} let a test pass for the wrong reason - a + * too-long txid was meant to fail outpoint parsing ({@code InvalidOutPoint}), but with a + * valid txid and no UTXO information the same superclass assertion still passed via a + * completely different failure ({@code FfiValidation}). Asserting the exact nested variant is + * required here specifically to catch that. + */ +class ValidationTest { + // 21_000_000 BTC in sats, plus one - one past Bitcoin's maximum possible supply. + private static final long TOO_LARGE_AMOUNT_SATS = 21_000_000L * 100_000_000L + 1L; + private static final String VALID_TXID = "00".repeat(32); + + @Test + void inputPairRejectsInvalidOutpoint() { + // Too-long txid fails outpoint parsing before amount/UTXO checks - see the class doc + // comment on why this asserts the exact nested variant, not just InputPairException. + String tooLongTxid = "00".repeat(64); + InputPairException.InvalidOutPoint ex = assertThrows(InputPairException.InvalidOutPoint.class, + () -> new InputPair( + new TxIn(new OutPoint(tooLongTxid, 0), new byte[0], 0, List.of()), + new PsbtInput(new TxOut(TOO_LARGE_AMOUNT_SATS, new byte[] {0x51}), null, null), + null)); + assertEquals(tooLongTxid, ex.txid()); + } + + @Test + void inputPairRejectsAmountOverflow() { + InputPairException.FfiValidation ex = assertThrows(InputPairException.FfiValidation.class, + () -> new InputPair( + new TxIn(new OutPoint(VALID_TXID, 0), new byte[0], 0, List.of()), + new PsbtInput(new TxOut(TOO_LARGE_AMOUNT_SATS, new byte[] {0x51}), null, null), + null)); + FfiValidationException.AmountOutOfRange detail = + assertInstanceOf(FfiValidationException.AmountOutOfRange.class, ex.v1()); + assertEquals(TOO_LARGE_AMOUNT_SATS, detail.amountSat()); + } + + @Test + void inputPairRejectsOversizedScript() { + byte[] oversizedScript = new byte[10_001]; + java.util.Arrays.fill(oversizedScript, (byte) 0x51); + InputPairException.FfiValidation ex = assertThrows(InputPairException.FfiValidation.class, + () -> new InputPair( + new TxIn(new OutPoint(VALID_TXID, 0), new byte[0], 0, List.of()), + new PsbtInput(new TxOut(1L, oversizedScript), null, null), + null)); + FfiValidationException.ScriptTooLarge detail = + assertInstanceOf(FfiValidationException.ScriptTooLarge.class, ex.v1()); + assertEquals(10_001L, detail.len()); + assertEquals(10_000L, detail.max()); + } + + @Test + void inputPairRejectsWeightOutOfRange() { + for (long weight : new long[] {0L, 4_000_001L}) { + InputPairException.FfiValidation ex = assertThrows(InputPairException.FfiValidation.class, + () -> new InputPair( + new TxIn(new OutPoint(VALID_TXID, 0), new byte[0], 0, List.of()), + new PsbtInput(new TxOut(1L, new byte[] {0x6a}), null, null), + new Weight(weight))); + FfiValidationException.WeightOutOfRange detail = + assertInstanceOf(FfiValidationException.WeightOutOfRange.class, ex.v1()); + assertEquals(weight, detail.weightUnits()); + assertEquals(4_000_000L, detail.maxWu()); + } + } + + @Test + void receiverBuilderRejectsBadAddress() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + assertThrows(ReceiverBuilderException.class, + () -> new ReceiverBuilder("not-an-address", "https://example.com", ohttpKeys)); + } + + @Test + void receiverBuilderRejectsAmountOverflow() throws Exception { + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder builder = new ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys)) { + assertThrows(FfiValidationException.AmountOutOfRange.class, + () -> builder.withAmount(TOO_LARGE_AMOUNT_SATS)); + } + } + + @Test + void senderBuilderWithAdditionalFeeRejectsAmountOverflow() throws Exception { + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + SenderInputException.FfiValidation ex = assertThrows(SenderInputException.FfiValidation.class, + () -> builder.buildWithAdditionalFee(TOO_LARGE_AMOUNT_SATS, null, 1_000L, false)); + assertInstanceOf(FfiValidationException.AmountOutOfRange.class, ex.v1()); + } + } + + @Test + void senderBuilderWithAdditionalFeeRejectsFeeRateOverflow() throws Exception { + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + SenderInputException.FfiValidation ex = assertThrows(SenderInputException.FfiValidation.class, + () -> builder.buildWithAdditionalFee(1L, null, Long.MAX_VALUE, false)); + assertInstanceOf(FfiValidationException.FeeRateOutOfRange.class, ex.v1()); + } + } + + @Test + void senderBuilderRecommendedRejectsFeeRateOverflow() throws Exception { + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + SenderInputException.FfiValidation ex = assertThrows(SenderInputException.FfiValidation.class, + () -> builder.buildRecommended(Long.MAX_VALUE)); + assertInstanceOf(FfiValidationException.FeeRateOutOfRange.class, ex.v1()); + } + } + + @Test + void senderBuilderNonIncentivizingRejectsFeeRateOverflow() throws Exception { + try (PjUri uri = v2PjUri(); SenderBuilder builder = new SenderBuilder(Payjoin.originalPsbt(), uri)) { + SenderInputException.FfiValidation ex = assertThrows(SenderInputException.FfiValidation.class, + () -> builder.buildNonIncentivizing(Long.MAX_VALUE)); + assertInstanceOf(FfiValidationException.FeeRateOutOfRange.class, ex.v1()); + } + } + + @Test + void pjUriRejectsAmountOverflow() throws Exception { + try (PjUri uri = v2PjUri()) { + assertThrows(FfiValidationException.AmountOutOfRange.class, + () -> uri.setAmountSats(TOO_LARGE_AMOUNT_SATS)); + } + } + + @Test + void senderBuilderRejectsBadPsbt() throws Exception { + try (Uri parsed = Uri.parse("bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj"); + PjUri uri = parsed.checkPjSupported()) { + assertThrows(SenderInputException.class, () -> new SenderBuilder("not-a-psbt", uri)); + } + } + + private static PjUri v2PjUri() throws Exception { + InMemoryPersisters.InMemoryReceiverPersister persister = new InMemoryPersisters.InMemoryReceiverPersister(); + OhttpKeys ohttpKeys = OhttpKeys.decode(TestFixtures.OHTTP_KEYS_DATA); + try (ReceiverBuilder receiverBuilder = new ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", "https://example.com", ohttpKeys)) { + try (InitialReceiveTransition initial = receiverBuilder.build()) { + Initialized initialized = initial.save(persister); + try { + return initialized.pjUri(); + } finally { + initialized.close(); + } + } + } + } +} From db26046f6b41cf07f36983de99fc2e731b05c3e1 Mon Sep 17 00:00:00 2001 From: ram0verflow Date: Tue, 15 Sep 2026 15:05:54 +0530 Subject: [PATCH 4/5] Add Java BIP77 integration test 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. --- .../payjoindevkit/BIP77IntegrationTest.java | 527 ++++++++++++++++++ .../test/java/org/payjoindevkit/JsonRpc.java | 227 ++++++++ .../test/java/org/payjoindevkit/TestHttp.java | 76 +++ 3 files changed, 830 insertions(+) create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/BIP77IntegrationTest.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/JsonRpc.java create mode 100644 payjoin-ffi/java/src/test/java/org/payjoindevkit/TestHttp.java diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/BIP77IntegrationTest.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/BIP77IntegrationTest.java new file mode 100644 index 000000000..c3cd7342d --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/BIP77IntegrationTest.java @@ -0,0 +1,527 @@ +package org.payjoindevkit; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Full BIP 77 v2↔v2 round trip against the in-process directory, OHTTP relay, and bitcoind + * regtest - {@code payjoin-test-utils}, the same test infrastructure the Kotlin bindings' + * {@code IntegrationTests.kt} uses. A close Java port of that test's flow (same RPC sequence, + * same receiver checklist, same final assertions - including that the broadcast transaction + * spends coins from both wallets), adapted to the Java-native generated API: every type here + * genuinely implements {@code AutoCloseable} (unlike the Kotlin bindings, where a couple of + * types are only {@code Disposable} - see README.md), so this uses plain try-with-resources + * throughout instead of a custom {@code useDisposable} helper. + *

+ * No public production infrastructure - sender and receiver are both driven through + * this Java binding target. + */ +class BIP77IntegrationTest { + private static final long POLL_SLEEP_MS = 250L; + private static final long POLL_TIMEOUT_NS = 30_000_000_000L; + + @Test + void v2ToV2Payjoin() throws Exception { + Payjoin.initTracing(); + try (TestServices services = TestServices.initialize()) { + services.waitForServicesReady(); + String directory = services.directoryUrl(); + String relay = services.ohttpRelayUrl(); + try (OhttpKeys ohttpKeys = services.fetchOhttpKeys(); + TestHttp http = new TestHttp(services); + BitcoindEnv env = Payjoin.initBitcoindSenderReceiver()) { + try (RpcClient senderRpc = env.getSender(); RpcClient receiverRpc = env.getReceiver()) { + runV2ToV2(http, directory, relay, ohttpKeys, senderRpc, receiverRpc); + } + } + } + } + + private void runV2ToV2(TestHttp http, String directory, String relay, OhttpKeys ohttpKeys, + RpcClient senderRpc, RpcClient receiverRpc) throws Exception { + String receiverAddress = JsonRpc.stringResult(rpc(receiverRpc, "getnewaddress")); + Set senderOutpoints = listOutpoints(senderRpc); + Set receiverOutpoints = listOutpoints(receiverRpc); + InMemoryPersisters.InMemoryReceiverPersister recvPersister = new InMemoryPersisters.InMemoryReceiverPersister(); + InMemoryPersisters.InMemorySenderPersister sendPersister = new InMemoryPersisters.InMemorySenderPersister(); + + // Inside the receiver: start the session. + Initialized session; + try (ReceiverBuilder builder = new ReceiverBuilder(receiverAddress, directory, ohttpKeys)) { + try (InitialReceiveTransition initial = builder.build()) { + session = initial.save(recvPersister); + } + } + try { + UncheckedOriginalPayload firstPoll = pollReceiver(session, recvPersister, http, relay); + assertEquals(null, firstPoll, "receiver mailbox should be empty before the sender posts"); + + // Inside the sender: build and post the Original PSBT. + PjUri pjUri = session.pjUri(); + try { + String originalPsbt = buildSweepPsbt(senderRpc, pjUri); + WithReplyKey withReplyKey; + try (SenderBuilder senderBuilder = new SenderBuilder(originalPsbt, pjUri)) { + try (InitialSendTransition initial = senderBuilder.buildRecommended(1_000L)) { + withReplyKey = initial.save(sendPersister); + } + } + PollingForProposal pollingForProposal; + try (RequestOhttpContext posted = withReplyKey.createV2PostRequest(relay)) { + byte[] body = http.post(posted.request()); + try (WithReplyKeyTransition transition = withReplyKey.processResponse(body, posted.ohttpCtx())) { + pollingForProposal = transition.save(sendPersister); + } + } finally { + withReplyKey.close(); + } + + try { + // Inside the receiver: wait for the sender's post, then work through the + // full receiver checklist to a finalized PayjoinProposal. + PayjoinProposal payjoinProposal = waitForReceiverProposal(session, recvPersister, http, relay, receiverRpc); + try { + try (RequestResponse posted = payjoinProposal.createPostRequest(relay)) { + byte[] body = http.post(posted.request()); + try (PayjoinProposalTransition transition = + payjoinProposal.processResponse(body, posted.clientResponse())) { + transition.save(recvPersister).close(); + } + } + + // Inside the sender: poll until the receiver's proposal comes back. + String psbtBase64 = waitForSenderProposal(pollingForProposal, sendPersister, http, relay); + finishPayjoin(senderRpc, receiverRpc, psbtBase64, senderOutpoints, receiverOutpoints); + } finally { + payjoinProposal.close(); + } + } finally { + pollingForProposal.close(); + } + } finally { + pjUri.close(); + } + } finally { + session.close(); + } + + recvPersister.closeSession(); + sendPersister.closeSession(); + } + + private UncheckedOriginalPayload pollReceiver(Initialized session, InMemoryPersisters.InMemoryReceiverPersister persister, + TestHttp http, String relay) throws Exception { + try (RequestResponse requestResponse = session.createPollRequest(relay)) { + byte[] body = http.post(requestResponse.request()); + try (InitializedTransition transition = session.processResponse(body, requestResponse.clientResponse())) { + InitializedTransitionOutcome outcome = transition.save(persister); + if (outcome instanceof InitializedTransitionOutcome.Progress progress) { + // Progress retains the payload as the return value; closing the outcome + // wrapper would free that handle, so it's returned without closing. + return progress.inner(); + } else if (outcome instanceof InitializedTransitionOutcome.Stasis stasis) { + stasis.close(); + return null; + } + throw new IllegalStateException("unreachable: unknown InitializedTransitionOutcome"); + } + } + } + + private PayjoinProposal waitForReceiverProposal(Initialized session, InMemoryPersisters.InMemoryReceiverPersister persister, + TestHttp http, String relay, RpcClient receiverRpc) throws Exception { + long deadline = System.nanoTime() + POLL_TIMEOUT_NS; + int attempts = 0; + while (System.nanoTime() < deadline) { + attempts++; + UncheckedOriginalPayload original = pollReceiver(session, persister, http, relay); + if (original != null) { + try { + return processUncheckedProposal(original, persister, receiverRpc); + } finally { + original.close(); + } + } + Thread.sleep(POLL_SLEEP_MS); + } + fail("Timed out waiting for sender original after " + attempts + " poll(s)"); + throw new AssertionError("unreachable"); + } + + private PayjoinProposal processUncheckedProposal(UncheckedOriginalPayload proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + MaybeInputsOwned maybeInputsOwned; + try (UncheckedOriginalPayloadTransition transition = + proposal.checkBroadcastSuitability(null, new MempoolAcceptanceCallback(receiverRpc))) { + maybeInputsOwned = transition.save(persister); + } + try { + return processMaybeInputsOwned(maybeInputsOwned, persister, receiverRpc); + } finally { + maybeInputsOwned.close(); + } + } + + private PayjoinProposal processMaybeInputsOwned(MaybeInputsOwned proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + MaybeInputsSeen maybeInputsSeen; + try (MaybeInputsOwnedTransition transition = proposal.checkInputsNotOwned(new IsInputOwnedCallback(receiverRpc))) { + maybeInputsSeen = transition.save(persister); + } + try { + return processMaybeInputsSeen(maybeInputsSeen, persister, receiverRpc); + } finally { + maybeInputsSeen.close(); + } + } + + private PayjoinProposal processMaybeInputsSeen(MaybeInputsSeen proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + OutputsUnknown outputsUnknown; + try (MaybeInputsSeenTransition transition = proposal.checkNoInputsSeenBefore(new CheckInputsNotSeenCallback())) { + outputsUnknown = transition.save(persister); + } + try { + return processOutputsUnknown(outputsUnknown, persister, receiverRpc); + } finally { + outputsUnknown.close(); + } + } + + private PayjoinProposal processOutputsUnknown(OutputsUnknown proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + WantsOutputs wantsOutputs; + try (OutputsUnknownTransition transition = proposal.identifyReceiverOutputs(new IsScriptOwnedCallback(receiverRpc))) { + wantsOutputs = transition.save(persister); + } + try { + return processWantsOutputs(wantsOutputs, persister, receiverRpc); + } finally { + wantsOutputs.close(); + } + } + + private PayjoinProposal processWantsOutputs(WantsOutputs proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + WantsInputs wantsInputs; + try (WantsOutputsTransition transition = proposal.commitOutputs()) { + wantsInputs = transition.save(persister); + } + try { + return processWantsInputs(wantsInputs, persister, receiverRpc); + } finally { + wantsInputs.close(); + } + } + + private PayjoinProposal processWantsInputs(WantsInputs proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + List inputs = getInputs(receiverRpc); + WantsFeeRange wantsFeeRange; + try { + try (WantsInputs contributed = proposal.contributeInputs(inputs)) { + try (WantsInputsTransition transition = contributed.commitInputs()) { + wantsFeeRange = transition.save(persister); + } + } + } finally { + for (InputPair input : inputs) { + input.close(); + } + } + try { + return processWantsFeeRange(wantsFeeRange, persister, receiverRpc); + } finally { + wantsFeeRange.close(); + } + } + + private PayjoinProposal processWantsFeeRange(WantsFeeRange proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + ProvisionalProposal provisional; + try (WantsFeeRangeTransition transition = proposal.applyFeeRange(1L, 10L)) { + provisional = transition.save(persister); + } + try { + return processProvisionalProposal(provisional, persister, receiverRpc); + } finally { + provisional.close(); + } + } + + private PayjoinProposal processProvisionalProposal(ProvisionalProposal proposal, + InMemoryPersisters.InMemoryReceiverPersister persister, RpcClient receiverRpc) throws Exception { + try (ProvisionalProposalTransition transition = proposal.finalizeProposal(new ProcessPsbtCallback(receiverRpc))) { + return transition.save(persister); + } + } + + private String waitForSenderProposal(PollingForProposal pollingForProposal, + InMemoryPersisters.InMemorySenderPersister persister, TestHttp http, String relay) throws Exception { + long deadline = System.nanoTime() + POLL_TIMEOUT_NS; + int attempts = 0; + PollingForProposal current = pollingForProposal; + while (System.nanoTime() < deadline) { + attempts++; + PollingForProposalTransitionOutcome outcome; + try (RequestOhttpContext posted = current.createPollRequest(relay)) { + byte[] body = http.post(posted.request()); + try (PollingForProposalTransition transition = current.processResponse(body, posted.ohttpCtx())) { + outcome = transition.save(persister); + } + } + if (outcome instanceof PollingForProposalTransitionOutcome.Progress progress) { + // The final `current` handle is deliberately left for the UniFFI cleaner here - + // same as the Kotlin bindings' IntegrationTests.kt in this exact case. + return progress.psbtBase64(); + } else if (outcome instanceof PollingForProposalTransitionOutcome.Stasis stasis) { + if (current != pollingForProposal) { + current.close(); + } + current = stasis.inner(); + Thread.sleep(POLL_SLEEP_MS); + } else { + throw new IllegalStateException("unreachable: unknown PollingForProposalTransitionOutcome"); + } + } + fail("Timed out waiting for receiver proposal after " + attempts + " poll(s)"); + throw new AssertionError("unreachable"); + } + + private void finishPayjoin(RpcClient senderRpc, RpcClient receiverRpc, String psbtBase64, + Set senderOutpoints, Set receiverOutpoints) throws Exception { + String payjoinPsbt = JsonRpc.objectField(rpc(senderRpc, "walletprocesspsbt", jstr(psbtBase64)), "psbt"); + String finalPsbt = JsonRpc.objectField(rpc(senderRpc, "finalizepsbt", jstr(payjoinPsbt), "false"), "psbt"); + String finalTxHex = JsonRpc.objectField(rpc(senderRpc, "finalizepsbt", jstr(finalPsbt), "true"), "hex"); + String txid = JsonRpc.stringResult(rpc(senderRpc, "sendrawtransaction", jstr(finalTxHex))); + assertTrue(!txid.isEmpty(), "sendrawtransaction should accept the payjoin"); + + JsonRpc.Value decodedTx = JsonRpc.parse(rpc(senderRpc, "decoderawtransaction", jstr(finalTxHex))); + List vins = decodedTx.get("vin").asArray(); + List vouts = decodedTx.get("vout").asArray(); + assertEquals(2, vins.size()); + assertEquals(1, vouts.size()); + + Set spent = new HashSet<>(); + for (JsonRpc.Value vin : vins) { + spent.add(new OutpointRef(vin.get("txid").asString(), (int) vin.get("vout").asDouble())); + } + assertTrue(spent.stream().anyMatch(senderOutpoints::contains), "final tx should spend a sender input"); + assertTrue(spent.stream().anyMatch(receiverOutpoints::contains), "final tx should spend a receiver input"); + } + + private static String rpc(RpcClient client, String method, String... params) throws Exception { + List paramList = new ArrayList<>(List.of(params)); + return client.call(method, paramList); + } + + // String-valued RPC params go through jstr(); JSON structure ([], objects, numbers, true/false) is passed raw. + private static String jstr(String value) { + StringBuilder sb = new StringBuilder(); + sb.append('"'); + for (char ch : value.toCharArray()) { + if (ch == '\\' || ch == '"') { + sb.append('\\'); + } + sb.append(ch); + } + sb.append('"'); + return sb.toString(); + } + + private static String buildSweepPsbt(RpcClient sender, PjUri pjUri) throws Exception { + String outputs = "{" + jstr(pjUri.address()) + ":50}"; + String options = "{\"lockUnspents\":true,\"fee_rate\":10,\"subtractFeeFromOutputs\":[0]}"; + String psbt = JsonRpc.objectField( + rpc(sender, "walletcreatefundedpsbt", "[]", outputs, "0", options), "psbt"); + return JsonRpc.objectField( + rpc(sender, "walletprocesspsbt", jstr(psbt), "true", jstr("ALL"), "false"), "psbt"); + } + + private static List getInputs(RpcClient rpcConnection) throws Exception { + List utxos = JsonRpc.parse(rpc(rpcConnection, "listunspent")).asArray(); + List pairs = new ArrayList<>(); + for (JsonRpc.Value utxo : utxos) { + String txid = utxo.get("txid").asString(); + int vout = (int) utxo.get("vout").asDouble(); + byte[] scriptPubkey = hexDecode(utxo.get("scriptPubKey").asString()); + long amountSat = Math.round(utxo.get("amount").asDouble() * 100_000_000.0); + TxIn txIn = new TxIn(new OutPoint(txid, vout), new byte[0], 0, List.of()); + PsbtInput psbtIn = new PsbtInput(new TxOut(amountSat, scriptPubkey), null, null); + pairs.add(new InputPair(txIn, psbtIn, null)); + } + return pairs; + } + + private static Set listOutpoints(RpcClient client) throws Exception { + List utxos = JsonRpc.parse(rpc(client, "listunspent")).asArray(); + Set outpoints = new HashSet<>(); + for (JsonRpc.Value utxo : utxos) { + outpoints.add(new OutpointRef(utxo.get("txid").asString(), (int) utxo.get("vout").asDouble())); + } + return outpoints; + } + + private static byte[] hexDecode(String hex) { + return java.util.HexFormat.of().parseHex(hex); + } + + private record OutpointRef(String txid, int vout) { + } + + /** + * Kotlin PR #1869 review lesson (chavic, on the equivalent Kotlin callback): "Can we let RPC + * errors fail the test here? Returning false treats an RPC failure as 'input not owned', so a + * broken ownership check goes unnoticed." Every callback below follows the same rule: only a + * cleanly-parsed, legitimate negative answer from bitcoind returns false. An RPC/transport + * failure or a response that doesn't parse as expected throws ForeignException.InternalException + * instead - the interfaces below all declare `throws ForeignException`, so this crosses the FFI + * boundary as a real error and fails the test loudly, rather than silently becoming "not owned"/ + * "not broadcastable". + */ + private static ForeignException.InternalException wrapRpcFailure(String what, Exception cause) { + return new ForeignException.InternalException(what + ": " + cause); + } + + private static final class MempoolAcceptanceCallback implements CanBroadcast { + private final RpcClient connection; + + MempoolAcceptanceCallback(RpcClient connection) { + this.connection = connection; + } + + @Override + public boolean callback(byte[] tx) throws ForeignException { + // A real mempool rejection ("allowed": false, parsed successfully) is a legitimate + // false; only the RPC call/response parsing itself throws. + JsonRpc.Value result; + try { + String hexTx = java.util.HexFormat.of().formatHex(tx); + result = JsonRpc.parse(rpc(connection, "testmempoolaccept", "[" + jstr(hexTx) + "]")); + } catch (Exception e) { + throw wrapRpcFailure("testmempoolaccept RPC failed", e); + } + try { + return result.asArray().get(0).get("allowed").asBoolean(); + } catch (Exception e) { + throw wrapRpcFailure("unexpected testmempoolaccept response shape: " + result, e); + } + } + } + + private static final class IsScriptOwnedCallback implements IsScriptOwned { + private final RpcClient connection; + + IsScriptOwnedCallback(RpcClient connection) { + this.connection = connection; + } + + @Override + public boolean callback(byte[] script) throws ForeignException { + JsonRpc.Value decoded; + try { + decoded = JsonRpc.parse(rpc(connection, "decodescript", jstr(java.util.HexFormat.of().formatHex(script)))); + } catch (Exception e) { + throw wrapRpcFailure("decodescript RPC failed", e); + } + List candidates = new ArrayList<>(); + if (decoded.has("address")) { + candidates.add(decoded.get("address").asString()); + } + if (decoded.has("addresses")) { + for (JsonRpc.Value a : decoded.get("addresses").asArray()) { + candidates.add(a.asString()); + } + } + if (decoded.has("p2sh")) { + candidates.add(decoded.get("p2sh").asString()); + } + if (decoded.has("segwit")) { + JsonRpc.Value segwit = decoded.get("segwit"); + if (segwit.has("address")) { + candidates.add(segwit.get("address").asString()); + } + if (segwit.has("addresses")) { + for (JsonRpc.Value a : segwit.get("addresses").asArray()) { + candidates.add(a.asString()); + } + } + } + for (String addr : candidates) { + JsonRpc.Value info; + try { + info = JsonRpc.parse(rpc(connection, "getaddressinfo", jstr(addr))); + } catch (Exception e) { + throw wrapRpcFailure("getaddressinfo RPC failed for " + addr, e); + } + if (info.has("ismine") && info.get("ismine").asBoolean()) { + return true; + } + } + return false; + } + } + + private static final class IsInputOwnedCallback implements IsInputOwned { + private final RpcClient connection; + + IsInputOwnedCallback(RpcClient connection) { + this.connection = connection; + } + + @Override + public boolean callback(OutPoint outpoint) throws ForeignException { + JsonRpc.Value txOut; + try { + txOut = JsonRpc.parse( + rpc(connection, "gettxout", jstr(outpoint.txid()), String.valueOf(outpoint.vout()), "true")); + } catch (Exception e) { + throw wrapRpcFailure("gettxout RPC failed for " + outpoint.txid() + ":" + outpoint.vout(), e); + } + // A null result is bitcoind's normal answer for a spent/nonexistent output, not a + // failure - legitimately "can't be ours" rather than "unknown". + if (txOut.isNull()) { + return false; + } + String scriptHex; + try { + scriptHex = txOut.get("scriptPubKey").get("hex").asString(); + } catch (Exception e) { + throw wrapRpcFailure("unexpected gettxout response shape: " + txOut, e); + } + return new IsScriptOwnedCallback(connection).callback(hexDecode(scriptHex)); + } + } + + private static final class CheckInputsNotSeenCallback implements IsOutputKnown { + @Override + public boolean callback(OutPoint outpoint) { + return false; + } + } + + private static final class ProcessPsbtCallback implements ProcessPsbt { + private final RpcClient connection; + + ProcessPsbtCallback(RpcClient connection) { + this.connection = connection; + } + + @Override + public String callback(String psbt) throws ForeignException { + try { + return JsonRpc.objectField(rpc(connection, "walletprocesspsbt", jstr(psbt)), "psbt"); + } catch (Exception e) { + throw wrapRpcFailure("walletprocesspsbt RPC failed", e); + } + } + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/JsonRpc.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/JsonRpc.java new file mode 100644 index 000000000..d5c329185 --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/JsonRpc.java @@ -0,0 +1,227 @@ +package org.payjoindevkit; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A minimal JSON parser for reading bitcoind RPC responses in {@link BIP77IntegrationTest}. The + * JDK has no built-in JSON parser and bitcoind's response shapes here are simple (objects, + * arrays, strings, numbers, booleans, null) - this avoids adding a JSON library dependency for + * what a couple hundred lines of straightforward recursive-descent parsing covers. Not a + * general-purpose JSON library: no streaming, no configurable number types, not built for reuse + * outside this test. + */ +final class JsonRpc { + private JsonRpc() { + } + + static Value parse(String json) { + Parser parser = new Parser(json); + Value value = parser.parseValue(); + parser.skipWhitespace(); + if (!parser.atEnd()) { + throw new IllegalArgumentException("Trailing content in JSON: " + json); + } + return value; + } + + /** {@code result} field convenience for bitcoind's {@code call()} wrapper, when the result is a bare string. */ + static String stringResult(String json) { + return parse(json).asString(); + } + + /** {@code result.} convenience, when the result is a JSON object. */ + static String objectField(String json, String field) { + return parse(json).get(field).asString(); + } + + /** A parsed JSON value: null, boolean, number (as double), string, array, or object. */ + static final class Value { + private final Object raw; + + private Value(Object raw) { + this.raw = raw; + } + + boolean isNull() { + return raw == null; + } + + boolean asBoolean() { + return (Boolean) raw; + } + + double asDouble() { + return (Double) raw; + } + + String asString() { + return (String) raw; + } + + @SuppressWarnings("unchecked") + List asArray() { + return (List) raw; + } + + boolean has(String field) { + return asObject().containsKey(field); + } + + Value get(String field) { + Value value = asObject().get(field); + if (value == null) { + throw new IllegalArgumentException("Missing JSON field: " + field); + } + return value; + } + + @SuppressWarnings("unchecked") + private Map asObject() { + return (Map) raw; + } + } + + private static final class Parser { + private final String json; + private int pos; + + Parser(String json) { + this.json = json; + } + + boolean atEnd() { + return pos >= json.length(); + } + + void skipWhitespace() { + while (pos < json.length() && Character.isWhitespace(json.charAt(pos))) { + pos++; + } + } + + Value parseValue() { + skipWhitespace(); + char c = json.charAt(pos); + return switch (c) { + case '{' -> parseObject(); + case '[' -> parseArray(); + case '"' -> new Value(parseString()); + case 't' -> parseLiteral("true", Boolean.TRUE); + case 'f' -> parseLiteral("false", Boolean.FALSE); + case 'n' -> parseLiteral("null", null); + default -> parseNumber(); + }; + } + + private Value parseLiteral(String literal, Object value) { + if (!json.startsWith(literal, pos)) { + throw new IllegalArgumentException("Invalid JSON literal at " + pos + " in: " + json); + } + pos += literal.length(); + return new Value(value); + } + + private Value parseObject() { + expect('{'); + Map fields = new LinkedHashMap<>(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return new Value(fields); + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + fields.put(key, parseValue()); + skipWhitespace(); + char next = json.charAt(pos++); + if (next == '}') { + break; + } + if (next != ',') { + throw new IllegalArgumentException("Expected ',' or '}' at " + (pos - 1) + " in: " + json); + } + } + return new Value(fields); + } + + private Value parseArray() { + expect('['); + List items = new ArrayList<>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return new Value(items); + } + while (true) { + items.add(parseValue()); + skipWhitespace(); + char next = json.charAt(pos++); + if (next == ']') { + break; + } + if (next != ',') { + throw new IllegalArgumentException("Expected ',' or ']' at " + (pos - 1) + " in: " + json); + } + } + return new Value(items); + } + + private String parseString() { + expect('"'); + StringBuilder sb = new StringBuilder(); + while (true) { + char c = json.charAt(pos++); + if (c == '"') { + break; + } + if (c == '\\') { + char escaped = json.charAt(pos++); + switch (escaped) { + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + case '/' -> sb.append('/'); + case 'n' -> sb.append('\n'); + case 't' -> sb.append('\t'); + case 'r' -> sb.append('\r'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'u' -> { + String hex = json.substring(pos, pos + 4); + pos += 4; + sb.append((char) Integer.parseInt(hex, 16)); + } + default -> throw new IllegalArgumentException("Invalid escape \\" + escaped); + } + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private Value parseNumber() { + int start = pos; + while (pos < json.length() && "-+.0123456789eE".indexOf(json.charAt(pos)) >= 0) { + pos++; + } + return new Value(Double.parseDouble(json.substring(start, pos))); + } + + private char peek() { + return json.charAt(pos); + } + + private void expect(char c) { + char actual = json.charAt(pos++); + if (actual != c) { + throw new IllegalArgumentException("Expected '" + c + "' at " + (pos - 1) + " in: " + json); + } + } + } +} diff --git a/payjoin-ffi/java/src/test/java/org/payjoindevkit/TestHttp.java b/payjoin-ffi/java/src/test/java/org/payjoindevkit/TestHttp.java new file mode 100644 index 000000000..d83bb675c --- /dev/null +++ b/payjoin-ffi/java/src/test/java/org/payjoindevkit/TestHttp.java @@ -0,0 +1,76 @@ +package org.payjoindevkit; + +import java.io.ByteArrayInputStream; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.time.Duration; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +/** + * HTTP client for the v2 integration harness. A near-direct Java port of the Kotlin bindings' + * {@code TestHttp.kt} (same class, same behavior) - that file is itself already close to plain + * Java, so this only adjusts syntax, not approach. + * + *

The in-process directory serves HTTPS with a self-signed certificate from + * {@code payjoin-test-utils} ({@code local_cert_key()}, SANs {@code localhost} and + * {@code 0.0.0.0}). This client trusts that one certificate and nothing else, and sends every + * request through the OHTTP relay as an HTTP proxy. + */ +final class TestHttp implements AutoCloseable { + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + private final HttpClient client; + + TestHttp(TestServices services) throws Exception { + this.client = buildClient(services); + } + + byte[] post(Request request) throws Exception { + HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(request.url())) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", request.contentType()) + .POST(HttpRequest.BodyPublishers.ofByteArray(request.body())) + .build(); + HttpResponse response = client.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); + int status = response.statusCode(); + if (status < 200 || status >= 300) { + throw new IllegalStateException("HTTP " + status + " posting to " + request.url()); + } + return response.body(); + } + + @Override + public void close() { + client.close(); + } + + private static HttpClient buildClient(TestServices services) throws Exception { + Duration timeout = Duration.ofSeconds(30); + URI relay = URI.create(services.ohttpRelayUrl()); + int port = relay.getPort() == -1 ? 80 : relay.getPort(); + return HttpClient.newBuilder() + .connectTimeout(timeout) + .sslContext(sslContextTrusting(services.cert())) + .proxy(ProxySelector.of(new InetSocketAddress(relay.getHost(), port))) + .build(); + } + + private static SSLContext sslContextTrusting(byte[] certDer) throws Exception { + var cert = CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(certDer)); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setCertificateEntry("directory", cert); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext; + } +} From 7fa67f69b9409bb547db370861316f34108ea0bf Mon Sep 17 00:00:00 2001 From: ram0verflow Date: Tue, 15 Sep 2026 15:06:06 +0530 Subject: [PATCH 5/5] Add Java dev shell and CI workflow 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. --- .github/workflows/java.yml | 45 ++++++++++++++++++++++++++++++++++++++ flake.nix | 32 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 .github/workflows/java.yml diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 000000000..a2e18856c --- /dev/null +++ b/.github/workflows/java.yml @@ -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' diff --git a/flake.nix b/flake.nix index afee40989..33aa89c43 100644 --- a/flake.nix +++ b/flake.nix @@ -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 @@ -522,6 +553,7 @@ csharp = csharpDevShell; dart = dartDevShell; kotlin = kotlinDevShell; + java = javaDevShell; }; formatter = treefmtEval.config.build.wrapper; checks =