From 1af3587c66e3e42f47bbb07f0ff002168e171116 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:54:52 +0000 Subject: [PATCH 1/8] Add the native build contract Each binding builds its native libraries in its own job, on its own runner, with whatever toolchain that job sets up, so no shipped file can be rebuilt bit for bit. Add one script that builds every shipped target on Linux with one pinned toolchain: contrib/native/build.sh --- contrib/native/README.md | 40 ++++++++++ contrib/native/apple-cc.sh | 21 ++++++ contrib/native/apple-stubs.sh | 31 ++++++++ contrib/native/build.sh | 133 +++++++++++++++++++++++++++++++++ contrib/native/vs-channel.json | 17 +++++ flake.nix | 63 ++++++++++++++++ 6 files changed, 305 insertions(+) create mode 100644 contrib/native/README.md create mode 100755 contrib/native/apple-cc.sh create mode 100755 contrib/native/apple-stubs.sh create mode 100755 contrib/native/build.sh create mode 100644 contrib/native/vs-channel.json diff --git a/contrib/native/README.md b/contrib/native/README.md new file mode 100644 index 000000000..e94e46f4f --- /dev/null +++ b/contrib/native/README.md @@ -0,0 +1,40 @@ +# Native build contract + +The payjoin-ffi native libraries are built on Linux, by one script, in one +devshell. The language bindings move onto these libraries one at a time. + +```sh +nix develop .#native -c contrib/native/build.sh +``` + +The Android targets need the NDK, which only `.#native-android` provides. + +The library lands in `//`. The +[native workflow](../../.github/workflows/native.yml) builds every target +twice, on separate runners from different paths, and fails unless both +builds are byte-identical. A macOS runner checks the Apple libraries +against Xcode and never produces any. + +| Invariant | Rule | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Toolchain | Rust 1.85.1. CI keeps testing the 1.85.0 MSRV. | +| Lockfile | `Cargo-recent.lock`, built `--locked` | +| Features | None. Bindings are generated separately, so the library is the same for every language. | +| Paths | The repository is remapped to `/payjoin`, the target dir to `/payjoin/target`, the cargo home to `/cargo`, the rustup home to `/rustup`, the NDK to `/ndk` and the Windows SDK to `/xwin`, for rustc and the C compiler | +| Profile | Cargo's release defaults | +| Linux | `cargo zigbuild` against glibc 2.17 | +| macOS | zig through `apple-cc.sh` against the stub SDK, deployment target 10.15 on x86_64 and 11.0 on arm64, Flutter's floor and the first macOS on Apple silicon. `universal2-apple-darwin` is the two slices combined with `llvm-lipo`. | +| iOS | zig through `apple-cc.sh` against the stub SDK, minimum iOS 13.0. Device and arm64 simulator only. | +| Windows | `xwin` fetches MSVC's CRT 14.44.17.14 and Windows SDK 10.0.26100 from the Visual Studio 17.14.37824.6 manifest pinned in `vs-channel.json`. LLVM 21's clang-cl, llvm-lib and lld-link build against them, linked with `/Brepro` and without a PDB. | +| Android | NDK 29.0.14206865 at API 21, 16 KB aligned on 64-bit by the NDK's default | + +The Apple dylibs are linked with an `@rpath/libpayjoin_ffi.dylib` install +name, `-headerpad_max_install_names` for Flutter's `install_name_tool`, and +`-S`, which keeps object paths and mtimes out of the stabs. +`apple-stubs.sh` generates the stub SDK from the macOS `libSystem.tbd` and +Darwin headers that zig ships, so no Apple SDK file is used or committed. + +`x86_64-apple-ios`, the Intel simulator, is not built. Rust 1.85.1's std +for it targets iOS 10.0, so its objects carry `LC_VERSION_MIN_IPHONEOS`, +which zig 0.16 reads as a device platform and rejects for a simulator +link. diff --git a/contrib/native/apple-cc.sh b/contrib/native/apple-cc.sh new file mode 100755 index 000000000..0d6176e20 --- /dev/null +++ b/contrib/native/apple-cc.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Compiler and linker for the Apple targets: zig for $APPLE_CC_TARGET +# against the stub SDK in $SDKROOT. The target flags rustc and cc-rs pass +# are dropped in favor of those. +set -euo pipefail + +args=() +while (($#)); do + case $1 in + -target | -arch | -isysroot) shift ;; + --target=* | -mmacosx-version-min=* | -mios-version-min=* | -mios-simulator-version-min=* | -miphoneos-version-min=*) ;; + *) args+=("$1") ;; + esac + shift +done + +# -headerpad_max_install_names leaves room for Flutter's install_name_tool, +# and -S keeps zig from writing object paths and mtimes into stabs. +exec zig cc -target "$APPLE_CC_TARGET" --sysroot "$SDKROOT" \ + -isystem "$SDKROOT/usr/include" -L/usr/lib \ + -Wl,-headerpad_max_install_names -Wl,-S "${args[@]}" diff --git a/contrib/native/apple-stubs.sh b/contrib/native/apple-stubs.sh new file mode 100755 index 000000000..6f7705385 --- /dev/null +++ b/contrib/native/apple-stubs.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Generate a stub Apple SDK in $1 from the macOS libSystem.tbd and Darwin +# headers that zig ships, so the iOS and macOS targets link on Linux +# without Apple's SDK. Every target list is widened to all four targets; +# the macOS verification job checks the imports against Xcode's real stubs. +set -euo pipefail + +sdk=$1 +zig_lib=$(zig env | sed -n 's/^ *\.lib_dir = "\(.*\)",$/\1/p') +apple_targets="[ arm64-ios, arm64-ios-simulator, arm64-macos, x86_64-macos ]" + +rm -rf "$sdk" +mkdir -p "$sdk/usr/include" "$sdk/usr/lib" +# The nix store copy is read-only, and the next run must remove it. +cp -R --no-preserve=mode "$zig_lib/libc/include/any-darwin-any/." "$sdk/usr/include/" +sed -zE "s/targets:( *)\[[^]]*\]/targets:\1$apple_targets/g" \ + "$zig_lib/libc/darwin/libSystem.tbd" >"$sdk/usr/lib/libSystem.tbd" +# std links libiconv on Apple targets, and zig ships no stub for it. +cat >"$sdk/usr/lib/libiconv.tbd" </. +set -euo pipefail + +target=$1 +out_dir=$(realpath -m "$2") +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +target_dir=$(realpath -m "${CARGO_TARGET_DIR:-$repo/target}") +target_env=${target//-/_} +CARGO_HOME=$(realpath -m "${CARGO_HOME:-$HOME/.cargo}") +export CARGO_HOME +# Two builds of the same zig version share a cache key, so keep the cache +# with the build rather than in $HOME. +export ZIG_GLOBAL_CACHE_DIR=$target_dir/zig-cache + +cd "$repo" +source contrib/lockfile.sh +use_lockfile Cargo-recent.lock + +# rustc and the C compiler see the same prefixes, and the last match wins. +prefixes=( + "$repo=/payjoin" + "$target_dir=/payjoin/target" + "$CARGO_HOME=/cargo" + "${RUSTUP_HOME:-$HOME/.rustup}=/rustup" +) +rustflags=() +cflags=() +cargo=(cargo build) +cargo_target=$target +cflag= + +case $target in + *-unknown-linux-gnu) + lib=libpayjoin_ffi.so + cargo=(cargo zigbuild) + cargo_target=$target.2.17 + ;; + *-pc-windows-msvc) + lib=payjoin_ffi.dll + arch=${target%%-*} + # MSVC's CRT and the Windows SDK from the Visual Studio manifest in + # vs-channel.json, splatted once per set of pins. + sdk=10.0.26100 + crt=14.44.17.14 + manifest=$(sha256sum contrib/native/vs-channel.json | cut -c1-16) + xwin_dir=${XDG_CACHE_HOME:-$HOME/.cache}/payjoin-xwin/$manifest-$sdk-$crt + if [[ ! -d $xwin_dir ]]; then + # Concurrent first builds splat their own copies, and the first + # one done is kept. + mkdir -p "$(dirname "$xwin_dir")" + tmp=$(mktemp -d "$xwin_dir.XXXXXX") + xwin --accept-license --cache-dir "$tmp/download" \ + --manifest contrib/native/vs-channel.json \ + --sdk-version "$sdk" --crt-version "$crt" \ + --arch x86_64,aarch64 splat --output "$tmp/splat" + mv -T "$tmp/splat" "$xwin_dir" 2>/dev/null || true + rm -rf "$tmp" + fi + export "CARGO_TARGET_${target_env^^}_LINKER=lld-link" \ + "CC_$target_env=clang-cl" "AR_$target_env=llvm-lib" + # lld-link otherwise stamps the build time, and the PDB rustc asks + # for gives the DLL an id that depends on the build paths. + rustflags+=(-Clink-arg=/Brepro -Clink-arg=/DEBUG:NONE) + for dir in crt/lib sdk/lib/um sdk/lib/ucrt; do + rustflags+=("-Lnative=$xwin_dir/$dir/$arch") + done + # clang-cl takes clang's own options behind /clang:. + cflag=/clang: + cflags+=("--target=$target") + for dir in crt/include sdk/include/ucrt sdk/include/um sdk/include/shared; do + cflags+=(/imsvc "$xwin_dir/$dir") + done + prefixes+=("$xwin_dir=/xwin") + ;; + *-linux-android*) + lib=libpayjoin_ffi.so + # API 21 is Rust's floor. NDK 29 aligns 64-bit libraries to 16 KB. + ndk_bin=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin + clang=$ndk_bin/${target/armv7/armv7a}21-clang + export "CARGO_TARGET_${target_env^^}_LINKER=$clang" "CC_$target_env=$clang" \ + "AR_$target_env=$ndk_bin/llvm-ar" + prefixes+=("$(realpath "$ANDROID_NDK_HOME")=/ndk") + ;; + *-apple-darwin) + # Flutter's floor, and the first macOS on Apple silicon. + macos=10.15 + if [[ $target == aarch64-* ]]; then + macos=11.0 + fi + export APPLE_CC_TARGET=${target%%-*}-macos.$macos + export MACOSX_DEPLOYMENT_TARGET=$macos + ;;& + aarch64-apple-ios | aarch64-apple-ios-sim) + export APPLE_CC_TARGET=aarch64-ios.13.0 + if [[ $target == *-sim ]]; then + APPLE_CC_TARGET+=-simulator + fi + export IPHONEOS_DEPLOYMENT_TARGET=13.0 + ;;& + *-apple-darwin | aarch64-apple-ios | aarch64-apple-ios-sim) + lib=libpayjoin_ffi.dylib + export SDKROOT=$target_dir/apple-stubs + bash contrib/native/apple-stubs.sh "$SDKROOT" + export "CARGO_TARGET_${target_env^^}_LINKER=$repo/contrib/native/apple-cc.sh" + export "CC_$target_env=$repo/contrib/native/apple-cc.sh" + export "AR_$target_env=$(command -v zig) ar" + rustflags+=("-Clink-arg=-Wl,-install_name,@rpath/$lib") + ;; + *) + echo "unsupported target: $target" >&2 + exit 1 + ;; +esac + +for prefix in "${prefixes[@]}"; do + rustflags+=("--remap-path-prefix=$prefix") + cflags+=("$cflag-ffile-prefix-map=$prefix") +done +# Quoted, because cc-rs would otherwise split a path with spaces. +export CC_SHELL_ESCAPED_FLAGS=1 +export "CFLAGS_$target_env=$(printf '%q ' "${cflags[@]}")" +CARGO_ENCODED_RUSTFLAGS=$( + IFS=$'\x1f' + echo "${rustflags[*]}" +) +export CARGO_ENCODED_RUSTFLAGS + +"${cargo[@]}" --locked --release --package payjoin-ffi --lib \ + --target "$cargo_target" --target-dir "$target_dir" +mkdir -p "$out_dir/$target" +cp "$target_dir/$target/release/$lib" "$out_dir/$target/" diff --git a/contrib/native/vs-channel.json b/contrib/native/vs-channel.json new file mode 100644 index 000000000..2ecc4fea0 --- /dev/null +++ b/contrib/native/vs-channel.json @@ -0,0 +1,17 @@ +{ + "channelItems": [ + { + "id": "Microsoft.VisualStudio.Manifests.VisualStudio", + "version": "17.14.37824.6", + "type": "Manifest", + "payloads": [ + { + "fileName": "VisualStudio.vsman", + "sha256": "5bd3b0b2ae509af2e9559b852f78545df0bf2c081dd850caed76f1dc134c511e", + "size": 30443792, + "url": "https://download.visualstudio.microsoft.com/download/pr/4bc08c27-bd31-470c-9a30-3b62707fbf0f/5bd3b0b2ae509af2e9559b852f78545df0bf2c081dd850caed76f1dc134c511e/VisualStudio.vsman" + } + ] + } + ] +} diff --git a/flake.nix b/flake.nix index 160ab703e..e06aaf633 100644 --- a/flake.nix +++ b/flake.nix @@ -74,6 +74,8 @@ }; msrv-version = "1.85.0"; + # Rust version used to build release artifacts. + release-version = "1.85.1"; nginxWithStream = pkgs.nginxMainline.overrideAttrs (oldAttrs: { configureFlags = oldAttrs.configureFlags ++ [ @@ -472,6 +474,65 @@ ''; }; + # Release builds of the payjoin-ffi native library through + # contrib/native/build.sh. The tools come from unstable: the release + # branch only has zig 0.15, and 0.17 regressed the iOS Mach-O linker. + unstablePkgs = nixpkgs-unstable.legacyPackages.${system}; + + nativeRustToolchain = pkgs.rust-bin.stable.${release-version}.minimal.override { + targets = [ + "aarch64-apple-ios" + "aarch64-apple-ios-sim" + "aarch64-unknown-linux-gnu" + "x86_64-unknown-linux-gnu" + "aarch64-apple-darwin" + "x86_64-apple-darwin" + "aarch64-pc-windows-msvc" + "x86_64-pc-windows-msvc" + "aarch64-linux-android" + "armv7-linux-androideabi" + "x86_64-linux-android" + ]; + }; + + # The NDK and the SDK parts it pulls in are unfree, so they come from + # an instance used for nothing else. + androidPkgs = import nixpkgs-unstable { + inherit system; + config = { + android_sdk.accept_license = true; + allowUnfree = true; + }; + }; + + androidNdk = + (androidPkgs.androidenv.composeAndroidPackages { + includeNDK = true; + ndkVersions = [ "29.0.14206865" ]; + }).ndk-bundle; + + nativeDevShell = pkgs.mkShell { + name = "native"; + packages = with unstablePkgs; [ + nativeRustToolchain + zig_0_16 + # nixpkgs wraps cargo-zigbuild with its default zig, 0.17. + (cargo-zigbuild.override { zig = zig_0_16; }) + xwin + # The Windows targets compile C with clang-cl, archive with + # llvm-lib and link with lld-link. + llvmPackages_21.clang-unwrapped + llvmPackages_21.lld + llvmPackages_21.llvm + ]; + }; + + # Only the Android builds fetch the NDK. + nativeAndroidDevShell = nativeDevShell.overrideAttrs { + name = "native-android"; + ANDROID_NDK_HOME = "${androidNdk}/libexec/android-sdk/ndk-bundle"; + }; + simpleCheck = args: pkgs.stdenvNoCC.mkDerivation ( @@ -525,6 +586,8 @@ csharp = csharpDevShell; dart = dartDevShell; kotlin = kotlinDevShell; + native = nativeDevShell; + native-android = nativeAndroidDevShell; }; formatter = treefmtEval.config.build.wrapper; checks = From d51b0a0ff12045e36f4b2244ace10361c009a89a Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:54:52 +0000 Subject: [PATCH 2/8] Check in CI that the native libraries reproduce Nothing yet checks that build.sh produces the same bytes twice. Build each target twice, on separate runners. The second build runs from another checkout path, home and cargo home, all with a space in them, so any path that leaks into a library breaks the match, including one from a cache the toolchain keeps under the home directory. A compare job fails unless the second build matches the first one's hashes. Once they match, the compare job combines the two macOS slices with llvm-lipo into universal2-apple-darwin, the file Python ships. lipo output depends only on its inputs, so the derived file needs no second build. Every checked library is uploaded as one `payjoin-native` artifact, laid out by triple, for the bindings to package. The workflow runs in the daily bindings run and not on pull requests: two builds of eleven targets is 22 jobs, too many for every PR, so scheduled and release runs carry this check. --- .github/workflows/cron-daily-bindings.yml | 2 + .github/workflows/native.yml | 113 ++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .github/workflows/native.yml diff --git a/.github/workflows/cron-daily-bindings.yml b/.github/workflows/cron-daily-bindings.yml index 5da0b4e0e..ac95ea388 100644 --- a/.github/workflows/cron-daily-bindings.yml +++ b/.github/workflows/cron-daily-bindings.yml @@ -23,3 +23,5 @@ jobs: uses: $/.github/workflows/dart.yml kotlin: uses: $/.github/workflows/kotlin.yml + native: + uses: $/.github/workflows/native.yml diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 000000000..4f849d388 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,113 @@ +name: Native libraries + +# Builds the payjoin-ffi native library for each target twice, on separate +# runners from different paths, and fails unless both builds are +# byte-identical. + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.target }} (${{ matrix.build }}) + runs-on: ubuntu-26.04 + timeout-minutes: 60 + strategy: + matrix: + target: + - aarch64-apple-ios + - aarch64-apple-ios-sim + - aarch64-unknown-linux-gnu + - x86_64-unknown-linux-gnu + - aarch64-apple-darwin + - x86_64-apple-darwin + - aarch64-pc-windows-msvc + - x86_64-pc-windows-msvc + - aarch64-linux-android + - armv7-linux-androideabi + - x86_64-linux-android + build: [first, second] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # unneeded: no git auth beyond checkout + - name: Set up nix + uses: $/.github/actions/setup-nix + - name: Build + env: + TARGET: ${{ matrix.target }} + BUILD: ${{ matrix.build }} + run: | + set -euo pipefail + # The second build runs from other paths, with a space in them, so + # any path that leaks into the library breaks the match. Its own + # home moves the caches the toolchain keeps there, such as zig's. + repo=$GITHUB_WORKSPACE + if [[ $BUILD == second ]]; then + repo="$RUNNER_TEMP/second build/payjoin" + mkdir -p "$repo" + cp -R . "$repo" + export HOME="$RUNNER_TEMP/second build/home" CARGO_HOME="$RUNNER_TEMP/second build/cargo" + fi + shell=native + if [[ $TARGET == *-android* ]]; then + shell=native-android + fi + nix develop ".#$shell" -c "$repo/contrib/native/build.sh" "$TARGET" "$RUNNER_TEMP/out" + - name: Upload the library + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: native-${{ matrix.build }}-${{ matrix.target }} + path: ${{ runner.temp }}/out + if-no-files-found: error + + compare: + name: Compare the two builds + needs: build + runs-on: ubuntu-26.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # unneeded: no git auth beyond checkout + - name: Set up nix + uses: $/.github/actions/setup-nix + - name: Download the first build + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: native-first-* + merge-multiple: true + path: ${{ runner.temp }}/first + - name: Download the second build + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: native-second-* + merge-multiple: true + path: ${{ runner.temp }}/second + - name: Compare + working-directory: ${{ runner.temp }} + run: | + set -euo pipefail + (cd first && sha256sum -- */*) | tee SHA256SUMS + (cd second && sha256sum -c ../SHA256SUMS) + # Both slices just matched, so the file derived from them needs no + # second build of its own. + - name: Derive the universal2 macOS library + run: | + set -euo pipefail + dir="$RUNNER_TEMP/first" + mkdir -p "$dir/universal2-apple-darwin" + nix shell --inputs-from . nixpkgs-unstable#llvmPackages_21.llvm -c llvm-lipo -create \ + "$dir/aarch64-apple-darwin/libpayjoin_ffi.dylib" \ + "$dir/x86_64-apple-darwin/libpayjoin_ffi.dylib" \ + -output "$dir/universal2-apple-darwin/libpayjoin_ffi.dylib" + - name: Upload the libraries + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: payjoin-native + path: ${{ runner.temp }}/first + if-no-files-found: error From 601f30ff5f3fbb377ab9276dcedbc15c141b077b Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:54:52 +0000 Subject: [PATCH 3/8] Verify the Apple libraries on a macOS runner The Linux builds link against stubs derived from zig's macOS libSystem, not Apple's SDKs, so a symbol missing on the platform would link and then fail to load. Nothing has yet loaded these libraries on an Apple system. Add a macOS job that verifies the libraries and never produces any: * Xcode's linker resolves every import of each iOS and macOS library against the real SDK of its platform. * Each library, the universal2 one included, is renamed with install_name_tool and signed with codesign, as Flutter does when it wraps a dylib in a framework. * A small Flutter app runs a smoke test in the arm64 simulator. A stand-in build hook makes it bundle the Linux-built library, so the test goes through Flutter's own embed path with those exact bytes. The smoke test does not reuse the Dart unit tests, because those call helpers from the `_test-utils` feature, which the shipped library is built without. --- .github/workflows/native.yml | 76 ++++++++++++++++++- .../integration_test/payjoin_test.dart | 60 +++++++++++++++ .../contrib/ios-simulator/prebuilt_hook.dart | 19 +++++ .../dart/contrib/ios-simulator/pubspec.yaml | 23 ++++++ 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 payjoin-ffi/dart/contrib/ios-simulator/integration_test/payjoin_test.dart create mode 100644 payjoin-ffi/dart/contrib/ios-simulator/prebuilt_hook.dart create mode 100644 payjoin-ffi/dart/contrib/ios-simulator/pubspec.yaml diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 4f849d388..4c46af493 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -2,7 +2,8 @@ name: Native libraries # Builds the payjoin-ffi native library for each target twice, on separate # runners from different paths, and fails unless both builds are -# byte-identical. +# byte-identical. The libraries only ever come from Linux. The macOS job +# checks the Apple ones against Xcode and produces nothing. on: workflow_call: @@ -111,3 +112,76 @@ jobs: name: payjoin-native path: ${{ runner.temp }}/first if-no-files-found: error + + verify-apple: + name: Verify the Apple libraries on macOS + needs: compare + runs-on: macos-latest + timeout-minutes: 60 + env: + RUSTUP_TOOLCHAIN: 1.85.0 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # unneeded: no git auth beyond checkout + - name: Download the libraries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: payjoin-native + path: ${{ runner.temp }}/native + - name: Check the imports against Xcode's SDKs + working-directory: ${{ runner.temp }}/native + run: | + set -euo pipefail + # Apple's linker resolves every symbol a library imports against + # the SDK of the platform it runs on, and fails on any it lacks. + check() { + local triple=$1 target=$2 sdk=$3 + nm -u -j "$triple/libpayjoin_ffi.dylib" | sed 's/^/-Wl,-u,/' >"$triple.args" + xcrun --sdk "$sdk" clang -target "$target" -dynamiclib -x c /dev/null \ + -liconv @"$triple.args" -o "$RUNNER_TEMP/$triple-imports.dylib" + } + check aarch64-apple-ios arm64-apple-ios13.0 iphoneos + check aarch64-apple-ios-sim arm64-apple-ios13.0-simulator iphonesimulator + check aarch64-apple-darwin arm64-apple-macos11.0 macosx + check x86_64-apple-darwin x86_64-apple-macos10.15 macosx + - name: Rename and sign each library the way Flutter embeds it + run: | + set -euo pipefail + lipo "$RUNNER_TEMP/native/universal2-apple-darwin/libpayjoin_ffi.dylib" -verify_arch arm64 x86_64 + for lib in "$RUNNER_TEMP"/native/*/libpayjoin_ffi.dylib; do + cp "$lib" "$RUNNER_TEMP/payjoin_ffi" + install_name_tool -id @rpath/payjoin_ffi.framework/payjoin_ffi "$RUNNER_TEMP/payjoin_ffi" + codesign --force --sign - "$RUNNER_TEMP/payjoin_ffi" + codesign --verify --verbose "$RUNNER_TEMP/payjoin_ffi" + done + - name: "Install Rust 1.85.0" + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: 1.85.0 + - name: Set up Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + channel: stable + flutter-version: 3.44.9 + - name: Generate the Dart bindings + run: | + set -euo pipefail + cp Cargo-recent.lock Cargo.lock + cd payjoin-ffi/dart + # The library carries no features, so neither may the bindings. + PAYJOIN_FFI_FEATURES='' bash scripts/generate_bindings.sh + - name: Run a smoke test in the iOS simulator + working-directory: payjoin-ffi/dart + run: | + set -euo pipefail + # Bundle the libraries built on Linux instead of building one. + cp contrib/ios-simulator/prebuilt_hook.dart hook/build.dart + cp -R "$RUNNER_TEMP/native" prebuilt + cd contrib/ios-simulator + flutter create --platforms ios . + udid=$(xcrun simctl list devices available --json | + jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid') + xcrun simctl boot "$udid" + flutter test integration_test -d "$udid" diff --git a/payjoin-ffi/dart/contrib/ios-simulator/integration_test/payjoin_test.dart b/payjoin-ffi/dart/contrib/ios-simulator/integration_test/payjoin_test.dart new file mode 100644 index 000000000..a8eceeccc --- /dev/null +++ b/payjoin-ffi/dart/contrib/ios-simulator/integration_test/payjoin_test.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:payjoin/payjoin.dart' as payjoin; + +// The bundled library is built without the test utilities, so the unit tests +// that call them cannot run here. +class _ReceiverPersister implements payjoin.JsonReceiverSessionPersister { + final List events = []; + + @override + void save(String event) => events.add(event); + + @override + List load() => events; + + @override + void close() {} +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + test('parses a payjoin URI', () { + final uri = payjoin.Uri.parse( + uri: 'bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com', + ); + expect(uri.address(), '12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX'); + }); + + test('rejects userinfo in the payjoin URL', () { + expect( + () => payjoin.Uri.parse( + uri: 'bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example@evil.com', + ), + throwsA(isA()), + ); + }); + + test('persists a new receiver session', () { + final persister = _ReceiverPersister(); + payjoin.ReceiverBuilder( + address: 'tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4', + directory: 'https://example.com', + ohttpKeys: payjoin.OhttpKeys.decode( + bytes: Uint8List.fromList( + hex.decode( + '01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382af588d370957000400010003', + ), + ), + ), + ).build().save(persister: persister); + expect( + payjoin.replayReceiverEventLog(persister: persister).state(), + isA(), + ); + }); +} diff --git a/payjoin-ffi/dart/contrib/ios-simulator/prebuilt_hook.dart b/payjoin-ffi/dart/contrib/ios-simulator/prebuilt_hook.dart new file mode 100644 index 000000000..e2476a74b --- /dev/null +++ b/payjoin-ffi/dart/contrib/ios-simulator/prebuilt_hook.dart @@ -0,0 +1,19 @@ +import 'package:code_assets/code_assets.dart'; +import 'package:hooks/hooks.dart'; + +// Replaces the package's build hook in the iOS simulator check, so the app +// bundles the library cross-built on Linux instead of building its own. +void main(List args) async { + await build(args, (input, output) async { + output.assets.code.add( + CodeAsset( + package: input.packageName, + name: 'uniffi:payjoin_ffi', + linkMode: DynamicLoadingBundled(), + file: input.packageRoot.resolve( + 'prebuilt/aarch64-apple-ios-sim/libpayjoin_ffi.dylib', + ), + ), + ); + }); +} diff --git a/payjoin-ffi/dart/contrib/ios-simulator/pubspec.yaml b/payjoin-ffi/dart/contrib/ios-simulator/pubspec.yaml new file mode 100644 index 000000000..79515bb64 --- /dev/null +++ b/payjoin-ffi/dart/contrib/ios-simulator/pubspec.yaml @@ -0,0 +1,23 @@ +name: payjoin_ios_simulator +description: >- + A Flutter app that runs a payjoin smoke test in the iOS simulator against + the library cross-built on Linux. +publish_to: none +version: 0.0.0 + +environment: + sdk: ^3.10.0 + +dependencies: + flutter: + sdk: flutter + # CI swaps in prebuilt_hook.dart as the package's build hook. + payjoin: + path: ../.. + +dev_dependencies: + convert: ^3.1.0 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter From 169c7f5669971eea923f90341014bdb22e7b0091 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:55:07 +0000 Subject: [PATCH 4/8] Build the C# native assets with build.sh The NuGet native assets were cross-built with their own toolchain: cargo-zigbuild and cargo-xwin from pip, clang-cl and llvm-lib from the runner image, Rust 1.85.0, and the `csharp` feature, which only the bindings generator needs but which changes the library's metadata hash. None of it followed the native build contract, so the shipped files could not be rebuilt bit for bit. Build each RID with contrib/native/build.sh in the native devshell, the same command the native workflow checks for reproducibility. The library is featureless and the toolchain comes from the flake, so the pip pins, the clang-cl symlinks and the cross branch of build_nuget_native.sh go away. The per-RID smoke tests still install the packed nupkg on each platform. --- .github/workflows/csharp.yml | 63 +++------------ payjoin-ffi/csharp/CONTRIBUTING.md | 10 +-- .../csharp/scripts/build_nuget_native.sh | 79 +------------------ .../csharp/scripts/cross-requirements.txt | 19 ----- 4 files changed, 16 insertions(+), 155 deletions(-) delete mode 100644 payjoin-ffi/csharp/scripts/cross-requirements.txt diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 0d8118188..647dbe366 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -107,22 +107,12 @@ jobs: run: dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal" build-nuget-native: - # Every RID cross-compiles from Linux: cargo-zigbuild links the Linux targets - # against a pinned glibc floor and the macOS targets against zig's bundled - # libSystem stubs; cargo-xwin links MSVC-ABI Windows targets against the - # Microsoft CRT/SDK. The per-RID smoke jobs verify each produced asset on - # real hardware. + # Every RID builds on Linux with the native build contract's script, the + # one the native workflow checks for reproducibility. The per-RID smoke + # jobs verify each produced asset on real hardware. name: "Build NuGet native asset" if: github.event_name != 'pull_request' runs-on: ubuntu-26.04 - env: - # Must match the dtolnay/rust-toolchain version exactly: the cross-target - # std components are installed for that toolchain, and a partial version - # here selects a different rustup toolchain entry without them (E0463). - RUSTUP_TOOLCHAIN: 1.85.0 - defaults: - run: - working-directory: payjoin-ffi/csharp strategy: # Let every RID report its own result; a failure in one target should not # cancel the signal from the others. @@ -147,54 +137,19 @@ jobs: with: persist-credentials: false # unneeded: no git auth beyond checkout - - name: Install Rust 1.85.0 - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 - with: - toolchain: 1.85.0 - targets: ${{ matrix.target }} - - - name: Use cache - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - lookup-only: ${{ startsWith(github.ref, 'refs/tags/') }} - - - name: Install cross toolchain - # Version- and hash-pinned: these tools produce the shipped binaries. - run: pip install --require-hashes -r scripts/cross-requirements.txt - - - name: Install MSVC toolchain frontends (clang-cl, llvm-lib) - if: startsWith(matrix.rid, 'win-') - # cargo-xwin compiles C dependencies with clang-cl and archives them with - # llvm-lib. The runner image ships LLVM with versioned binary names only, - # so expose the unversioned names cc-rs looks for. - run: | - for tool in clang-cl llvm-lib; do - if command -v "$tool" >/dev/null; then - continue - fi - latest=$(ls -1 /usr/bin/"$tool"-* 2>/dev/null | sort -V | tail -n 1) - if [ -z "$latest" ]; then - sudo apt-get update -q - sudo apt-get install -y -q llvm clang - latest=$(ls -1 /usr/bin/"$tool"-* 2>/dev/null | sort -V | tail -n 1) - fi - if [ -z "$latest" ]; then - echo "$tool not found on the runner" >&2 - exit 1 - fi - sudo ln -s "$latest" /usr/local/bin/"$tool" - done - command -v clang-cl llvm-lib + - name: Set up nix + uses: $/.github/actions/setup-nix - name: Build release native asset - run: | - PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=${{ matrix.rid }} bash ./scripts/build_nuget_native.sh + env: + TARGET: ${{ matrix.target }} + run: nix develop .#native -c contrib/native/build.sh "$TARGET" "$RUNNER_TEMP/out" - name: Upload NuGet native asset uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.rid }} - path: payjoin-ffi/csharp/artifacts/runtimes/${{ matrix.rid }}/native/* + path: ${{ runner.temp }}/out/${{ matrix.target }}/* if-no-files-found: error pack-nuget: diff --git a/payjoin-ffi/csharp/CONTRIBUTING.md b/payjoin-ffi/csharp/CONTRIBUTING.md index 513ecc61e..747f35509 100644 --- a/payjoin-ffi/csharp/CONTRIBUTING.md +++ b/payjoin-ffi/csharp/CONTRIBUTING.md @@ -52,13 +52,13 @@ the C# bindings): bash ./scripts/build_nuget_native.sh ``` -Any supported RID can also be cross-compiled from a Linux host, which is how CI -builds every native asset (`pip install -r scripts/cross-requirements.txt` for -the version- and hash-pinned toolchain CI uses, and `rustup target add` the -matching triple first): +CI builds every RID's native asset on Linux under the +[native build contract](../../contrib/native/README.md). To build one the same +way, run from the repository root with the target triple for the RID, then copy +`//*` to `payjoin-ffi/csharp/artifacts/runtimes/{rid}/native/`: ```shell -PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=osx-arm64 bash ./scripts/build_nuget_native.sh +nix develop .#native -c contrib/native/build.sh aarch64-apple-darwin ``` On Windows: diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.sh b/payjoin-ffi/csharp/scripts/build_nuget_native.sh index 6a9c94fe4..1d8433d2b 100755 --- a/payjoin-ffi/csharp/scripts/build_nuget_native.sh +++ b/payjoin-ffi/csharp/scripts/build_nuget_native.sh @@ -55,50 +55,6 @@ native_library_name() { esac } -# Rust target triple for cross-compiling a RID from a Linux host. Linux targets pin a -# glibc floor through zig so the produced .so loads on older distributions instead of -# inheriting the build host's glibc version. -rid_to_cross_target() { - case "$1" in - linux-x64) - echo "x86_64-unknown-linux-gnu.$PAYJOIN_FFI_GLIBC_FLOOR" - ;; - linux-arm64) - echo "aarch64-unknown-linux-gnu.$PAYJOIN_FFI_GLIBC_FLOOR" - ;; - osx-x64) - echo "x86_64-apple-darwin" - ;; - osx-arm64) - echo "aarch64-apple-darwin" - ;; - win-x64) - echo "x86_64-pc-windows-msvc" - ;; - win-arm64) - echo "aarch64-pc-windows-msvc" - ;; - *) - echo "Unsupported RID for cross build: $1" >&2 - exit 1 - ;; - esac -} - -# cargo-zigbuild links Linux (with a chosen glibc floor) and macOS targets (zig ships -# redistributable libSystem stubs); cargo-xwin links MSVC-ABI Windows targets against -# the Microsoft CRT/SDK it fetches. -rid_to_cross_tool() { - case "$1" in - linux-* | osx-*) - echo "zigbuild" - ;; - win-*) - echo "xwin" - ;; - esac -} - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CSHARP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -114,42 +70,11 @@ LIBNAME=$(native_library_name "$RID") export PAYJOIN_FFI_FEATURES="${PAYJOIN_FFI_FEATURES-}" export PAYJOIN_FFI_PROFILE="${PAYJOIN_FFI_PROFILE:-release}" -PAYJOIN_FFI_GLIBC_FLOOR=${PAYJOIN_FFI_GLIBC_FLOOR:-2.17} - -if [[ ${PAYJOIN_FFI_CROSS:-0} == 1 ]]; then - # Cross-compile the native library for $RID from a Linux host. Bindings are never - # generated here; the pack step generates the production bindings it packages. - TARGET=$(rid_to_cross_target "$RID") - TOOL=$(rid_to_cross_tool "$RID") - if ! command -v "cargo-$TOOL" >/dev/null; then - echo "cargo-$TOOL is required for cross builds: pip install cargo-zigbuild cargo-xwin" >&2 - exit 1 - fi - - CROSS_CARGO=(cargo "$TOOL") - if [[ $TOOL == xwin ]]; then - CROSS_CARGO+=(build) - fi - - GENERATOR_FEATURES="csharp${PAYJOIN_FFI_FEATURES:+,$PAYJOIN_FFI_FEATURES}" - (cd .. && "${CROSS_CARGO[@]}" --target "$TARGET" --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi) - - # Cargo writes to the triple without any zig glibc suffix. - TARGET_DIR=${TARGET%."$PAYJOIN_FFI_GLIBC_FLOOR"} - if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then - PROFILE_DIR=debug - else - PROFILE_DIR=$PAYJOIN_FFI_PROFILE - fi - BUILT_LIB="../../target/$TARGET_DIR/$PROFILE_DIR/$LIBNAME" -else - bash ./scripts/generate_bindings.sh --native-only - BUILT_LIB="lib/$LIBNAME" -fi +bash ./scripts/generate_bindings.sh --native-only ARTIFACT_DIR="artifacts/runtimes/$RID/native" rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" -cp "$BUILT_LIB" "$ARTIFACT_DIR/$LIBNAME" +cp "lib/$LIBNAME" "$ARTIFACT_DIR/$LIBNAME" echo "Wrote $ARTIFACT_DIR/$LIBNAME" diff --git a/payjoin-ffi/csharp/scripts/cross-requirements.txt b/payjoin-ffi/csharp/scripts/cross-requirements.txt deleted file mode 100644 index 9f3db226f..000000000 --- a/payjoin-ffi/csharp/scripts/cross-requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Pinned cross-compilation toolchain for the CI native-asset job (Linux runners). -# These tools produce the exact binaries shipped in the NuGet package, so they -# are pinned by version and hash: a floating install would let an upstream -# release or a compromised PyPI package silently change the shipped artifacts. -# Update deliberately, never implicitly. Hashes cover the x86_64 and aarch64 -# manylinux wheels. -ziglang==0.16.0 \ - --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b \ - --hash=sha256:e27d409812b11e0fb89ed0200cf2e55b6464d43f9461553104e4a4f9a94a1fd5 -cargo-zigbuild==0.23.0 \ - --hash=sha256:3a2e021435a5e3c8b3a70bfc490580a3af6952185aa5ffb722b005d1fbe000c9 \ - --hash=sha256:d984af93da7a230606452ef5f6df47e6a081b12dcead8a3e8e5ff024728c94f0 -cargo-xwin==0.22.0 \ - --hash=sha256:eebe464a84371c98c9a3e2d24a87b6c0fdc575146f6670f49823a366158027bf \ - --hash=sha256:8a6d3f235590a1e583286884cbdf0b2af67164713f8a81d292f3312e0fa2355e -# Transitive dependency of cargo-xwin. -ninja==1.13.0 \ - --hash=sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa \ - --hash=sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630 From 7b5b8573800445886c0097cba2ce7d821d01e7c0 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:55:07 +0000 Subject: [PATCH 5/8] Build the Python wheel libraries with build.sh The wheel's libraries were built by build-wheel.sh itself: the Linux one with the python devshell's host cargo, so its glibc floor was whatever the runner's toolchain linked against, and the universal2 macOS one with cargo-zigbuild, which links with `-g` and needs a toolchain of its own. Neither followed the native build contract. Build the Linux library and both macOS slices with contrib/native/build.sh in the native devshell, and combine the slices with llvm-lipo as the native workflow does. build-wheel.sh now takes the library to bundle as its argument. It generates the bindings from a development build of the same featureless library, because uniffi reads its metadata from the static symbol table, which the zig-linked Linux library lacks. The Linux library needs at most glibc 2.17, so auditwheel tags the wheel manylinux_2_17, as the changelog notes. The python devshell no longer cross-links anything, so it drops cargo-zigbuild and the macOS targets on Linux. --- .github/workflows/python.yml | 25 +++++++-- flake.nix | 23 ++++----- payjoin-ffi/CHANGELOG.md | 4 ++ payjoin-ffi/python/RELEASING.md | 10 ++-- payjoin-ffi/python/contrib/build-wheel.sh | 63 +++++++++++------------ 5 files changed, 72 insertions(+), 53 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c75efb35b..49712ccd9 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -68,8 +68,10 @@ jobs: build-wheel: # The wheel bundles a prebuilt native library, so each supported # platform packs its own wheel: x86_64 on Linux (retagged manylinux by - # auditwheel) and a fat universal2 dylib for macOS. Both wheels build - # on a Linux host. The smoke jobs below verify each wheel on real hardware. + # auditwheel) and a fat universal2 dylib for macOS. Both libraries build + # on Linux with the native build contract's script, and the universal2 + # file is combined from its two slices the way the native workflow does + # it. The smoke jobs below verify each wheel on real hardware. name: "Build wheel" if: github.event_name != 'pull_request' runs-on: ubuntu-26.04 @@ -89,9 +91,26 @@ jobs: - name: Set up nix uses: $/.github/actions/setup-nix - name: Build the wheel - run: nix develop .#python --command bash ./payjoin-ffi/python/contrib/build-wheel.sh env: PAYJOIN_WHEEL_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + out=$RUNNER_TEMP/out + if [[ $PAYJOIN_WHEEL_PLATFORM == linux-x64 ]]; then + nix develop .#native -c contrib/native/build.sh x86_64-unknown-linux-gnu "$out" + lib=$out/x86_64-unknown-linux-gnu/libpayjoin_ffi.so + else + for target in aarch64-apple-darwin x86_64-apple-darwin; do + nix develop .#native -c contrib/native/build.sh "$target" "$out" + done + lib=$out/universal2-apple-darwin/libpayjoin_ffi.dylib + mkdir -p "$(dirname "$lib")" + nix develop .#native -c llvm-lipo -create \ + "$out/aarch64-apple-darwin/libpayjoin_ffi.dylib" \ + "$out/x86_64-apple-darwin/libpayjoin_ffi.dylib" \ + -output "$lib" + fi + nix develop .#python --command bash ./payjoin-ffi/python/contrib/build-wheel.sh "$lib" - name: Upload wheel uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/flake.nix b/flake.nix index e06aaf633..76c29717a 100644 --- a/flake.nix +++ b/flake.nix @@ -423,16 +423,15 @@ "rustfmt" "llvm-tools-preview" ]; - targets = [ - # On Darwin the native test builds lipo both arches; on Linux - # cargo-zigbuild cross-links the release wheel's universal2 - # dylib from these same targets. - "aarch64-apple-darwin" - "x86_64-apple-darwin" - ] - ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ - "x86_64-unknown-linux-gnu" - ]; + targets = + # On Darwin the native test builds lipo both arches. + pkgs.lib.optionals pkgs.stdenv.isDarwin [ + "aarch64-apple-darwin" + "x86_64-apple-darwin" + ] + ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + "x86_64-unknown-linux-gnu" + ]; }; pythonDevShell = pkgs.mkShell { @@ -454,10 +453,6 @@ # Applies the manylinux platform tag the release wheel satisfies; # PyPI rejects raw linux_x86_64 wheels. auditwheel - # Cross-links the macOS release wheel against zig's bundled - # Apple SDK stubs, so the dylib records system install names - # instead of nix store paths. - cargo-zigbuild ]; env = { diff --git a/payjoin-ffi/CHANGELOG.md b/payjoin-ffi/CHANGELOG.md index 578323a34..ea0bdb423 100644 --- a/payjoin-ffi/CHANGELOG.md +++ b/payjoin-ffi/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- Python: the Linux wheel's library now needs at most glibc 2.17, and + the wheel is tagged `manylinux_2_17`. The macOS wheel now supports + macOS 10.15 and later, down from 13. + ## [0.25.1] - Wraps payjoin 1.2.0 diff --git a/payjoin-ffi/python/RELEASING.md b/payjoin-ffi/python/RELEASING.md index 46153efd4..fa9cc7299 100644 --- a/payjoin-ffi/python/RELEASING.md +++ b/payjoin-ffi/python/RELEASING.md @@ -20,10 +20,12 @@ workflow builds release wheels with `_test-utils`) and smoke-installs them on every supported platform: - `manylinux` x86_64, tagged by auditwheel with the glibc floor the binary - actually satisfies; -- macOS `universal2` (a fat x86_64 + arm64 dylib), cross-compiled from the - Linux host with cargo-zigbuild, so the dylib links the Apple SDK stubs - zig bundles and records system install names rather than nix store paths. + actually satisfies, 2.17; +- macOS `universal2` (a fat x86_64 + arm64 dylib), combined from the two + macOS slices. + +Both libraries are built on Linux under the +[native build contract](../../contrib/native/README.md). The wheels are tagged `py3-none` because the generated bindings load the bundled library through `ctypes` and do not depend on a CPython ABI; any diff --git a/payjoin-ffi/python/contrib/build-wheel.sh b/payjoin-ffi/python/contrib/build-wheel.sh index 2f2ffd476..a06a15ad3 100755 --- a/payjoin-ffi/python/contrib/build-wheel.sh +++ b/payjoin-ffi/python/contrib/build-wheel.sh @@ -1,21 +1,31 @@ #!/usr/bin/env bash set -euo pipefail -# Build the production wheel for one platform into dist/: release profile, -# no test utils. Select the platform with PAYJOIN_WHEEL_PLATFORM -# (linux-x64, the default, or macos-universal2). -# -# Both wheels build on a Linux host: a dylib linked inside the nix dev shell -# on a mac records nix store install names that exist on no user machine, -# while cargo-zigbuild links against zig's bundled Apple SDK stubs and records -# the system ones. +# Build the production wheel for one platform into dist/ around a native +# library built under the native build contract (contrib/native/build.sh): +# release profile, no test utils. Select the platform with +# PAYJOIN_WHEEL_PLATFORM (linux-x64, the default, or macos-universal2) and +# pass the library, libpayjoin_ffi.so or the universal2 +# libpayjoin_ffi.dylib, as the only argument. if [[ "$(uname -s)" != Linux ]]; then echo "error: release wheels build on a Linux host only" >&2 exit 1 fi +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi +LIBRARY=$(realpath "$1") PLATFORM=${PAYJOIN_WHEEL_PLATFORM:-linux-x64} +case "$PLATFORM" in + linux-x64 | macos-universal2) ;; + *) + echo "error: unsupported PAYJOIN_WHEEL_PLATFORM: $PLATFORM" >&2 + exit 1 + ;; +esac # Build against the maintained lockfile instead of resolving the dependency # graph fresh on every run. use_lockfile copies Cargo-recent.lock into place @@ -27,28 +37,18 @@ use_lockfile Cargo-recent.lock cd "$REPO_ROOT/payjoin-ffi/python" +# uniffi reads its metadata from the static symbol table, which the +# zig-linked Linux library does not have, so the bindings come from a +# development build of the same featureless library. echo "==> Generating production FFI bindings..." -PAYJOIN_FFI_FEATURES="" PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindings.sh +(cd .. && cargo build --lib && + cargo run --bin uniffi-bindgen generate \ + --library ../target/debug/libpayjoin_ffi.so \ + --language python --out-dir python/src/payjoin/) -# generate_bindings.sh stages the host's library; make sure the wheel -# ships exactly one platform's binary. -case "$PLATFORM" in - linux-x64) - rm -f src/payjoin/libpayjoin_ffi.dylib - ;; - macos-universal2) - echo "==> Cross-compiling the universal2 macOS library..." - (cd "$REPO_ROOT/payjoin-ffi" && - cargo zigbuild --profile release --target universal2-apple-darwin) - rm -f src/payjoin/libpayjoin_ffi.so - cp "$REPO_ROOT/target/universal2-apple-darwin/release/libpayjoin_ffi.dylib" \ - src/payjoin/ - ;; - *) - echo "error: unsupported PAYJOIN_WHEEL_PLATFORM: $PLATFORM" >&2 - exit 1 - ;; -esac +# A development build may have staged the other platform's library. +rm -f src/payjoin/libpayjoin_ffi.so src/payjoin/libpayjoin_ffi.dylib +cp "$LIBRARY" src/payjoin/ echo "==> Building the wheel..." # Drop setuptools' build/ staging dir too: it survives across runs and @@ -72,10 +72,9 @@ if [[ $PLATFORM == linux-x64 ]]; then rm dist/*-linux_x86_64.whl else # setup.py tagged the wheel with the build machine's (Linux) platform. - # The macOS floor is zig's: its bundled SDK stubs currently support - # macOS >= 13, and that is the minos both slices record. Keep this tag - # in sync with the LC_BUILD_VERSION of the built dylib if zig moves. - wheel tags --platform-tag macosx_13_0_universal2 --remove dist/*.whl + # The native build contract's macOS deployment target is 10.15 for the + # x86_64 slice and 11.0, the first macOS on Apple silicon, for arm64. + wheel tags --platform-tag macosx_10_15_universal2 --remove dist/*.whl fi echo "==> Built wheel:" From 72c49ebdbdc1340e86dc1fa426c356949ffa1b4f Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:55:07 +0000 Subject: [PATCH 6/8] Align Dart's Android libraries to 16 KB on NDK r27 native_toolchain_rust links Android libraries with the NDK the consumer's Flutter project selects, and accepts r27, the first NDK with the API 35 compiler wrappers it calls. r27 still aligns 64-bit libraries to 4 KB, which Google Play no longer accepts for apps that target Android 15 or later. NDK r28 and later align to 16 KB by default. Pass -z max-page-size=16384 to the linker for the 64-bit Android targets. Linking payjoin-ffi with NDK r27 changes its LOAD alignment from 0x1000 to 0x4000, and with NDK r29 the library is byte-identical with or without the flag, so no version check is needed. 32-bit ARM has no 16 KB requirement and is left alone. --- payjoin-ffi/CHANGELOG.md | 2 ++ payjoin-ffi/dart/hook/build.dart | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/payjoin-ffi/CHANGELOG.md b/payjoin-ffi/CHANGELOG.md index ea0bdb423..1127995ac 100644 --- a/payjoin-ffi/CHANGELOG.md +++ b/payjoin-ffi/CHANGELOG.md @@ -3,6 +3,8 @@ - Python: the Linux wheel's library now needs at most glibc 2.17, and the wheel is tagged `manylinux_2_17`. The macOS wheel now supports macOS 10.15 and later, down from 13. +- Dart: Android builds with NDK r27 now align 64-bit libraries to + 16 KB, as Google Play requires. Later NDKs already did. ## [0.25.1] diff --git a/payjoin-ffi/dart/hook/build.dart b/payjoin-ffi/dart/hook/build.dart index aa11d6df5..04a0129b1 100644 --- a/payjoin-ffi/dart/hook/build.dart +++ b/payjoin-ffi/dart/hook/build.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:code_assets/code_assets.dart'; import 'package:hooks/hooks.dart'; import 'package:native_toolchain_rust/native_toolchain_rust.dart'; @@ -22,6 +23,7 @@ void main(List args) async { final rustupHome = nonEmpty(environment['RUSTUP_HOME']) ?? (home == null ? null : '${strip(home)}$separator.rustup'); + final code = input.config.buildCodeAssets ? input.config.code : null; // Encoded rather than RUSTFLAGS, which Cargo splits on whitespace: the // output directory lives under the consumer's project, whose path may // contain spaces. hooks_runner never passes RUSTFLAGS to a build hook, @@ -37,6 +39,12 @@ void main(List args) async { if (rustupHome != null) (rustupHome, '/rustup'), ]) '--remap-path-prefix=${strip(from)}=$to', + // native_toolchain_rust accepts NDK r27, which aligns 64-bit libraries + // to 4 KB, and Google Play requires 16 KB. Later NDKs already align to + // 16 KB, and the flag leaves their output unchanged. + if (code?.targetOS == OS.android && + code?.targetArchitecture != Architecture.arm) + '-Clink-arg=-Wl,-z,max-page-size=16384', ].join('\x1f'); // The path overlay doubles as the development marker. `.pubignore` From 07cf31e531eb0904f312159804a804bd382c90cc Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:55:07 +0000 Subject: [PATCH 7/8] Build release bindings with Rust 1.85.1 The native libraries build with Rust 1.85.1, but the jobs that build the rest of each release still used the 1.85.0 MSRV toolchain. pack-npm compiles the published wasm and build-wheel generates the published bindings, in the javascript and python devshells. pack-nuget generates its bindings with 1.85.0 from the runner. Move those two devshells and pack-nuget to 1.85.1. The devshells' test jobs now build with 1.85.1 too. rust.yml's 1.85.0 leg keeps the MSRV for the core crates, and the C# and Kotlin test jobs keep it for payjoin-ffi. payjoin-ffi now declares rust-version 1.85 like the other crates, so Cargo names the floor to anyone building below it. The dart devshell stays on 1.85.0. Dart publishes generated Dart source and the Rust crate, which consumers compile with the package's own 1.85.1 toolchain file. Its build hook runs rustup's 1.85.1, which sets LD_LIBRARY_PATH to that toolchain's libraries while cargo still finds the devshell's rustc first in PATH. A nix 1.85.1 rustc then loads rustup's librustc_driver of the same name and fails to find libz. The C# Windows test job installed 1.85.0 but selected `1.85`, which rustup resolves to the newest 1.85 release, 1.85.1, so it did not test the MSRV like the Linux and macOS jobs. It now names 1.85.0. --- .github/workflows/csharp.yml | 10 +++++----- flake.nix | 12 ++++++------ payjoin-ffi/Cargo.toml | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml index 647dbe366..40de266cc 100644 --- a/.github/workflows/csharp.yml +++ b/.github/workflows/csharp.yml @@ -70,7 +70,7 @@ jobs: if: github.event_name != 'pull_request' runs-on: windows-latest env: - RUSTUP_TOOLCHAIN: 1.85 + RUSTUP_TOOLCHAIN: 1.85.0 defaults: run: working-directory: payjoin-ffi/csharp @@ -159,8 +159,8 @@ jobs: needs: build-nuget-native env: # Exact version so the packaged bindings build with the toolchain the - # action installs, not an implicitly-installed latest 1.85.x. - RUSTUP_TOOLCHAIN: 1.85.0 + # action installs, the release toolchain of the native build contract. + RUSTUP_TOOLCHAIN: 1.85.1 defaults: run: working-directory: payjoin-ffi/csharp @@ -170,10 +170,10 @@ jobs: with: persist-credentials: false # unneeded: no git auth beyond checkout - - name: Install Rust 1.85.0 + - name: Install Rust 1.85.1 uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 with: - toolchain: 1.85.0 + toolchain: 1.85.1 - name: Use cache uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 diff --git a/flake.nix b/flake.nix index 76c29717a..830869ebb 100644 --- a/flake.nix +++ b/flake.nix @@ -321,11 +321,11 @@ builtins.removeAttrs pythonWorkspace.deps.all [ "payjoin" ] ); - # Rust toolchain for the javascript dev shell: msrv pinned to match - # payjoin-ffi/javascript build requirements, with wasm32 target added + # Rust toolchain for the javascript dev shell: the release version, as + # pack-npm builds the published wasm here, with wasm32 target added # so cargo can build per-arch artifacts under nix (rustup target add # is a no-op against a nix-provided toolchain). - jsRustToolchain = pkgs.rust-bin.stable.${msrv-version}.default.override { + jsRustToolchain = pkgs.rust-bin.stable.${release-version}.default.override { extensions = [ "rust-src" "rustfmt" @@ -413,11 +413,11 @@ 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 + # Rust toolchain for the python dev shell: the release version, as + # build-wheel generates the published bindings here, with per-arch targets added # so cargo can build artifacts under nix for payjoin-ffi/python/scripts/generate_bindings.sh # (rustup target add is a no-op against a nix-provided toolchain). - pythonRustToolchain = pkgs.rust-bin.stable.${msrv-version}.default.override { + pythonRustToolchain = pkgs.rust-bin.stable.${release-version}.default.override { extensions = [ "rust-src" "rustfmt" diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 0fb4197f7..87e1b0dfa 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -2,6 +2,7 @@ name = "payjoin-ffi" version = "0.25.1" edition = "2024" +rust-version = "1.85" license = "MIT OR Apache-2.0" exclude = ["tests"] From 2c7acf20d73ff74b2a855c24115e122ad36bb999 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 28 Oct 2026 14:55:07 +0000 Subject: [PATCH 8/8] Build Kotlin's production bindings from the lock The Kotlin workflow compiles production bindings after its tests by calling generate_bindings.sh directly, and only test.sh put Cargo-recent.lock in place. CI still built that step from the lock, but only because a fresh checkout has no Cargo.lock for test.sh to restore on exit. Calling the script on its own, or after test.sh restored a different lockfile, resolves the dependency graph afresh. Have generate_bindings.sh use the maintained lockfile itself, as the C# script does. When test.sh has already put it in place, the call does nothing. --- payjoin-ffi/kotlin/scripts/generate_bindings.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/payjoin-ffi/kotlin/scripts/generate_bindings.sh b/payjoin-ffi/kotlin/scripts/generate_bindings.sh index cb611cb2d..44262bb8f 100755 --- a/payjoin-ffi/kotlin/scripts/generate_bindings.sh +++ b/payjoin-ffi/kotlin/scripts/generate_bindings.sh @@ -16,6 +16,14 @@ else fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Build against the maintained lockfile instead of resolving the dependency +# graph fresh on every run. use_lockfile copies Cargo-recent.lock into place +# and restores the previous state when this script exits. +cd "$SCRIPT_DIR/../../.." +source contrib/lockfile.sh +use_lockfile Cargo-recent.lock + cd "$SCRIPT_DIR/../.." echo "Generating payjoin Kotlin..."