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 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);