diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml new file mode 100644 index 000000000..fbda675ae --- /dev/null +++ b/.github/workflows/kotlin.yml @@ -0,0 +1,38 @@ +name: Build and Test Kotlin +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 kotlin dev shell, so changes to + # the flake change this workflow's environment. + - flake.nix + - flake.lock + - .github/workflows/kotlin.yml + +jobs: + build-kotlin-and-test: + name: "Build and test kotlin" + 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 .#kotlin -c bash ./payjoin-ffi/kotlin/contrib/test.sh diff --git a/flake.nix b/flake.nix index 981a9f0cd..2b57b61fd 100644 --- a/flake.nix +++ b/flake.nix @@ -389,6 +389,24 @@ BITCOIND_SKIP_DOWNLOAD = 1; }; + kotlinDevShell = pkgs.mkShell { + name = "kotlin-dev"; + packages = + with pkgs; + [ + rustVersions.msrv + jdk21 + 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 @@ -502,6 +520,7 @@ javascript = javascriptDevShell; csharp = csharpDevShell; dart = dartDevShell; + kotlin = kotlinDevShell; }; formatter = treefmtEval.config.build.wrapper; checks = diff --git a/payjoin-ffi/README.md b/payjoin-ffi/README.md index 284a76978..b56a17290 100644 --- a/payjoin-ffi/README.md +++ b/payjoin-ffi/README.md @@ -16,6 +16,7 @@ The directories below include instructions for using, building, and publishing t | Dart | linux, macOS | [payjoin-ffi/dart](dart) | [pub.dev](https://pub.dev/packages/payjoin) | | JavaScript | linux, macOS | [payjoin-ffi/javascript](javascript) | [npm](https://www.npmjs.com/package/payjoin) | | C# | linux, macOS, windows | [payjoin-ffi/csharp](csharp) | [nuget](https://www.nuget.org/packages/Payjoin) | +| Kotlin | linux, macOS | [payjoin-ffi/kotlin](kotlin) | (not published) | ## Minimum Supported Rust Version (MSRV) diff --git a/payjoin-ffi/contrib/test.sh b/payjoin-ffi/contrib/test.sh index 19689fa69..ebb258159 100755 --- a/payjoin-ffi/contrib/test.sh +++ b/payjoin-ffi/contrib/test.sh @@ -2,7 +2,7 @@ set -e cd "$(dirname "$0")/.." cargo test --package payjoin-ffi --verbose --features=_manual-tls,_test-utils -BINDINGS="dart javascript python csharp" +BINDINGS="dart javascript python csharp kotlin" pids=() tmpfiles=() for binding in $BINDINGS; do diff --git a/payjoin-ffi/kotlin/.gitignore b/payjoin-ffi/kotlin/.gitignore new file mode 100644 index 000000000..526ee0881 --- /dev/null +++ b/payjoin-ffi/kotlin/.gitignore @@ -0,0 +1,15 @@ +# Generated UniFFI Kotlin +src/main/kotlin/org/ + +# Native library copied by generate_bindings.sh +/lib/*.so +/lib/*.dylib +/lib/*.dll + +# Gradle +.gradle/ +build/ +local.properties + +.idea/ +.DS_Store diff --git a/payjoin-ffi/kotlin/CONTRIBUTING.md b/payjoin-ffi/kotlin/CONTRIBUTING.md new file mode 100644 index 000000000..b79bd6ca8 --- /dev/null +++ b/payjoin-ffi/kotlin/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing to the Payjoin Kotlin Bindings + +Kotlin/JVM bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/), generated from +`payjoin-ffi` with Mozilla UniFFI. 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/kotlin +bash ./scripts/generate_bindings.sh +./gradlew test +``` + +Generation uses the in-tree `uniffi-bindgen` binary (`--language kotlin`). There is no extra Cargo +feature on `payjoin-ffi`. 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.kotlin.rename]` in +`payjoin-ffi/uniffi.toml`, so it does not clash with `AutoCloseable.close()`. + +With nix, `nix develop .#kotlin` provides the pinned MSRV Rust toolchain, JDK 21, and +`BITCOIND_EXE` (via `nixpkgs`, with `BITCOIND_SKIP_DOWNLOAD=1`), and is what CI uses. Without +nix, use a local JDK 21+ and the Gradle wrapper; `corepc-node` will download `bitcoind` on +first test run. diff --git a/payjoin-ffi/kotlin/README.md b/payjoin-ffi/kotlin/README.md new file mode 100644 index 000000000..c5807e467 --- /dev/null +++ b/payjoin-ffi/kotlin/README.md @@ -0,0 +1,34 @@ +# Payjoin Kotlin Bindings + +Kotlin/JVM bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/), generated from `payjoin-ffi` with UniFFI (`--language kotlin`). + +Payjoin lets the receiver contribute inputs to the sender's transaction. 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). + +Requires **JDK 21+**. Native `payjoin_ffi` is loaded via JNA. + +Protocol session teardown on pending-fallback and JSON persisters is `closeSession()`. `AutoCloseable.close()` drops the Rust handle (try-with-resources / `.use`). Other language bindings keep `close`. + +## Build and test + +```shell +cd payjoin-ffi/kotlin +bash ./scripts/generate_bindings.sh +./gradlew test +``` + +Or `bash ./contrib/test.sh` from this directory (uses `Cargo-recent.lock`). + +`./gradlew test` includes a v2↔v2 integration test that starts a local payjoin directory, OHTTP relay, and bitcoind. Inside the nix `.#kotlin` shell, `BITCOIND_EXE` points at the nixpkgs +`bitcoind` and `BITCOIND_SKIP_DOWNLOAD=1` is set, so no download happens. Outside the nix shell, +Bitcoin Core is downloaded by corepc-node (`29_0`) on first run instead. + +Without nix: Rust (see repo `rust-toolchain.toml` / MSRV 1.85), JDK 21+, and the Gradle wrapper in this directory. Network access is required the first time bitcoind is fetched. + +## Stability + +Pre-1.0. Generated sources are not committed; run generate before test or pack. + +## Documentation + +- [Payjoin Dev Kit](https://payjoindevkit.org/) +- [rust-payjoin](https://github.com/payjoin/rust-payjoin) diff --git a/payjoin-ffi/kotlin/build.gradle.kts b/payjoin-ffi/kotlin/build.gradle.kts new file mode 100644 index 000000000..687a920bf --- /dev/null +++ b/payjoin-ffi/kotlin/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + // KGP's Gradle compatibility matrix must cover the wrapper version pinned in + // gradle/wrapper/gradle-wrapper.properties (currently 9.1.0). 2.1.20 only tests + // against Gradle up to 8.12.1; 2.3.20+ is the first stable KGP line whose matrix + // extends to 9.1.0 (tested up to 9.3.0). + kotlin("jvm") version "2.3.21" +} + +repositories { + mavenCentral() +} + +dependencies { + api("net.java.dev.jna:jna:5.17.0") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } +} + +tasks.test { + useJUnitPlatform() + val libDir = layout.projectDirectory.dir("lib").asFile + val native = listOf("libpayjoin_ffi.so", "libpayjoin_ffi.dylib", "payjoin_ffi.dll") + .map { libDir.resolve(it) } + .firstOrNull { it.exists() } + if (native != null) { + inputs.file(native) + systemProperty("uniffi.component.payjoin.libraryOverride", native.absolutePath) + } + systemProperty("jna.library.path", libDir.absolutePath) +} diff --git a/payjoin-ffi/kotlin/contrib/test.sh b/payjoin-ffi/kotlin/contrib/test.sh new file mode 100755 index 000000000..046df6079 --- /dev/null +++ b/payjoin-ffi/kotlin/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/kotlin" + +echo "==> Generating FFI bindings..." +bash ./scripts/generate_bindings.sh + +echo "==> Running Kotlin tests..." +./gradlew --no-daemon test diff --git a/payjoin-ffi/kotlin/gradle.properties b/payjoin-ffi/kotlin/gradle.properties new file mode 100644 index 000000000..f1e88d6a4 --- /dev/null +++ b/payjoin-ffi/kotlin/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx1g +kotlin.code.style=official diff --git a/payjoin-ffi/kotlin/gradle/wrapper/gradle-wrapper.jar b/payjoin-ffi/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..8bdaf60c7 Binary files /dev/null and b/payjoin-ffi/kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/payjoin-ffi/kotlin/gradle/wrapper/gradle-wrapper.properties b/payjoin-ffi/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..26dec6cde --- /dev/null +++ b/payjoin-ffi/kotlin/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/kotlin/gradlew b/payjoin-ffi/kotlin/gradlew new file mode 100755 index 000000000..adff685a0 --- /dev/null +++ b/payjoin-ffi/kotlin/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/kotlin/gradlew.bat b/payjoin-ffi/kotlin/gradlew.bat new file mode 100644 index 000000000..c4bdd3ab8 --- /dev/null +++ b/payjoin-ffi/kotlin/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/kotlin/scripts/generate_bindings.sh b/payjoin-ffi/kotlin/scripts/generate_bindings.sh new file mode 100755 index 000000000..6199d8841 --- /dev/null +++ b/payjoin-ffi/kotlin/scripts/generate_bindings.sh @@ -0,0 +1,50 @@ +#!/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 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../.." + +echo "Generating payjoin Kotlin..." +PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES-_test-utils} +PAYJOIN_FFI_PROFILE=${PAYJOIN_FFI_PROFILE:-dev} +if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then + TARGET_PROFILE_DIR=debug +else + TARGET_PROFILE_DIR=$PAYJOIN_FFI_PROFILE +fi +FEATURE_ARGS=() +if [[ -n $PAYJOIN_FFI_FEATURES ]]; then + FEATURE_ARGS=(--features "$PAYJOIN_FFI_FEATURES") +fi + +cargo build "${FEATURE_ARGS[@]}" --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi + +OUT_DIR="kotlin/src/main/kotlin" +mkdir -p "$OUT_DIR" +rm -rf "$OUT_DIR/org" + +# ktlint is optional; --no-format keeps generate working without it. +cargo run "${FEATURE_ARGS[@]}" --profile dev -p payjoin-ffi --bin uniffi-bindgen -- generate \ + --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \ + --language kotlin \ + --out-dir "$OUT_DIR" \ + --no-format + +mkdir -p kotlin/lib +cp "../target/$TARGET_PROFILE_DIR/$LIBNAME" "kotlin/lib/$LIBNAME" + +echo "All done!" diff --git a/payjoin-ffi/kotlin/settings.gradle.kts b/payjoin-ffi/kotlin/settings.gradle.kts new file mode 100644 index 000000000..29aa1e341 --- /dev/null +++ b/payjoin-ffi/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "payjoin" diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/CancelTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/CancelTests.kt new file mode 100644 index 000000000..73ff6a5ae --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/CancelTests.kt @@ -0,0 +1,75 @@ +package org.payjoindevkit + +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class CancelTests { + @Test + fun receiverCancel() { + val persister = InMemoryReceiverPersister() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + val initialized = ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + ohttpKeys, + ).build().save(persister) + val fallbackTx = initialized.cancel().save(persister) + assertNull(fallbackTx) + assertIs(replayReceiverEventLog(persister).state()) + } + + @Test + fun receiverCancelAsync() = runTest { + val persister = InMemoryReceiverPersisterAsync() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + val initialized = ReceiverBuilder( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + "https://example.com", + ohttpKeys, + ).build().saveAsync(persister) + val fallbackTx = initialized.cancel().saveAsync(persister) + assertNull(fallbackTx) + assertIs(replayReceiverEventLogAsync(persister).state()) + } + + @Test + fun senderCancel() { + val receiverPersister = InMemoryReceiverPersister() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + val receiver = ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + ohttpKeys, + ).build().save(receiverPersister) + val uri = receiver.pjUri() + val persister = InMemorySenderPersister() + val withReplyKey = SenderBuilder(originalPsbt(), uri).buildRecommended(1000u).save(persister) + val pendingFallback = withReplyKey.cancel().save(persister) + assertTrue(pendingFallback.fallbackTx().isNotEmpty()) + assertIs(replaySenderEventLog(persister).state()) + pendingFallback.closeSession().save(persister) + assertIs(replaySenderEventLog(persister).state()) + } + + @Test + fun senderCancelAsync() = runTest { + val receiverPersister = InMemoryReceiverPersisterAsync() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + val receiver = ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + ohttpKeys, + ).build().saveAsync(receiverPersister) + val uri = receiver.pjUri() + val persister = InMemorySenderPersisterAsync() + val withReplyKey = SenderBuilder(originalPsbt(), uri).buildRecommended(1000u).saveAsync(persister) + val pendingFallback = withReplyKey.cancel().saveAsync(persister) + assertTrue(pendingFallback.fallbackTx().isNotEmpty()) + assertIs(replaySenderEventLogAsync(persister).state()) + pendingFallback.closeSession().saveAsync(persister) + assertIs(replaySenderEventLogAsync(persister).state()) + } +} diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/InMemoryPersisters.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/InMemoryPersisters.kt new file mode 100644 index 000000000..ef7954140 --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/InMemoryPersisters.kt @@ -0,0 +1,41 @@ +package org.payjoindevkit + +internal abstract class MemoryEventLog { + private val events = java.util.concurrent.CopyOnWriteArrayList() + @Volatile var closed: Boolean = false + private set + + fun save(event: String) { + events.add(event) + } + + fun load(): List = events.toList() + + fun closeSession() { + closed = true + } +} + +internal abstract class MemoryEventLogAsync { + private val events = java.util.concurrent.CopyOnWriteArrayList() + @Volatile var closed: Boolean = false + private set + + suspend fun save(event: String) { + events.add(event) + } + + suspend fun load(): List = events.toList() + + suspend fun closeSession() { + closed = true + } +} + +internal class InMemoryReceiverPersister : MemoryEventLog(), JsonReceiverSessionPersister + +internal class InMemorySenderPersister : MemoryEventLog(), JsonSenderSessionPersister + +internal class InMemoryReceiverPersisterAsync : MemoryEventLogAsync(), JsonReceiverSessionPersisterAsync + +internal class InMemorySenderPersisterAsync : MemoryEventLogAsync(), JsonSenderSessionPersisterAsync diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt new file mode 100644 index 000000000..10f4b9f64 --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/IntegrationTests.kt @@ -0,0 +1,455 @@ +package org.payjoindevkit + +import java.util.HexFormat +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.double +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Full BIP 77 v2↔v2 round trip against the in-process directory, OHTTP relay, + * and bitcoind. Mirrors `payjoin-ffi/python/test/test_payjoin_integration_test.py` + * (`test_integration_v2_to_v2`): same RPC sequence, same receiver checklist, + * same final assertions, plus an explicit check that the broadcast transaction + * spends coins from both wallets. + * + * Every [TestServices] call takes a global runtime mutex and blocks, so sender + * and receiver are driven strictly in sequence. + */ +class IntegrationTests { + @Test + fun v2ToV2Payjoin() { + initTracing() + TestServices.initialize().use { services -> + services.waitForServicesReady() + val directory = services.directoryUrl() + val relay = services.ohttpRelayUrl() + services.fetchOhttpKeys().use { ohttpKeys -> + TestHttp(services).use { http -> + initBitcoindSenderReceiver().use { env -> + env.getSender().use { senderRpc -> + env.getReceiver().use { receiverRpc -> + runV2ToV2( + http, + directory, + relay, + ohttpKeys, + senderRpc, + receiverRpc, + ) + } + } + } + } + } + } + } + + private fun runV2ToV2( + http: TestHttp, + directory: String, + relay: String, + ohttpKeys: OhttpKeys, + senderRpc: RpcClient, + receiverRpc: RpcClient, + ) { + val receiverAddress = Json.parseToJsonElement(rpc(receiverRpc, "getnewaddress")).jsonPrimitive.content + val senderOutpoints = listOutpoints(senderRpc) + val receiverOutpoints = listOutpoints(receiverRpc) + val recvPersister = InMemoryReceiverPersister() + val sendPersister = InMemorySenderPersister() + + // ********************** + // Inside the Receiver: + val session = ReceiverBuilder(receiverAddress, directory, ohttpKeys).use { builder -> + builder.build().use { it.save(recvPersister) } + } + session.use { + val firstPoll = pollReceiver(session, recvPersister, http, relay) + assertEquals(null, firstPoll, "receiver mailbox should be empty before the sender posts") + + // ********************** + // Inside the Sender: + val pjUri = session.pjUri() + pjUri.use { + val originalPsbt = buildSweepPsbt(senderRpc, pjUri) + val withReplyKey = SenderBuilder(originalPsbt, pjUri).use { senderBuilder -> + senderBuilder.buildRecommended(1000u).use { it.save(sendPersister) } + } + withReplyKey.use { + val pollingForProposal = withReplyKey.createV2PostRequest(relay).useDisposable { posted -> + val body = http.post(posted.request) + withReplyKey.processResponse(body, posted.ohttpCtx).use { it.save(sendPersister) } + } + pollingForProposal.use { + // ********************** + // Inside the Receiver: + val payjoinProposal = waitForReceiverProposal(session, recvPersister, http, relay, receiverRpc) + payjoinProposal.use { + payjoinProposal.createPostRequest(relay).useDisposable { posted -> + val body = http.post(posted.request) + payjoinProposal.processResponse(body, posted.clientResponse).use { transition -> + transition.save(recvPersister).use { } + } + } + + // ********************** + // Inside the Sender: + val psbtBase64 = waitForSenderProposal(pollingForProposal, sendPersister, http, relay) + finishPayjoin( + senderRpc, + receiverRpc, + psbtBase64, + senderOutpoints, + receiverOutpoints, + ) + } + } + } + } + } + + recvPersister.closeSession() + sendPersister.closeSession() + } + + private fun pollReceiver( + session: Initialized, + recvPersister: InMemoryReceiverPersister, + http: TestHttp, + relay: String, + ): UncheckedOriginalPayload? { + return session.createPollRequest(relay).useDisposable { requestResponse -> + val body = http.post(requestResponse.request) + session.processResponse(body, requestResponse.clientResponse).use { transition -> + when (val outcome = transition.save(recvPersister)) { + // Progress retains outcome.inner as the return value; destroying the enum would free that handle. + is InitializedTransitionOutcome.Progress -> outcome.inner + is InitializedTransitionOutcome.Stasis -> { + outcome.destroy() + null + } + } + } + } + } + + private fun waitForReceiverProposal( + session: Initialized, + recvPersister: InMemoryReceiverPersister, + http: TestHttp, + relay: String, + receiverRpc: RpcClient, + ): PayjoinProposal { + val deadline = System.nanoTime() + POLL_TIMEOUT_NS + var attempts = 0 + while (System.nanoTime() < deadline) { + attempts += 1 + val original = pollReceiver(session, recvPersister, http, relay) + if (original != null) { + return original.use { processUncheckedProposal(it, recvPersister, receiverRpc) } + } + Thread.sleep(POLL_SLEEP_MS) + } + fail("Timed out waiting for sender original after $attempts poll(s)") + } + + private fun processUncheckedProposal( + proposal: UncheckedOriginalPayload, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val maybeInputsOwned = proposal.checkBroadcastSuitability(null, MempoolAcceptanceCallback(receiverRpc)) + .use { it.save(recvPersister) } + return maybeInputsOwned.use { processMaybeInputsOwned(it, recvPersister, receiverRpc) } + } + + private fun processMaybeInputsOwned( + proposal: MaybeInputsOwned, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val maybeInputsSeen = proposal.checkInputsNotOwned(IsInputOwnedCallback(receiverRpc)) + .use { it.save(recvPersister) } + return maybeInputsSeen.use { processMaybeInputsSeen(it, recvPersister, receiverRpc) } + } + + private fun processMaybeInputsSeen( + proposal: MaybeInputsSeen, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val outputsUnknown = proposal.checkNoInputsSeenBefore(CheckInputsNotSeenCallback()) + .use { it.save(recvPersister) } + return outputsUnknown.use { processOutputsUnknown(it, recvPersister, receiverRpc) } + } + + private fun processOutputsUnknown( + proposal: OutputsUnknown, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val wantsOutputs = proposal.identifyReceiverOutputs(IsScriptOwnedCallback(receiverRpc)) + .use { it.save(recvPersister) } + return wantsOutputs.use { processWantsOutputs(it, recvPersister, receiverRpc) } + } + + private fun processWantsOutputs( + proposal: WantsOutputs, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val wantsInputs = proposal.commitOutputs().use { it.save(recvPersister) } + return wantsInputs.use { processWantsInputs(it, recvPersister, receiverRpc) } + } + + private fun processWantsInputs( + proposal: WantsInputs, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val inputs = getInputs(receiverRpc) + val wantsFeeRange = try { + proposal.contributeInputs(inputs).use { contributed -> + contributed.commitInputs().use { it.save(recvPersister) } + } + } finally { + inputs.forEach { it.close() } + } + return wantsFeeRange.use { processWantsFeeRange(it, recvPersister, receiverRpc) } + } + + private fun processWantsFeeRange( + proposal: WantsFeeRange, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + val provisional = proposal.applyFeeRange(1u, 10u).use { it.save(recvPersister) } + return provisional.use { processProvisionalProposal(it, recvPersister, receiverRpc) } + } + + private fun processProvisionalProposal( + proposal: ProvisionalProposal, + recvPersister: InMemoryReceiverPersister, + receiverRpc: RpcClient, + ): PayjoinProposal { + return proposal.finalizeProposal(ProcessPsbtCallback(receiverRpc)).use { it.save(recvPersister) } + } + + private fun waitForSenderProposal( + pollingForProposal: PollingForProposal, + sendPersister: InMemorySenderPersister, + http: TestHttp, + relay: String, + ): String { + val deadline = System.nanoTime() + POLL_TIMEOUT_NS + var attempts = 0 + var sender = pollingForProposal + while (System.nanoTime() < deadline) { + attempts += 1 + val outcome = sender.createPollRequest(relay).useDisposable { pollReq -> + val body = http.post(pollReq.request) + sender.processResponse(body, pollReq.ohttpCtx).use { it.save(sendPersister) } + } + when (outcome) { + // Progress returns a PSBT string, not a live handle; same rule as receiver Progress — do not destroy. + is PollingForProposalTransitionOutcome.Progress -> return outcome.psbtBase64 + is PollingForProposalTransitionOutcome.Stasis -> { + if (sender !== pollingForProposal) { + sender.close() + } + sender = outcome.inner + Thread.sleep(POLL_SLEEP_MS) + } + } + } + fail("Timed out waiting for receiver proposal after $attempts poll(s)") + } + + private fun finishPayjoin( + senderRpc: RpcClient, + receiverRpc: RpcClient, + psbtBase64: String, + senderOutpoints: Set, + receiverOutpoints: Set, + ) { + val payjoinPsbt = Json.parseToJsonElement(rpc(senderRpc, "walletprocesspsbt", jstr(psbtBase64))) + .jsonObject.getValue("psbt").jsonPrimitive.content + val finalPsbt = Json.parseToJsonElement(rpc(senderRpc, "finalizepsbt", jstr(payjoinPsbt), "false")) + .jsonObject.getValue("psbt").jsonPrimitive.content + val finalTxHex = Json.parseToJsonElement(rpc(senderRpc, "finalizepsbt", jstr(finalPsbt), "true")) + .jsonObject.getValue("hex").jsonPrimitive.content + val txid = Json.parseToJsonElement(rpc(senderRpc, "sendrawtransaction", jstr(finalTxHex))) + .jsonPrimitive.content + assertTrue(txid.isNotEmpty(), "sendrawtransaction should accept the payjoin") + + val networkFees = Json.parseToJsonElement(rpc(senderRpc, "decodepsbt", jstr(finalPsbt))) + .jsonObject.getValue("fee").jsonPrimitive.double + val decodedTx = Json.parseToJsonElement(rpc(senderRpc, "decoderawtransaction", jstr(finalTxHex))) + .jsonObject + val vins = decodedTx.getValue("vin").jsonArray + val vouts = decodedTx.getValue("vout").jsonArray + assertEquals(2, vins.size) + assertEquals(1, vouts.size) + + val spent = vins.map { vin -> + val obj = vin.jsonObject + OutpointRef( + obj.getValue("txid").jsonPrimitive.content, + obj.getValue("vout").jsonPrimitive.double.toInt().toUInt(), + ) + }.toSet() + assertTrue(spent.any { it in senderOutpoints }, "final tx should spend a sender input") + assertTrue(spent.any { it in receiverOutpoints }, "final tx should spend a receiver input") + + val receiverPending = Json.parseToJsonElement(rpc(receiverRpc, "getbalances")) + .jsonObject.getValue("mine").jsonObject.getValue("untrusted_pending").jsonPrimitive.double + assertEquals(100.0 - networkFees, receiverPending, 1e-6) + val senderBalance = Json.parseToJsonElement(rpc(senderRpc, "getbalance")).jsonPrimitive.double + assertEquals(0.0, senderBalance, 1e-6) + } +} + +private const val POLL_SLEEP_MS = 250L +private const val POLL_TIMEOUT_NS = 30_000_000_000L + +private data class OutpointRef(val txid: String, val vout: UInt) + +private fun rpc(client: RpcClient, method: String, vararg params: String?): String = + client.call(method, params.toList()) + +// String-valued RPC params go through jstr(); JSON structure ([], objects, numbers, true/false) is passed raw. +private fun jstr(value: String): String = buildString { + append('"') + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + else -> append(ch) + } + } + append('"') +} + +private fun buildSweepPsbt(sender: RpcClient, pjUri: PjUri): String { + val outputs = "{${jstr(pjUri.address())}:50}" + val options = """{"lockUnspents":true,"fee_rate":10,"subtractFeeFromOutputs":[0]}""" + val psbt = Json.parseToJsonElement( + rpc(sender, "walletcreatefundedpsbt", "[]", outputs, "0", options), + ).jsonObject.getValue("psbt").jsonPrimitive.content + return Json.parseToJsonElement(rpc(sender, "walletprocesspsbt", jstr(psbt), "true", jstr("ALL"), "false")) + .jsonObject.getValue("psbt").jsonPrimitive.content +} + +private fun getInputs(rpcConnection: RpcClient): List { + val utxos = Json.parseToJsonElement(rpc(rpcConnection, "listunspent")).jsonArray + return utxos.map { utxo -> + val obj = utxo.jsonObject + val txid = obj.getValue("txid").jsonPrimitive.content + val vout = obj.getValue("vout").jsonPrimitive.double.toInt().toUInt() + val scriptPubkey = HexFormat.of().parseHex(obj.getValue("scriptPubKey").jsonPrimitive.content) + val amountSat = kotlin.math.round(obj.getValue("amount").jsonPrimitive.double * 100_000_000.0).toULong() + val txin = TxIn( + previousOutput = OutPoint(txid, vout), + scriptSig = ByteArray(0), + sequence = 0u, + witness = emptyList(), + ) + val psbtIn = PsbtInput( + witnessUtxo = TxOut(amountSat, scriptPubkey), + redeemScript = null, + witnessScript = null, + ) + InputPair(txin, psbtIn, null) + } +} + +private fun listOutpoints(client: RpcClient): Set = + Json.parseToJsonElement(rpc(client, "listunspent")).jsonArray.map { utxo -> + val obj = utxo.jsonObject + OutpointRef( + obj.getValue("txid").jsonPrimitive.content, + obj.getValue("vout").jsonPrimitive.double.toInt().toUInt(), + ) + }.toSet() + +private class MempoolAcceptanceCallback(private val connection: RpcClient) : CanBroadcast { + override fun callback(tx: ByteArray): Boolean { + return try { + val hexTx = HexFormat.of().formatHex(tx) + Json.parseToJsonElement(rpc(connection, "testmempoolaccept", "[${jstr(hexTx)}]")) + .jsonArray[0] + .jsonObject.getValue("allowed").jsonPrimitive.boolean + } catch (_: Exception) { + false + } + } +} + +private class IsScriptOwnedCallback(private val connection: RpcClient) : IsScriptOwned { + override fun callback(script: ByteArray): Boolean { + return try { + val decoded = Json.parseToJsonElement( + rpc(connection, "decodescript", jstr(HexFormat.of().formatHex(script))), + ).jsonObject + val candidates = mutableListOf() + decoded["address"]?.jsonPrimitive?.contentOrNull?.let { candidates.add(it) } + decoded["addresses"]?.jsonArray?.forEach { item -> + if (item.jsonPrimitive.isString) candidates.add(item.jsonPrimitive.content) + } + decoded["p2sh"]?.jsonPrimitive?.contentOrNull?.let { candidates.add(it) } + decoded["segwit"]?.jsonObject?.let { segwit -> + segwit["address"]?.jsonPrimitive?.contentOrNull?.let { candidates.add(it) } + segwit["addresses"]?.jsonArray?.forEach { item -> + if (item.jsonPrimitive.isString) candidates.add(item.jsonPrimitive.content) + } + } + candidates.any { addr -> + Json.parseToJsonElement(rpc(connection, "getaddressinfo", jstr(addr))) + .jsonObject["ismine"]?.jsonPrimitive?.booleanOrNull == true + } + } catch (_: Exception) { + false + } + } +} + +private class IsInputOwnedCallback(private val connection: RpcClient) : IsInputOwned { + override fun callback(outpoint: OutPoint): Boolean { + return try { + val txOut = Json.parseToJsonElement( + rpc( + connection, + "gettxout", + jstr(outpoint.txid), + outpoint.vout.toString(), + "true", + ), + ) + if (txOut is JsonNull) return false + val scriptHex = txOut.jsonObject.getValue("scriptPubKey").jsonObject.getValue("hex").jsonPrimitive.content + IsScriptOwnedCallback(connection).callback(HexFormat.of().parseHex(scriptHex)) + } catch (_: Exception) { + false + } + } +} + +private class CheckInputsNotSeenCallback : IsOutputKnown { + override fun callback(outpoint: OutPoint): Boolean = false +} + +private class ProcessPsbtCallback(private val connection: RpcClient) : ProcessPsbt { + override fun callback(psbt: String): String = + Json.parseToJsonElement(rpc(connection, "walletprocesspsbt", jstr(psbt))) + .jsonObject.getValue("psbt").jsonPrimitive.content +} diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt new file mode 100644 index 000000000..2cbfcd03f --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/PersistenceTests.kt @@ -0,0 +1,79 @@ +package org.payjoindevkit + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class PersistenceTests { + @Test + fun receiverPersistence() { + val persister = InMemoryReceiverPersister() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + ReceiverBuilder("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys) + .build() + .save(persister) + val state = replayReceiverEventLog(persister).state() + assertIs(state) + assertFalse(persister.closed) + persister.closeSession() + assertTrue(persister.closed) + } + + @Test + fun senderPersistence() { + val receiverPersister = InMemoryReceiverPersister() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + val receiver = ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + ohttpKeys, + ).build().save(receiverPersister) + val uri = receiver.pjUri() + val senderPersister = InMemorySenderPersister() + SenderBuilder(originalPsbt(), uri).buildRecommended(1000u).save(senderPersister) + val state = replaySenderEventLog(senderPersister).state() + assertIs(state) + assertFalse(senderPersister.closed) + senderPersister.closeSession() + assertTrue(senderPersister.closed) + receiverPersister.closeSession() + assertTrue(receiverPersister.closed) + } + + @Test + fun receiverPersistenceAsync() = runTest { + val persister = InMemoryReceiverPersisterAsync() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + ReceiverBuilder("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com", ohttpKeys) + .build() + .saveAsync(persister) + val state = replayReceiverEventLogAsync(persister).state() + assertIs(state) + assertFalse(persister.closed) + persister.closeSession() + assertTrue(persister.closed) + } + + @Test + fun senderPersistenceAsync() = runTest { + val receiverPersister = InMemoryReceiverPersisterAsync() + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + val receiver = ReceiverBuilder( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + "https://example.com", + ohttpKeys, + ).build().saveAsync(receiverPersister) + val uri = receiver.pjUri() + val senderPersister = InMemorySenderPersisterAsync() + SenderBuilder(originalPsbt(), uri).buildRecommended(1000u).saveAsync(senderPersister) + val state = replaySenderEventLogAsync(senderPersister).state() + assertIs(state) + assertFalse(senderPersister.closed) + senderPersister.closeSession() + assertTrue(senderPersister.closed) + receiverPersister.closeSession() + assertTrue(receiverPersister.closed) + } +} diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt new file mode 100644 index 000000000..ff1351dda --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestFixtures.kt @@ -0,0 +1,8 @@ +package org.payjoindevkit + +import java.util.HexFormat + +internal val ohttpKeysData: ByteArray = + HexFormat.of().parseHex( + "01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003", + ) diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestHttp.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestHttp.kt new file mode 100644 index 000000000..d9e70ef8c --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/TestHttp.kt @@ -0,0 +1,74 @@ +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. + * + * The in-process directory serves HTTPS with a self-signed certificate from + * `payjoin-test-utils` (`local_cert_key()`, SANs `localhost` and `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. + */ +class TestHttp(services: TestServices) : AutoCloseable { + private val requestTimeout: Duration = Duration.ofSeconds(30) + private val client: HttpClient = buildClient(services) + + fun post(request: Request): ByteArray { + val httpRequest = HttpRequest.newBuilder(URI.create(request.url)) + .timeout(requestTimeout) + .header("Content-Type", request.contentType) + .POST(HttpRequest.BodyPublishers.ofByteArray(request.body)) + .build() + val response = client.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()) + val status = response.statusCode() + if (status < 200 || status >= 300) { + throw IllegalStateException("HTTP $status posting to ${request.url}") + } + return response.body() + } + + override fun close() { + client.close() + } + + companion object { + private fun buildClient(services: TestServices): HttpClient { + val timeout = Duration.ofSeconds(30) + val relay = URI.create(services.ohttpRelayUrl()) + val port = if (relay.port == -1) 80 else relay.port + return HttpClient.newBuilder() + .connectTimeout(timeout) + .sslContext(sslContextTrusting(services.cert())) + .proxy(ProxySelector.of(InetSocketAddress(relay.host, port))) + .build() + } + + private fun sslContextTrusting(certDer: ByteArray): SSLContext { + val cert = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(certDer)) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null, null) + keyStore.setCertificateEntry("directory", cert) + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(keyStore) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, tmf.trustManagers, null) + return sslContext + } + } +} + +internal inline fun T.useDisposable(block: (T) -> R): R = + try { block(this) } finally { destroy() } diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/UriTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/UriTests.kt new file mode 100644 index 000000000..56aa0ef43 --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/UriTests.kt @@ -0,0 +1,48 @@ +package org.payjoindevkit + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class UriTests { + @Test + fun urlEncodedPayjoinParameter() { + val uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao" + Uri.parse(uri).use { parsed -> + parsed.checkPjSupported().use { pjUri -> + assertContains(pjUri.pjEndpoint(), "example.com") + } + } + } + + @Test + fun missingAmountShouldBeOk() { + val uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj" + Uri.parse(uri).use { assertNotNull(it) } + } + + @Test + fun validUrisWithDifferentAddressesAndEndpoints() { + val https = exampleUrl() + val onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion" + val addresses = listOf( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4", + "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + ) + for (address in addresses) { + for (pj in listOf(https, onion)) { + Uri.parse("$address?amount=1&pj=$pj").use { assertNotNull(it) } + } + } + } + + @Test + fun uriParseSmoke() { + Uri.parse("bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4").use { uri -> + assertNotNull(uri.address()) + } + assertFailsWith { Uri.parse("not-a-uri") } + } +} diff --git a/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt new file mode 100644 index 000000000..c0bfd82e7 --- /dev/null +++ b/payjoin-ffi/kotlin/src/test/kotlin/org/payjoindevkit/ValidationTests.kt @@ -0,0 +1,38 @@ +package org.payjoindevkit + +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class ValidationTests { + @Test + fun receiverBuilderRejectsBadAddress() { + val ohttpKeys = OhttpKeys.decode(ohttpKeysData) + assertFailsWith { + ReceiverBuilder("not-an-address", "https://example.com", ohttpKeys) + } + } + + @Test + fun inputPairRejectsInvalidOutpoint() { + assertFailsWith { + val txin = TxIn( + previousOutput = OutPoint(txid = "deadbeef", vout = 0u), + scriptSig = ByteArray(0), + sequence = 0u, + witness = emptyList(), + ) + val psbtin = PsbtInput(witnessUtxo = null, redeemScript = null, witnessScript = null) + InputPair(txin, psbtin, null) + } + } + + @Test + fun senderBuilderRejectsBadPsbt() { + val uri = Uri.parse( + "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4?pj=https://example.com/pj", + ).checkPjSupported() + assertFailsWith { + SenderBuilder("not-a-psbt", uri) + } + } +} diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml index 9006539eb..f2be18d49 100644 --- a/payjoin-ffi/uniffi.toml +++ b/payjoin-ffi/uniffi.toml @@ -2,6 +2,16 @@ package_name = "org.payjoindevkit" cdylib_name = "payjoin_ffi" +# AutoCloseable/Disposable.close() drops the Rust handle. Payjoin also exports +# protocol close() on these types. Rename only the Kotlin protocol methods. +[bindings.kotlin.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"