Skip to content

Native FFI builds - #2023

Draft
spacebear21 wants to merge 9 commits into
payjoin:masterfrom
spacebear21:native-build
Draft

spacebear21 wants to merge 9 commits into
payjoin:masterfrom
spacebear21:native-build

Conversation

@spacebear21

@spacebear21 spacebear21 commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #2016

Combines phases 1 and 2 from #1997

Build every payjoin-ffi native library once per release, check that two builds of each are byte-identical, and ship those exact files through Python, C#, Dart and the GitHub release.

  • contrib/native/build.sh builds the featureless library for 11 targets on Linux in a new .#native devshell, with Rust 1.85.1, Cargo-recent.lock and path remapping.
    contrib/native/README.md documents the rules.
  • native.yml builds each target twice from different paths and fails on any mismatch. A macOS job checks the Apple libraries against Xcode and runs an iOS simulator smoke test. It
    runs daily and in releases, not on PRs.
  • Releases attach the libraries with SHA256SUMS and attestations. payjoin-ffi/VERIFYING.md explains how to check or rebuild them.
  • Python and C# package these files instead of building their own, and check every packed library byte for byte.
  • Dart ships the libraries in the package (26 MiB compressed), so apps need no Rust toolchain. build: source keeps the previous behaviour.

The first three commits are independent of the rest (Dart 16 KB alignment on NDK r27, Rust 1.85.1 for release bindings, Kotlin bindings from the lock) and will also be opened as small PRs.

Validated locally: every target reproduced across two builds with no build paths, the wheel and NuGet replays packed exactly those bytes, and the staged Dart package loaded its
library in prebuilt mode. The macOS job, the macOS and Windows smoke tests, release attestation and upload, and a Flutter app build in prebuilt mode run for the first time in CI.

Pull Request Checklist

Please confirm the following before requesting review:

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.
Dart's published crate builds with Rust 1.85.1, which its
rust-toolchain.toml pins, 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 builds the published library and
bindings, in the javascript and python devshells. pack-nuget generates
its bindings, and build-nuget-native the NuGet libraries, with 1.85.0
from the runner.

Move those two devshells, pack-nuget and build-nuget-native to 1.85.1,
named once in the flake as release-version, so the NuGet libraries and
their bindings build with the same toolchain. 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.
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.
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 <triple> <out-dir>
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
the scheduled run carries this check.
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.
The release shipped native libraries only inside the wheels and the
NuGet package, and nothing in it came from the native workflow's checked
double build.

The release workflow now calls the native workflow once, and every
publish job waits for it, so a library that does not reproduce or fails
the Apple checks stops the release. The GitHub release job names each
library payjoin_ffi-<version>-<target>.<ext>, attaches all 12, lists
them in SHA256SUMS and attests them, so a consumer can check a library
against the release without unpacking a package. VERIFYING.md explains
how to check one, or rebuild it from the Linux devshell, iOS included,
and compare.
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.
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. None of it followed the native build contract,
so the shipped files could not be rebuilt bit for bit, and they were
not the ones the native workflow had checked.

Both now download the payjoin-native artifact of the same run: the
wheels take the Linux and universal2 files, and the NuGet package takes
one file per RID, which removes the six build-nuget-native jobs, the
pip pins, the clang-cl symlinks and the cross branch of
build_nuget_native.sh. The release and the daily run make python and
csharp wait for native. The per-RID smoke tests still install the
packed nupkg on each platform.

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, and the macOS wheel is tagged macosx_10_15_universal2,
the contract's floor, as the changelog notes. The python devshell no
longer cross-links anything, so it drops cargo-zigbuild and the macOS
targets on Linux.

Since the GitHub release lists each library on its own, the wheel build
and the pack job compare every library inside the package with the
native file and fail on any difference. They run before publishing,
which the GitHub release job does not. With that check in place,
SHA256SUMS no longer lists the NuGet members a second time, and
generate-sha256sums.sh loses its archive option, which nothing else
uses.
Every Dart consumer compiled payjoin-ffi with their own Rust toolchain,
so none of them got the library the release checks for
reproducibility, and every app build needed Rust and the targets'
toolchains.

prepare-publish.sh now copies the native workflow's payjoin-native
artifact into the staged package as prebuilt/<target>/<file>, for the
11 targets the hook maps; the universal macOS library is left out. The
release and the daily run make dart wait for native to get it. The 11
libraries add about 26 MiB to the compressed archive, well under
pub.dev's 100 MiB limit.

The build hook now has three modes, chosen by the `build` user define
in the root package's pubspec:

- prebuilt, the default, registers the packaged library for the target.
  The build needs no network, no GitHub and no Rust, so it works
  offline and is hermetic. pub checks the archive against the sha256
  in the consumer's pubspec.lock, and the release attests the staged
  archive. A target without a library is a build error that says how
  to switch to source, never a silent source build.
- source builds the shipped native/Cargo.lock with --locked, as
  consumers did until now.
- workspace builds payjoin-ffi from the checkout with _test-utils, and
  is what the package's own pubspec sets. It replaces the presence of
  .cargo/config.local.toml as the development signal, so a copy of the
  package directory no longer becomes a development build by accident.

Both kinds of library register as uniffi:payjoin_ffi. uniffi-dart's
bindings bind every function with @Native(assetId:), so they resolve
by that id and the file name does not matter.

A prebuilt-linux job runs the consumer program against the staged
package. Pull requests build no release libraries, so the Linux test
job places the host library its tests built under prebuilt/ and runs
the same check. The reproducibility job's consumer now selects source,
which it exists to test.

Because the libraries travel inside the package, publish-dart keeps its
place beside the other publish jobs and the release still needs one
approval. It now also attests the staged package archive the release
attaches, as the other registries attest theirs.
@coveralls

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 41086680050

Coverage remained the same at 87.547%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 17947
Covered Lines: 15712
Line Coverage: 87.55%
Coverage Strength: 320.79 hits per line

💛 - Coveralls

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants