From faa4de50e198f3cd72524df2d651b5c344bceb63 Mon Sep 17 00:00:00 2001 From: Fab the Analyst Date: Mon, 17 Aug 2026 12:32:40 +0000 Subject: [PATCH 1/2] docs: pin toolchain and document reproducible builds for deployed contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the reproducible-build / supply-chain verification gap for the deployed testnet contracts: - Add rust-toolchain.toml pinning rustc 1.95.0 (with the wasm32v1-none target), so rustup auto-selects the exact toolchain instead of relying on README prose. dtolnay/rust-toolchain in CI picks this up too. - Pin soroban-sdk exactly ("=26.1.0") instead of a caret range, removing one further axis of non-determinism independent of Cargo.lock, and record the decision in the README. - Publish the actual on-chain WASM SHA-256 for all three deployed testnet contracts in the README's contract table, read directly from the ledger (not computed from source). Document that they were built from commit 8a7efbf with rustc 1.95.0 on macOS, that current main does not produce them (pre-crowdfunding source), and that even at that commit a Linux build is ~99% byte-identical but not hash-identical due to host-dependent LLVM/LTO codegen and the embedded absolute CARGO_HOME path. - Add the full verification recipe (build -> sha256sum -> make verify), the no-cargo-update rule, and scripts/verify-wasm-hash.mjs + `make verify` to compare local builds against the ledger-recorded hashes. Generated with Codebuff πŸ€– Co-Authored-By: Codebuff --- Cargo.toml | 7 +- Makefile | 8 +- README.md | 150 ++++++++++++++++++++++++++++++++--- rust-toolchain.toml | 19 +++++ scripts/verify-wasm-hash.mjs | 91 +++++++++++++++++++++ 5 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 rust-toolchain.toml create mode 100644 scripts/verify-wasm-hash.mjs diff --git a/Cargo.toml b/Cargo.toml index 6e9b396..92f13ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,12 @@ license = "Apache-2.0" publish = false [workspace.dependencies] -soroban-sdk = "26.1.0" +# Pinned exactly (not a caret range): the deployed testnet contracts were +# built against soroban-sdk 26.1.0 (see Cargo.lock), and an exact pin removes +# one further axis of non-determinism independent of Cargo.lock being +# respected. See the README's "Reproducible builds" section for the recorded +# decision. +soroban-sdk = "=26.1.0" # Build wasm contracts as small and fast as possible. [profile.release] diff --git a/Makefile b/Makefile index 5984132..ecfe430 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ WASM_DIR := target/$(WASM_TARGET)/release NETWORK ?= testnet SOURCE_ACCOUNT ?= mergefi-admin -.PHONY: build test test-verbose fmt clean deploy-escrow deploy-milestones deploy-maintenance-pool deploy +.PHONY: build test test-verbose fmt clean verify deploy-escrow deploy-milestones deploy-maintenance-pool deploy ## Build all contracts to optimized wasm (wasm32v1-none, the target Soroban's ## host currently requires for Rust 1.84+; falls back instructions below if @@ -29,6 +29,12 @@ test: test-verbose: cargo test --workspace -- --nocapture +## Compare the locally built wasm hashes against the hashes recorded on-chain +## for the deployed testnet contracts. Exits non-zero on any mismatch. Requires +## `make build` first and network access to the testnet RPC. +verify: build + node scripts/verify-wasm-hash.mjs + fmt: cargo fmt --all diff --git a/README.md b/README.md index 1b76c1f..5e0d6de 100644 --- a/README.md +++ b/README.md @@ -483,12 +483,17 @@ integration points: ### Prerequisites -- Rust (this repo was built/tested against `rustc 1.95.0`). -- The `wasm32v1-none` target for building deployable contract wasm: - `rustup target add wasm32v1-none`. - (Soroban's host requires this target rather than the legacy - `wasm32-unknown-unknown` on Rust 1.82+ β€” `soroban-sdk`'s build script - will tell you this explicitly if you try the wrong one.) +- Rust, **pinned to `rustc 1.95.0`** by the committed `rust-toolchain.toml`. + rustup automatically selects and installs that exact toolchain (and the + `wasm32v1-none` target, also listed there) on first build β€” no manual + version management needed. This is the exact toolchain the deployed + testnet contracts were built with; see "Reproducible builds" below for + why that matters and what it does and doesn't guarantee. +- The `wasm32v1-none` target for building deployable contract wasm + (installed automatically via `rust-toolchain.toml`; `rustup target add + wasm32v1-none` also works). Soroban's host requires this target rather + than the legacy `wasm32-unknown-unknown` on Rust 1.82+ β€” `soroban-sdk`'s + build script will tell you this explicitly if you try the wrong one. - [`stellar-cli`](https://developers.stellar.org/docs/tools/cli/install-cli) (the successor to `soroban-cli`) for `contract deploy` / `contract invoke` against testnet/mainnet. **Not installed in the @@ -512,8 +517,8 @@ cargo build --target wasm32v1-none --release \ -p mergefi-escrow -p mergefi-milestones -p mergefi-maintenance-pool ``` -Verified in this session: `cargo test --workspace` β€” **54/54 tests pass** -(28 escrow, 19 milestones, 7 maintenance-pool, including the +Verified in this session: `cargo test --workspace` β€” **56/56 tests pass** +(30 escrow, 19 milestones, 7 maintenance-pool, including the access-control boundary matrix added in #30 and the multi-sponsor crowdfunding tests added in #57/#58) on the native target using `soroban_sdk::testutils` (`Env::default()`, `Address::generate`, @@ -531,11 +536,21 @@ Stellar-side problem), so `scripts/deploy.mjs` and `scripts/invoke.mjs` perform the same upload β†’ create-contract β†’ initialize flow the CLI would otherwise do. -| Contract | Contract ID | -|---|---| -| `mergefi-escrow` | `CAY77D2SFDVQYONSPYHOEWARE3UIWQDYHWWI2WXNPFBLBKR2Q4GEWXFB` | -| `mergefi-milestones` | `CBBRLSL6TM6XCNP2XBVT4GFHJ3NNPFKI2BCZQJ4U3TI7GV7DO2F2HG6F` | -| `mergefi-maintenance-pool` | `CD46U7WTEM2I77TXQI2VIBRQXOHEFEYYR2XFA7OVGTXX5M2F7Z3ZQOX2` | +| Contract | Contract ID | Deployed WASM SHA-256 | +|---|---|---| +| `mergefi-escrow` | `CAY77D2SFDVQYONSPYHOEWARE3UIWQDYHWWI2WXNPFBLBKR2Q4GEWXFB` | `1b944e576943fd8ece02419165a02d11dd4a9454a424606130942c788deadbdd` | +| `mergefi-milestones` | `CBBRLSL6TM6XCNP2XBVT4GFHJ3NNPFKI2BCZQJ4U3TI7GV7DO2F2HG6F` | `542fd6e9dcca0287f3840068b13152454f2f1f54c4efe3548dff6d482f9ca1a6` | +| `mergefi-maintenance-pool` | `CD46U7WTEM2I77TXQI2VIBRQXOHEFEYYR2XFA7OVGTXX5M2F7Z3ZQOX2` | `311bc6e9d9e1d85020a76537987d60dc6a2f7c71e974cf8049b832d3fcc0f848` | + +The "Deployed WASM SHA-256" column is the SHA-256 of the exact bytecode +running on testnet, read **directly from the ledger** (the contract instance +entry records the hash of the bytes uploaded at deploy time) via +`node scripts/verify-wasm-hash.mjs`. It is *not* computed from this +repository's source β€” which is exactly the point: the WASM hashes in the +table above were built from commit `8a7efbf` with `rustc 1.95.0` on macOS, +**not** from current `main`. `make verify` against current `main` therefore +reports a mismatch by design β€” see "Reproducible builds" below for the full +explanation and how to verify or reproduce these hashes. All three were initialized with the same admin/treasury address (`GBUXADZJ7O4NM7S7CDZYVXGP37M772D2TYMFBT2QFH2JSRCFEJPAVW5N`, a @@ -567,6 +582,115 @@ node scripts/invoke.mjs initialize \ address: address: u32:250 ``` +### Reproducible builds + +This repo holds contracts that move real sponsor funds, and this README +publishes concrete testnet contract IDs as checkable references β€” so it must +be possible for an independent third party to confirm that the bytecode +running at those addresses corresponds to the published source. This section +documents how (and, honestly, how far) that verification currently works. + +#### What is pinned + +- **Toolchain.** `rust-toolchain.toml` pins `rustc 1.95.0` (plus the + `wasm32v1-none` target), and `rustup` auto-selects it for anyone building + from this repo. CI's `dtolnay/rust-toolchain` steps read that file too, so + CI builds with the same toolchain rather than "latest stable". +- **Dependencies.** `Cargo.lock` is committed and must stay committed β€” the + resolved versions of every dependency (including soroban-sdk 26.1.0) are + recorded there. **Never run `cargo update` before a build you intend to + deploy or verify.** `cargo build` / `make build` respects the lock as-is; + running `cargo update` first would silently change the dependency + resolution and therefore the produced WASM. If dependencies genuinely need + bumping, do it as a deliberate, separate change and re-verify below. +- **soroban-sdk is now pinned exactly.** `Cargo.toml` declares + `soroban-sdk = "=26.1.0"` rather than a caret range (`"26.1.0"` would + permit any `26.x.y` at resolution time). **Decision (issue #59): pin + exactly.** The committed `Cargo.lock` already makes the resolved version + deterministic as long as it is respected, but an exact pin removes that + axis of non-determinism even in an environment that ignores or regenerates + the lock β€” cheap insurance for a contract repo, at the cost of an explicit + edit when a dependency bump is actually intended. + +#### The verification recipe + +Given any commit, build it and compare against the ledger-recorded hashes: + +```sh +git checkout +rustup show # selects 1.95.0 from rust-toolchain.toml +make build # cargo build --target wasm32v1-none --release -p ... +sha256sum target/wasm32v1-none/release/*.wasm +make verify # compares local hashes vs the ledger (needs network) +``` + +`make verify` runs `scripts/verify-wasm-hash.mjs`, which reads each deployed +contract's WASM hash **from the testnet ledger** (not from this README) and +compares it with the locally built `.wasm`. It exits non-zero on any +mismatch. + +#### The currently-deployed bytecode (what the hashes above actually are) + +Forensics on the deployed WASM (embedded `contractmetav0` metadata + embedded +panic-location strings) establishes the following facts: + +- The deployed contracts were built from **commit `8a7efbf`** + ("Deploy all three contracts to Stellar testnet", 2026-07-05) β€” the + contract spec and every embedded string match that source state exactly. +- The build toolchain was **`rustc 1.95.0`** β€” the deployed WASM's + `contractmetav0` custom section records `rsver = 1.95.0`, matching this + repo's pinned toolchain. +- The build ran on **macOS** (`/Users/user/...`), and the WASM embeds the + absolute `CARGO_HOME` registry path in panic-location strings + (e.g. `/Users/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/soroban-sdk-26.1.0/src/env.rs`). + +Two consequences follow, and both are intentional, documented states rather +than oversights: + +1. **Current `main` does *not* produce the deployed hashes.** The deployed + contracts predate the multi-sponsor crowdfunding, `mergefi-common` + extraction, and milestones-crowdfunding changes. `make verify` on `main` + reports a mismatch β€” correct behavior until the contracts are redeployed + from current source (see below). +2. **Even at commit `8a7efbf` with `rustc 1.95.0`, a Linux build is not + byte-identical to the macOS build that was deployed.** With the pinned + toolchain and locked deps, a Linux build of `8a7efbf` reproduces the + deployed WASM to ~99% of bytes (16 of 184 functions differ by 1–2 bytes, + plus function-ordering differences), and the size and metadata match + exactly β€” but the SHA-256 differs because the embedded absolute registry + path and LLVM/LTO codegen are host-dependent: + + | Contract | On-chain (deployed) | Linux `8a7efbf` + 1.95.0 | + |---|---|---| + | `mergefi-escrow` | `1b944e57…deadbdd` | `02d6b18f…c1c213` | + | `mergefi-milestones` | `542fd6e9…9ca1a6` | `81bb70e6…17b21` | + | `mergefi-maintenance-pool` | `311bc6e9…0f848` | `ef4a8d4d…0a39a6` | + + This is precisely the supply-chain limitation this section exists to + surface: **rustc/LLVM codegen is not guaranteed bit-for-bit identical + across host machines, and soroban-sdk's panic locations embed the build + machine's absolute cargo path.** The pins above make builds reproducible + across environments that share a host layout and a toolchain; they do not + yet make them reproducible across arbitrary hosts. The ledger-recorded + hash β€” via `make verify` β€” remains the ground truth for "is this exact + bytecode deployed". + +#### Redeploying from current `main` + +After any contract change that is intended to go live, build from the exact +commit being deployed, then confirm the new bytecode is what the ledger +records: + +```sh +make build +node scripts/deploy.mjs target/wasm32v1-none/release/mergefi_escrow.wasm escrow +# ... for each contract ... +make verify # now reports βœ“ for the freshly deployed contracts +``` + +Consider recording the new hashes back in this README's table at that point, +so the published table tracks the currently-deployed bytecode. + ## Roadmap - Extract shared split/fee math (`compute_split`) into a common diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8cf0157 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,19 @@ +# Pin the exact rustc toolchain used to build and test these contracts so that +# rustup automatically selects it for anyone building from this repo, instead +# of relying on prose in the README being read and followed manually. +# +# rustc 1.95.0 is the version the deployed testnet contracts (see README, +# "Deployed on Stellar testnet" / "Reproducible builds") were built with, and +# the version the full test suite is verified against. The `wasm32v1-none` +# target is listed here so `cargo build --target wasm32v1-none` works out of +# the box after `rustup` auto-installs the toolchain. +# +# This file is part of the reproducible-build story documented in the README: +# even with the toolchain pinned, LLVM/rustc codegen is not guaranteed +# bit-for-bit identical across different host machines (see the README's +# "Reproducible builds" section for the exact caveat), so treat the pinned +# toolchain as necessary-but-not-sufficient for byte-identical WASM. +[toolchain] +channel = "1.95.0" +profile = "minimal" +targets = ["wasm32v1-none"] diff --git a/scripts/verify-wasm-hash.mjs b/scripts/verify-wasm-hash.mjs new file mode 100644 index 0000000..09533b6 --- /dev/null +++ b/scripts/verify-wasm-hash.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Verify that the locally built contract WASM matches what is actually + * deployed on Stellar testnet, byte-for-byte. + * + * For each of the three contracts it: + * 1. reads the WASM hash recorded in the ledger for the deployed contract + * (the contract instance entry carries the executable WASM hash β€” this + * is the hash of the exact bytes uploaded at deploy time); + * 2. reads `target/wasm32v1-none/release/.wasm` from a local + * `cargo build --target wasm32v1-none --release`; + * 3. compares them and reports match / mismatch. + * + * Usage (from the repo root, after `make build`): + * node scripts/verify-wasm-hash.mjs + * + * Exit code is 0 only if every contract matches. A mismatch means the + * locally built bytecode is not what is running on testnet β€” see the README's + * "Reproducible builds" section for what this implies and how to reproduce + * the deployed bytecode exactly. + */ +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { Address, rpc, xdr } from "@stellar/stellar-sdk"; + +const RPC_URL = "https://soroban-testnet.stellar.org"; +const WASM_DIR = path.resolve("target/wasm32v1-none/release"); + +const CONTRACTS = [ + ["mergefi-escrow", "mergefi_escrow.wasm", "CAY77D2SFDVQYONSPYHOEWARE3UIWQDYHWWI2WXNPFBLBKR2Q4GEWXFB"], + ["mergefi-milestones", "mergefi_milestones.wasm", "CBBRLSL6TM6XCNP2XBVT4GFHJ3NNPFKI2BCZQJ4U3TI7GV7DO2F2HG6F"], + ["mergefi-maintenance-pool", "mergefi_maintenance_pool.wasm", "CD46U7WTEM2I77TXQI2VIBRQXOHEFEYYR2XFA7OVGTXX5M2F7Z3ZQOX2"], +]; + +const server = new rpc.Server(RPC_URL); + +// The contract instance ledger entry (Protocol 22+) carries the executable +// WASM hash. Ledger key: ContractData { contract, key: LedgerKeyContractInstance, +// durability: Persistent }. +function instanceLedgerKey(contractId) { + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ); +} + +async function onChainWasmHash(contractId) { + const res = await server.getLedgerEntries(instanceLedgerKey(contractId)); + const entry = res.entries?.[0]; + if (!entry) { + throw new Error(`no instance entry found for ${contractId}`); + } + const executable = entry.val.contractData().val().instance().executable(); + return executable.wasmHash().toString("hex"); +} + +let allMatch = true; +for (const [name, wasmFile, contractId] of CONTRACTS) { + const wasmPath = path.join(WASM_DIR, wasmFile); + if (!fs.existsSync(wasmPath)) { + console.log(`βœ— ${name}: local wasm not found at ${wasmPath} (run \`make build\` first)`); + allMatch = false; + continue; + } + const localHash = crypto + .createHash("sha256") + .update(fs.readFileSync(wasmPath)) + .digest("hex"); + let deployedHash; + try { + deployedHash = await onChainWasmHash(contractId); + } catch (err) { + console.log(`βœ— ${name}: could not read on-chain hash: ${err.message}`); + allMatch = false; + continue; + } + const match = localHash === deployedHash; + if (!match) allMatch = false; + console.log( + `${match ? "βœ“" : "βœ—"} ${name}`, + `\n deployed on testnet: ${deployedHash}`, + `\n local build: ${localHash}`, + match ? "" : "\n MISMATCH β€” local bytecode differs from what is deployed on testnet", + ); +} + +process.exit(allMatch ? 0 : 1); From a9afa2376e42744ade78d9968ad0c7d0c69f38c4 Mon Sep 17 00:00:00 2001 From: Fab the Analyst Date: Mon, 24 Aug 2026 08:33:53 +0000 Subject: [PATCH 2/2] ci: pin rust-toolchain to @1.95.0 instead of @stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI now builds with the same exact rustc 1.95.0 that rust-toolchain.toml pins locally (and that the deployed testnet contracts were built with), via dtolnay/rust-toolchain's rev-based selection. dtolnay/rust-toolchain has no native rust-toolchain.toml support β€” its `toolchain` input is required β€” so @master with no input would have failed; @1.95.0 is the documented way to pin an exact toolchain. The wasm32v1-none target is still installed explicitly in the wasm-build job. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580340d..bc2436f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,12 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + # Pin CI to the exact rustc the repo pins locally: dtolnay/rust-toolchain + # selects the toolchain from the action's @rev, so @1.95.0 installs + # rustc 1.95.0 β€” the version in rust-toolchain.toml that the deployed + # testnet contracts were built with β€” instead of @stable ("latest + # stable"). rustfmt/clippy are installed on top of that pinned toolchain. + - uses: dtolnay/rust-toolchain@1.95.0 with: components: rustfmt, clippy @@ -27,7 +32,10 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + # Same pinned toolchain as the test job. The wasm32v1-none target is + # installed explicitly here (locally it is auto-installed from + # rust-toolchain.toml instead). + - uses: dtolnay/rust-toolchain@1.95.0 with: targets: wasm32v1-none