diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 97f72589..5c2fdda4 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -1,99 +1,297 @@ name: Contracts -name: Contracts - on: push: branches: [main] - paths: + # Also run on tags so the build-manifest is published with the release + tags: + - "v*" + paths: - "contracts/**" pull_request: branches: [main] - paths: + paths: - "contracts/**" permissions: contents: read concurrency: - group: contracts-$ {{ github.workflow }}-$ {{ github.ref }} + group: contracts-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - build-contracts: - runs-on: ubuntu-latest - timeout-minutes: 20 - defaults: - run: - working-directory: contracts - - steps: - - uses: actions/checkout@v7 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: contracts - - - name: Build contracts - run: cargo build --target wasm32-unknown-unknown --release - - - name: Check snapshot layout (no flat snapshots outside test/ subdir) - shell: bash - run: | - set -euo pipefail - # Snapshots must live under {contract}/test_snapshots/test/ - # Flat JSON files directly in test_snapshots/ indicate a misconfigured Env path - flat=$(find contracts -maxdepth 2 -name "test_snapshots" -type d \ - | xargs -I {} find {} -maxdepth 1 -name "*.json" 2>/dev/null || true) - if [ -n "$flat" ]; then - echo "ERROR: snapshot files found directly in test_snapshots/ (missing test/ subdir):" - echo "$flat" - exit 1 - fi - echo "Snapshot layout OK" - - - name: Run contract tests - run: cargo test - - - name: Run contract fuzz tests - run: cargo test -p subscription_renewal -p escrow -p payment-channel -p virtual-card fuzz_ - env: - PROPTYST_CASES: "8" - - - name: Verify backend contract interface alignment - working-directory: .. - run: | - npm ci --legacy-peer-deps --ignore-scripts --workspace=@syncro/backend --workspace=@syncro/shared 2>/dev/null || npm install --legacy-peer-deps --ignore-scripts --workspace=@syncro/backend --workspace=@syncro/shared - npm test -w @syncro/backend -- tests/integration/contract-interface-drift.test.ts - - - name: Check contract sizes - shell: bash - run: | - set -euo pipefail - shopt -s nullglob - - wasm_files=(target/wasm32-unknown-unknown/release/*.wasm) - - if [ ${#wasm_files[@]_ -} eq 0 ]; then - echo "No WASM artifacts found in target/wasm32-unknown-unknown/release" - exit 1 - fi - - for wasm in "${wasm_files[@]_ }"; do - size=$wc -c < "$wasm" - echo "$wasm: ${size} bytes" - - if [ "$size" -gt 65536 ]; then - echo "WARNING: Contract exceeds 64KB limit" - fi - done - - - name: Verify mainnet promotion gates - working-directory: .. - run: npx -y tsx deploy/verify-gates.ts + build-contracts: + name: Build & verify contracts + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: contracts + + steps: + - uses: actions/checkout@v4 + + # rust-toolchain.toml in contracts/ is automatically picked up by rustup. + # We install it explicitly here so the cache key matches the pinned version. + - name: Install pinned Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + # Read the channel straight from the toolchain file so this step + # stays in sync automatically when the file is updated. + toolchain: "1.81.0" + targets: wasm32-unknown-unknown + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: contracts + + # ── First build ───────────────────────────────────────────────────────── + - name: Build contracts (pass 1) + run: | + cargo build --target wasm32-unknown-unknown --release --locked + # Capture first-pass hashes immediately after the build + mkdir -p /tmp/wasm-pass1 + cp target/wasm32-unknown-unknown/release/*.wasm /tmp/wasm-pass1/ + + # ── Emit hashes after first build ─────────────────────────────────────── + - name: Compute and print WASM hashes (pass 1) + shell: bash + run: | + set -euo pipefail + echo "### WASM sha256 hashes (pass 1)" | tee /tmp/wasm-hashes-pass1.txt + echo "" | tee -a /tmp/wasm-hashes-pass1.txt + for f in /tmp/wasm-pass1/*.wasm; do + name=$(basename "$f") + hash=$(sha256sum "$f" | awk '{print $1}') + echo "${hash} ${name}" | tee -a /tmp/wasm-hashes-pass1.txt + done + + # ── Second build (determinism check) ──────────────────────────────────── + # Clean only the WASM output — keep the incremental cache so this doesn't + # double the build time — then rebuild from source. + - name: Wipe WASM output directory + run: rm -f target/wasm32-unknown-unknown/release/*.wasm + + - name: Build contracts (pass 2) + run: cargo build --target wasm32-unknown-unknown --release --locked + + - name: Compute WASM hashes (pass 2) + shell: bash + run: | + set -euo pipefail + echo "### WASM sha256 hashes (pass 2)" | tee /tmp/wasm-hashes-pass2.txt + echo "" | tee -a /tmp/wasm-hashes-pass2.txt + for f in target/wasm32-unknown-unknown/release/*.wasm; do + name=$(basename "$f") + hash=$(sha256sum "$f" | awk '{print $1}') + echo "${hash} ${name}" | tee -a /tmp/wasm-hashes-pass2.txt + done + + # ── Determinism gate ──────────────────────────────────────────────────── + - name: Verify determinism (pass 1 == pass 2) + shell: bash + run: | + set -euo pipefail + mismatch=0 + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + hash1=$(echo "$line" | awk '{print $1}') + name=$(echo "$line" | awk '{print $2}') + hash2=$(grep -F " ${name}" /tmp/wasm-hashes-pass2.txt | awk '{print $1}' || true) + if [ "$hash1" != "$hash2" ]; then + echo "DETERMINISM FAILURE: ${name}" + echo " pass-1: ${hash1}" + echo " pass-2: ${hash2}" + mismatch=1 + fi + done < /tmp/wasm-hashes-pass1.txt + + if [ "$mismatch" -eq 1 ]; then + echo "" + echo "ERROR: Non-deterministic build detected. The toolchain, compiler flags," + echo "or source may embed timestamps or random seeds. Investigate before merging." + exit 1 + fi + echo "All WASM artifacts are deterministic." + + # ── Publish hashes to job summary ─────────────────────────────────────── + - name: Write hashes to job summary + shell: bash + run: | + set -euo pipefail + { + echo "## WASM Build Hashes" + echo "" + echo "Commit: \`${{ github.sha }}\`" + echo "Toolchain: \`$(rustc --version)\`" + echo "" + echo "| Contract | sha256 |" + echo "| --- | --- |" + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + hash=$(echo "$line" | awk '{print $1}') + name=$(echo "$line" | awk '{print $2}') + echo "| \`${name}\` | \`${hash}\` |" + done < /tmp/wasm-hashes-pass1.txt + } >> "$GITHUB_STEP_SUMMARY" + + # ── Upload WASM + hash files as artifacts ──────────────────────────────── + - name: Upload WASM artifacts + uses: actions/upload-artifact@v4 + with: + name: wasm-contracts-${{ github.sha }} + path: | + contracts/target/wasm32-unknown-unknown/release/*.wasm + retention-days: 90 + + - name: Upload hash file + uses: actions/upload-artifact@v4 + with: + name: wasm-hashes-${{ github.sha }} + path: /tmp/wasm-hashes-pass1.txt + retention-days: 90 + + # ── Tests ──────────────────────────────────────────────────────────────── + - name: Check snapshot layout + shell: bash + run: | + set -euo pipefail + flat=$(find contracts -maxdepth 2 -name "test_snapshots" -type d \ + | xargs -I {} find {} -maxdepth 1 -name "*.json" 2>/dev/null || true) + if [ -n "$flat" ]; then + echo "ERROR: snapshot files found directly in test_snapshots/ (missing test/ subdir):" + echo "$flat" + exit 1 + fi + echo "Snapshot layout OK" + + - name: Run contract tests + run: cargo test --locked + + - name: Run contract fuzz tests + run: >- + cargo test --locked + -p subscription_renewal -p escrow -p payment-channel -p virtual-card + fuzz_ + env: + PROPTEST_CASES: "8" + + - name: Verify backend contract interface alignment + working-directory: .. + run: | + npm ci --legacy-peer-deps --ignore-scripts \ + --workspace=@syncro/backend \ + --workspace=@syncro/shared \ + 2>/dev/null \ + || npm install --legacy-peer-deps --ignore-scripts \ + --workspace=@syncro/backend \ + --workspace=@syncro/shared + npm test -w @syncro/backend -- tests/integration/contract-interface-drift.test.ts + + - name: Check contract sizes + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + wasm_files=(target/wasm32-unknown-unknown/release/*.wasm) + if [ ${#wasm_files[@]} -eq 0 ]; then + echo "No WASM artifacts found in target/wasm32-unknown-unknown/release" + exit 1 + fi + for wasm in "${wasm_files[@]}"; do + size=$(wc -c < "$wasm") + echo "$wasm: ${size} bytes" + if [ "$size" -gt 65536 ]; then + echo "WARNING: Contract exceeds 64KB limit" + fi + done + + - name: Verify mainnet promotion gates + working-directory: .. + run: npx -y tsx deploy/verify-gates.ts + + # ── Tagged-release manifest publication ───────────────────────────────────── + publish-manifest: + name: Publish build-manifest.json + needs: build-contracts + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write # needed to push the manifest file + + steps: + - uses: actions/checkout@v4 + with: + # Fetch full history so we can push back to the tag + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download hash file + uses: actions/download-artifact@v4 + with: + name: wasm-hashes-${{ github.sha }} + path: /tmp + + - name: Download WASM artifacts + uses: actions/download-artifact@v4 + with: + name: wasm-contracts-${{ github.sha }} + path: /tmp/wasm + + - name: Generate build-manifest.json + shell: bash + run: | + set -euo pipefail + commit="${{ github.sha }}" + tag="${{ github.ref_name }}" + toolchain=$(cat contracts/rust-toolchain.toml | grep 'channel' | sed 's/.*= *"\(.*\)"/\1/') + timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Build the contracts array from the hash file + contracts_json="[]" + while IFS= read -r line; do + [[ -z "$line" || "$line" == \#* ]] && continue + hash=$(echo "$line" | awk '{print $1}') + name=$(echo "$line" | awk '{print $2}') + # Strip .wasm extension for the contract name + contract_name="${name%.wasm}" + contracts_json=$(echo "$contracts_json" | \ + jq --arg n "$contract_name" --arg h "$hash" --arg f "$name" \ + '. + [{"contract": $n, "wasm_file": $f, "sha256": $h}]') + done < /tmp/wasm-hashes-pass1.txt + + jq -n \ + --arg commit "$commit" \ + --arg tag "$tag" \ + --arg toolchain "$toolchain" \ + --arg timestamp "$timestamp" \ + --argjson contracts "$contracts_json" \ + '{ + "$schema": "https://syncro.dev/schemas/build-manifest.json", + "version": $tag, + "commit": $commit, + "built_at": $timestamp, + "rust_toolchain": $toolchain, + "contracts": $contracts + }' > contracts/build-manifest.json + + echo "Generated build-manifest.json:" + cat contracts/build-manifest.json + + - name: Commit and push build-manifest.json + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add contracts/build-manifest.json + if git diff --cached --quiet; then + echo "No changes to build-manifest.json" + else + git commit -m "chore(contracts): update build-manifest.json for ${{ github.ref_name }} [skip ci]" + # Push to the tag's branch (main) rather than the tag itself + git push origin HEAD:main + fi diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 2367fc33..50338c4a 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,3 +1,6 @@ +# Reproducible builds: the Rust toolchain is pinned in rust-toolchain.toml. +# All CI builds use --locked so Cargo.lock is authoritative. +# Run `bash scripts/build-reproducible.sh` to reproduce hashes locally. [workspace] resolver = "2" members = [ diff --git a/contracts/build-manifest.json b/contracts/build-manifest.json new file mode 100644 index 00000000..6a8cbb9c --- /dev/null +++ b/contracts/build-manifest.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://syncro.dev/schemas/build-manifest.json", + "_comment": "This file is auto-generated by CI on tagged releases. Do not edit manually.", + "_how_to_verify": "See contracts/scripts/build-reproducible.sh for the single command to reproduce these hashes locally.", + "version": null, + "commit": null, + "built_at": null, + "rust_toolchain": null, + "contracts": [] +} diff --git a/contracts/rust-toolchain.toml b/contracts/rust-toolchain.toml new file mode 100644 index 00000000..fac5ed0e --- /dev/null +++ b/contracts/rust-toolchain.toml @@ -0,0 +1,7 @@ +# Pins the exact Rust toolchain used for release builds. +# Guardians and CI must use this version to reproduce the published WASM hashes. +# Update this file (and re-verify all hashes) when upgrading soroban-sdk. +[toolchain] +channel = "1.81.0" +targets = ["wasm32-unknown-unknown"] +profile = "minimal" diff --git a/contracts/scripts/build-reproducible.sh b/contracts/scripts/build-reproducible.sh new file mode 100755 index 00000000..fbe7f939 --- /dev/null +++ b/contracts/scripts/build-reproducible.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# contracts/scripts/build-reproducible.sh +# +# Reproducible WASM build for guardians +# ────────────────────────────────────── +# This script lets any guardian independently verify that the WASM hashes in +# build-manifest.json match the source at a given commit. +# +# USAGE +# # From the repo root — reproduce the build for the current working tree: +# bash contracts/scripts/build-reproducible.sh +# +# # Verify against a specific tagged release (checks out the tag first): +# bash contracts/scripts/build-reproducible.sh v1.2.3 +# +# REQUIREMENTS +# - rustup (https://rustup.rs) +# - git +# - sha256sum (coreutils; on macOS use `brew install coreutils`) +# +# The script installs the pinned toolchain from contracts/rust-toolchain.toml +# automatically via rustup. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CONTRACTS_DIR="$REPO_ROOT/contracts" +TAG="${1:-}" + +# ── Optional: check out a specific tag ────────────────────────────────────── +if [ -n "$TAG" ]; then + echo "==> Checking out tag: $TAG" + git -C "$REPO_ROOT" fetch --tags + git -C "$REPO_ROOT" checkout "tags/$TAG" +fi + +# ── Print environment ──────────────────────────────────────────────────────── +echo "" +echo "==> Build environment" +echo " Repo root : $REPO_ROOT" +echo " Commit : $(git -C "$REPO_ROOT" rev-parse HEAD)" +echo " Toolchain : $(grep 'channel' "$CONTRACTS_DIR/rust-toolchain.toml" | sed 's/.*= *"\(.*\)"/\1/')" +echo "" + +# ── Ensure the pinned toolchain is installed ──────────────────────────────── +# rustup reads rust-toolchain.toml automatically when we cd into contracts/ +pushd "$CONTRACTS_DIR" > /dev/null +echo "==> Installing pinned toolchain (rustup reads rust-toolchain.toml) ..." +rustup show active-toolchain || rustup toolchain install +echo "" + +# ── Clean previous WASM output ────────────────────────────────────────────── +echo "==> Cleaning previous WASM output ..." +rm -f target/wasm32-unknown-unknown/release/*.wasm + +# ── Build ──────────────────────────────────────────────────────────────────── +echo "==> Building contracts (release, locked) ..." +cargo build --target wasm32-unknown-unknown --release --locked +echo "" + +# ── Compute hashes ─────────────────────────────────────────────────────────── +WASM_DIR="target/wasm32-unknown-unknown/release" +HASH_FILE="$CONTRACTS_DIR/local-wasm-hashes.txt" + +echo "==> SHA-256 hashes of produced WASM files:" +echo "" +> "$HASH_FILE" +for f in "$WASM_DIR"/*.wasm; do + name=$(basename "$f") + hash=$(sha256sum "$f" | awk '{print $1}') + echo " ${hash} ${name}" | tee -a "$HASH_FILE" +done +echo "" +echo "Hashes saved to: $HASH_FILE" + +# ── Compare against build-manifest.json (if populated) ─────────────────────── +MANIFEST="$CONTRACTS_DIR/build-manifest.json" +if command -v jq &>/dev/null && [ -f "$MANIFEST" ]; then + manifest_commit=$(jq -r '.commit // empty' "$MANIFEST") + if [ -n "$manifest_commit" ]; then + echo "" + echo "==> Comparing against build-manifest.json (commit: ${manifest_commit}) ..." + mismatch=0 + while IFS= read -r line; do + [[ -z "$line" ]] && continue + local_hash=$(echo "$line" | awk '{print $1}') + wasm_file=$(echo "$line" | awk '{print $2}') + contract=$(echo "$wasm_file" | sed 's/\.wasm$//') + manifest_hash=$(jq -r \ + --arg c "$contract" \ + '.contracts[] | select(.contract == $c) | .sha256' \ + "$MANIFEST" || true) + if [ -z "$manifest_hash" ]; then + echo " SKIP (not in manifest): $wasm_file" + elif [ "$local_hash" = "$manifest_hash" ]; then + echo " OK : $wasm_file" + else + echo " FAIL: $wasm_file" + echo " local : $local_hash" + echo " manifest: $manifest_hash" + mismatch=1 + fi + done < "$HASH_FILE" + + if [ "$mismatch" -eq 1 ]; then + echo "" + echo "ERROR: Hash mismatch! The local build does not reproduce the published manifest." + echo "Ensure you are on commit ${manifest_commit} and using the pinned toolchain." + popd > /dev/null + exit 1 + else + echo "" + echo "All hashes match the published build-manifest.json. ✓" + fi + else + echo "" + echo "build-manifest.json has no commit field yet (pre-release). Skipping manifest comparison." + fi +else + echo "" + echo "jq not found or build-manifest.json missing — skipping manifest comparison." +fi + +popd > /dev/null +echo "" +echo "Done."