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 = 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 000000000..8bdaf60c7 Binary files /dev/null and b/payjoin-ffi/java/gradle/wrapper/gradle-wrapper.jar differ 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" 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/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/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/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/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/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; + } +} 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(); + } + } + } + } +} 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"