Repository navigation
Tracking: Build reproducible shared native libraries for all language bindings #1997
Description
Summary
Build each payjoin-ffi native library once, reproducibly, and ship the same bytes through
every language binding. Get Dart consumers who need reproducible builds (Bull Bitcoin,
who currently depend on a fork) onto upstream right away, without work that a later
redesign throws away.
The plan has three phases:
- Phase 0: a locked, path-remapped Dart source build, shipped in a shared
payjoin-ffi 0.25.1 and gated by a reproducibility check in CI. Lock and remap Dart native builds for reproducibility #1996. - Phase 1: one native build contract that every binding follows. All 12 targets, iOS
included, build on Linux. - Phase 2: CI builds each target once and checks it with a second build. Python, C#
and Dart ship the same files. Dart is prebuilt by default, with source builds on request.
Related: #1815, #1996, #1307, #1308, #1885, #1952.
Why
- Dart consumers resolve their dependency graph fresh on every build. The pub.dev
archive carries noCargo.lock. Builds drift as crates publish, and broke once already
(Resolve every binding build against the lockfile #1885). Bull Bitcoin needs reproducible APKs, so they ship from a fork
(SatoshiPortal/payjoin-rust) that carries Make Dart native builds reproducible #1815. - Each binding builds its native library differently.
- Feature sets differ: Python
"", C#csharp, Dartdartthrough a wrapper crate.
Features change Cargo's-C metadatahash, so they change the bytes even with no
cfg-gated code. - Rust is 1.85.0 everywhere except Dart's 1.85.1.
- The Linux glibc floor is 2.17 for C# and unpinned for Python.
- There are two separate zig installs, and clang-cl is unpinned.
- No binding remaps build paths, and Dart has no attestation.
- Feature sets differ: Python
- Today's builds can't be reproduced or cross-checked, so there's no single set of
hashes anyone can verify.
What we already verified
All of this was tested locally with Rust 1.85.1 against the #1996 staged lock.
- The staged lock builds on the consumer toolchain. A lock staged by cargo 1.99.1
builds with cargo 1.85.1--locked. The compiled graph (154 to 156 crates per target)
matchesCargo-recent.lockexactly. - Linux x86_64 reproduces. Two builds from different package roots, output dirs (one
with a space) andCARGO_HOMEs gave byte-identical.soand.afiles with no build
paths, at the default 16 codegen units. - iOS cross-builds reproducibly on Linux, with no Apple SDK.
- Tooling: zig 0.16.0 as linker and C compiler, zig's Darwin headers, and generated
libSystemandlibiconv.tbdstubs. - Result: two
aarch64-apple-iosbuilds from different paths were byte-identical.
Each is a valid iOS Mach-O (min OS 13.0, install name@rpath/…, 170 public imports). - Required flags:
-install_name @rpath/<lib>,-headerpad_max_install_names
(Flutter runsinstall_name_toolandcodesignon these dylibs), and-Wl,-S(zig
otherwise writes object paths and mtimes into stabs).
- Tooling: zig 0.16.0 as linker and C compiler, zig's Darwin headers, and generated
- The shipped lock carries 84 stale entries that are never compiled. They come from
payjoin-test-utils = "0.0.1", which resolves to the 2025 crates.io release (payjoin 0.24,ring 0.16,rustls 0.21) once the workspace[patch]no longer applies.cargo auditwould flag them. payjoincore comes from crates.io in staged builds.payjoin-ffirequires the
exact core version, so staging fails between a core bump and its crates.io publish.- Flutter 3.44.9 bundles
hooks_runner1.1.1. It strips allCARGO_*,RUSTUP_*,
CFLAGSandSOURCE_DATE_EPOCHvariables before running build hooks, so consumers can't
pass build flags through the environment. It does supportuser_defines, read from the
root or workspace pubspec.
Phase 0: locked Dart source builds in 0.25.1
Before tagging, we also build a bullbitcoin-mobile branch against the staged
package twice and compare the APKs with their verify_build.sh.
Then we release. A shared 0.25.1 with no API changes, after which Bull can switch to
the hosted package.
Phase 1: one native build contract
| Invariant | Rule |
|---|---|
| Toolchain | Rust 1.85.1 for every release build. MSRV test jobs stay on 1.85.0. |
| Lockfile | Always Cargo-recent.lock |
| Features | The cdylib is built with no language features; the bindgen binary is built |
| separately | |
| Paths | One remap set for rustc, matching -ffile-prefix-map for C (secp256k1-sys is |
| the only C dependency) | |
| Linux | glibc 2.17 through zig |
| macOS | zig, deployment target 13.0 |
| Windows | xwin with pinned SDK and CRT versions, pinned clang-cl, /Brepro |
| Android | NDK 29.0.14206865, API 21, 16 KB aligned |
| iOS | zig 0.16.0 called directly with repo-generated stubs (device and both |
| simulators), min OS 13.0 | |
| Tools | All 12 targets build on Linux in one pinned nix devshell (.#native) |
| Profile | Cargo defaults. 16 codegen units proved deterministic. |
| Proof | Every target is built twice on separate runners, and the hashes must match |
Work, in order:
- iOS on Linux first, because it gates Phase 2: the stub generator, a linker
wrapper, and double builds of all three iOS targets. A macOS runner verifies but never
produces artifacts:- it checks each dylib's imports against the real Xcode
libSystem.tbd, read in place - it runs the simulator builds through Flutter's embed and
codesignpath - it runs the Dart tests in the simulator
- it checks each dylib's imports against the real Xcode
contrib/native/build.sh <triple>and a reusablenative.yml. The workflow
double-builds 12 targets plus auniversal2-apple-darwinfile derived withllvm-lipo.- C# and Python move onto the contract. Python's Linux wheel becomes
manylinux_2_17. - The Dart source build gets C prefix maps, plus 16 KB link args for NDK r27.
- CI toolchain cleanup.
Phase 2: build once, ship everywhere
- One native build per release run. Python and C# package those files instead of
building their own. - Every native file is attested, attached to the GitHub release and listed in
SHA256SUMS. The release job asserts that the files inside wheels and nupkgs are
byte-identical to them. Dart gets attested too. - Dart goes prebuilt by default.
- The package carries a sha256 per target, and the hook downloads from the GitHub
release, verifies and caches. hooks: user_defines: payjoin: build:selectsprebuilt(the default),sourceor
workspace, with an optional mirror URL.- A failed download is a hard error that points to
build: source, never a silent
fallback. workspacereplaces the marker file as the dev signal, so git-ref consumers stop
getting dev builds by accident.
- The package carries a sha256 per target, and the hook downloads from the GitHub
publish-dartruns aftergithub-release, so the download URLs exist before
anyone resolves the package.- A
VERIFYING.mdexplains how to rebuild any target from the devshell and compare
againstSHA256SUMS.
Disclosure: this issue was drafted with AI assistance (Claude Code) from an investigation
I directed, including the local reproducibility experiments described above. I reviewed
it and wanted to post it here as a high level tracker while i work on implementing this.
Activity