From 02723f65dd9fb15010670c601bb7b89f6ab7882e Mon Sep 17 00:00:00 2001 From: oscar24357 Date: Sat, 17 Oct 2026 14:51:31 +0100 Subject: [PATCH] feat: reproducible escrow contract deployment workflow - Add rust-toolchain.toml pinning Rust 1.82.0 + wasm32 target - Add scripts/build-verify.sh for deterministic builds with SHA-256 artifact hashing - Add scripts/deploy-testnet.sh with full deployment record capture - Add scripts/deploy-mainnet.sh with manual approval gate and credential isolation - Add scripts/verify-deployment.sh for independent artifact verification - Add .github/workflows/contracts-ci.yml with pinned-toolchain CI, full test suite, Testnet deploy job, and Mainnet deploy behind environment approval gate - Add contracts/artifacts/ and contracts/deployments/ directories - Update contracts/.gitignore to exclude WASM binaries, keep JSON records - Add docs/escrow-deployment-guide.md with full setup, deployment, and verification docs --- .github/workflows/contracts-ci.yml | 401 +++++++++++++++++++++++++ contracts/.gitignore | 8 + contracts/artifacts/.gitkeep | 1 + contracts/deployments/.gitkeep | 1 + contracts/rust-toolchain.toml | 4 + contracts/scripts/build-verify.sh | 120 ++++++++ contracts/scripts/deploy-mainnet.sh | 237 +++++++++++++++ contracts/scripts/deploy-testnet.sh | 208 +++++++++++++ contracts/scripts/verify-deployment.sh | 175 +++++++++++ docs/escrow-deployment-guide.md | 354 ++++++++++++++++++++++ 10 files changed, 1509 insertions(+) create mode 100644 .github/workflows/contracts-ci.yml create mode 100644 contracts/artifacts/.gitkeep create mode 100644 contracts/deployments/.gitkeep create mode 100644 contracts/rust-toolchain.toml create mode 100644 contracts/scripts/build-verify.sh create mode 100644 contracts/scripts/deploy-mainnet.sh create mode 100644 contracts/scripts/deploy-testnet.sh create mode 100644 contracts/scripts/verify-deployment.sh create mode 100644 docs/escrow-deployment-guide.md diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml new file mode 100644 index 0000000..d6136e6 --- /dev/null +++ b/.github/workflows/contracts-ci.yml @@ -0,0 +1,401 @@ +name: Contracts CI — Build, Test & Deploy + +on: + push: + branches: [main, develop] + paths: + - 'contracts/**' + - '.github/workflows/contracts-ci.yml' + pull_request: + branches: [main, develop] + paths: + - 'contracts/**' + - '.github/workflows/contracts-ci.yml' + workflow_dispatch: + inputs: + deploy_target: + description: 'Deployment target (none | testnet | mainnet)' + required: false + default: 'none' + +# Only one deployment workflow runs at a time per branch +concurrency: + group: contracts-${{ github.ref }} + cancel-in-progress: false + +env: + # Pinned versions — update rust-toolchain.toml first, then update here + RUST_CHANNEL: "1.82.0" + SOROBAN_SDK_VERSION: "25.3.1" + +jobs: + # --------------------------------------------------------------------------- + # Job 1: Build contract and verify artifact + # --------------------------------------------------------------------------- + build: + name: Build & Verify Artifact + runs-on: ubuntu-latest + + outputs: + wasm_hash: ${{ steps.hash.outputs.wasm_hash }} + git_commit: ${{ steps.meta.outputs.git_commit }} + git_tag: ${{ steps.meta.outputs.git_tag }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain (pinned) + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_CHANNEL }} + targets: wasm32-unknown-unknown + components: rustfmt,clippy + + - name: Cache Cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contracts/target + key: contracts-${{ env.RUST_CHANNEL }}-${{ hashFiles('contracts/Cargo.lock') }} + restore-keys: | + contracts-${{ env.RUST_CHANNEL }}- + + - name: Validate toolchain version + working-directory: contracts + run: | + ACTUAL=$(rustc --version) + echo "Rust version: $ACTUAL" + echo "Expected channel: ${{ env.RUST_CHANNEL }}" + if ! echo "$ACTUAL" | grep -q "${{ env.RUST_CHANNEL }}"; then + echo "::error::Toolchain mismatch. Expected ${{ env.RUST_CHANNEL }}, got $ACTUAL" + exit 1 + fi + + - name: Check formatting + working-directory: contracts + run: cargo fmt --all -- --check + + - name: Run clippy + working-directory: contracts + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Build WASM artifact (--locked) + working-directory: contracts + run: | + RUSTFLAGS="-C strip=symbols" cargo build \ + --package escrow \ + --release \ + --target wasm32-unknown-unknown \ + --locked + + - name: Stage and hash artifact + id: hash + working-directory: contracts + run: | + mkdir -p artifacts + cp contracts/escrow/target/wasm32-unknown-unknown/release/escrow.wasm artifacts/escrow.wasm + WASM_HASH=$(sha256sum artifacts/escrow.wasm | awk '{print $1}') + echo "wasm_hash=$WASM_HASH" >> "$GITHUB_OUTPUT" + echo "$WASM_HASH escrow.wasm" > artifacts/escrow.wasm.sha256 + echo "WASM hash: $WASM_HASH" + + - name: Capture build metadata + id: meta + working-directory: contracts + run: | + GIT_COMMIT="${{ github.sha }}" + GIT_TAG="${{ github.ref_name }}" + RUST_VER=$(rustc --version) + WASM_SIZE=$(wc -c < artifacts/escrow.wasm) + SOROBAN_VER=$(grep -A1 'name = "soroban-sdk"' Cargo.lock \ + | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "unknown") + BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + echo "git_commit=$GIT_COMMIT" >> "$GITHUB_OUTPUT" + echo "git_tag=$GIT_TAG" >> "$GITHUB_OUTPUT" + + cat > artifacts/build-info.json <&1 | tee test-output.txt + + - name: Check for test failures + working-directory: contracts + run: | + if grep -q "test result: FAILED" test-output.txt; then + echo "::error::One or more contract tests FAILED. Deployment blocked." + exit 1 + fi + echo "All tests passed." + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ github.sha }} + path: contracts/test-output.txt + retention-days: 30 + + # --------------------------------------------------------------------------- + # Job 3: Deployment configuration validation + # --------------------------------------------------------------------------- + validate-config: + name: Validate Deployment Configuration + runs-on: ubuntu-latest + needs: [build, test] + if: github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_target != 'none' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate Testnet configuration + if: github.event.inputs.deploy_target == 'testnet' + env: + TESTNET_DEPLOYER_SECRET: ${{ secrets.TESTNET_DEPLOYER_SECRET }} + run: | + echo "Validating Testnet deployment configuration..." + MISSING="" + [ -z "$TESTNET_DEPLOYER_SECRET" ] && MISSING="$MISSING TESTNET_DEPLOYER_SECRET" + if [ -n "$MISSING" ]; then + echo "::error::Missing required secrets for Testnet deployment:$MISSING" + exit 1 + fi + echo "Testnet configuration valid." + + - name: Validate Mainnet configuration + if: github.event.inputs.deploy_target == 'mainnet' + env: + MAINNET_DEPLOYER_SECRET: ${{ secrets.MAINNET_DEPLOYER_SECRET }} + run: | + echo "Validating Mainnet deployment configuration..." + MISSING="" + [ -z "$MAINNET_DEPLOYER_SECRET" ] && MISSING="$MISSING MAINNET_DEPLOYER_SECRET" + if [ -n "$MISSING" ]; then + echo "::error::Missing required secrets for Mainnet deployment:$MISSING" + exit 1 + fi + # Ensure mainnet secret is not the same as testnet + if [ -n "${{ secrets.TESTNET_DEPLOYER_SECRET }}" ] && \ + [ "$MAINNET_DEPLOYER_SECRET" = "${{ secrets.TESTNET_DEPLOYER_SECRET }}" ]; then + echo "::error::MAINNET_DEPLOYER_SECRET must differ from TESTNET_DEPLOYER_SECRET." + exit 1 + fi + echo "Mainnet configuration valid." + + # --------------------------------------------------------------------------- + # Job 4: Deploy to Testnet (on workflow_dispatch only) + # --------------------------------------------------------------------------- + deploy-testnet: + name: Deploy to Testnet + runs-on: ubuntu-latest + environment: testnet + needs: [build, test, validate-config] + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.deploy_target == 'testnet' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain (pinned) + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_CHANNEL }} + targets: wasm32-unknown-unknown + + - name: Install stellar CLI + run: | + curl -fsSL https://github.com/stellar/stellar-cli/releases/latest/download/stellar-cli-x86_64-unknown-linux-gnu.tar.gz \ + | tar -xz -C /usr/local/bin stellar + stellar --version + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: escrow-wasm-${{ github.sha }} + path: contracts/artifacts + + - name: Verify artifact hash before deploy + working-directory: contracts + run: | + EXPECTED="${{ needs.build.outputs.wasm_hash }}" + ACTUAL=$(sha256sum artifacts/escrow.wasm | awk '{print $1}') + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "::error::Artifact hash mismatch. Expected $EXPECTED, got $ACTUAL. Deployment blocked." + exit 1 + fi + echo "Artifact hash verified: $ACTUAL" + + - name: Deploy to Testnet + working-directory: contracts + env: + TESTNET_DEPLOYER_SECRET: ${{ secrets.TESTNET_DEPLOYER_SECRET }} + SKIP_BUILD: "1" + run: | + chmod +x scripts/deploy-testnet.sh + bash scripts/deploy-testnet.sh + + - name: Upload deployment record + uses: actions/upload-artifact@v4 + with: + name: deployment-testnet-${{ github.sha }} + path: contracts/deployments/testnet-*.json + if-no-files-found: error + retention-days: 365 + + - name: Verify deployment + working-directory: contracts + run: | + RECORD=$(ls -t deployments/testnet-*.json | head -1) + chmod +x scripts/verify-deployment.sh + bash scripts/verify-deployment.sh "$RECORD" + + # --------------------------------------------------------------------------- + # Job 5: Mainnet approval gate + deploy + # --------------------------------------------------------------------------- + mainnet-approval: + name: Mainnet — Manual Approval Gate + runs-on: ubuntu-latest + environment: mainnet-approval + needs: [build, test, validate-config] + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.deploy_target == 'mainnet' + + steps: + - name: Approval checkpoint + run: | + echo "Mainnet deployment approved by a repository maintainer." + echo "Proceeding to deployment job." + + deploy-mainnet: + name: Deploy to Mainnet + runs-on: ubuntu-latest + environment: mainnet + needs: [build, test, validate-config, mainnet-approval] + if: | + github.event_name == 'workflow_dispatch' && + github.event.inputs.deploy_target == 'mainnet' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain (pinned) + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_CHANNEL }} + targets: wasm32-unknown-unknown + + - name: Install stellar CLI + run: | + curl -fsSL https://github.com/stellar/stellar-cli/releases/latest/download/stellar-cli-x86_64-unknown-linux-gnu.tar.gz \ + | tar -xz -C /usr/local/bin stellar + stellar --version + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: escrow-wasm-${{ github.sha }} + path: contracts/artifacts + + - name: Verify artifact hash before deploy + working-directory: contracts + run: | + EXPECTED="${{ needs.build.outputs.wasm_hash }}" + ACTUAL=$(sha256sum artifacts/escrow.wasm | awk '{print $1}') + if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "::error::Artifact hash mismatch. Expected $EXPECTED, got $ACTUAL. Deployment blocked." + exit 1 + fi + echo "Artifact hash verified: $ACTUAL" + + - name: Deploy to Mainnet + working-directory: contracts + env: + MAINNET_DEPLOYER_SECRET: ${{ secrets.MAINNET_DEPLOYER_SECRET }} + MAINNET_DEPLOY_CONFIRMED: "yes" + SKIP_BUILD: "1" + run: | + chmod +x scripts/deploy-mainnet.sh + bash scripts/deploy-mainnet.sh + + - name: Upload deployment record + uses: actions/upload-artifact@v4 + with: + name: deployment-mainnet-${{ github.sha }} + path: contracts/deployments/mainnet-*.json + if-no-files-found: error + retention-days: 3650 + + - name: Verify deployment + working-directory: contracts + run: | + RECORD=$(ls -t deployments/mainnet-*.json | head -1) + chmod +x scripts/verify-deployment.sh + bash scripts/verify-deployment.sh "$RECORD" diff --git a/contracts/.gitignore b/contracts/.gitignore index d74cc01..fa0442d 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -4,3 +4,11 @@ target # Local settings .soroban .stellar + +# Build artifacts (WASM binaries) — not committed; regenerated from source +artifacts/*.wasm +artifacts/*.sha256 + +# Keep build-info.json and deployment records in version control +!artifacts/build-info.json +!deployments/*.json diff --git a/contracts/artifacts/.gitkeep b/contracts/artifacts/.gitkeep new file mode 100644 index 0000000..d7209c5 --- /dev/null +++ b/contracts/artifacts/.gitkeep @@ -0,0 +1 @@ +# This directory stores build artifacts. diff --git a/contracts/deployments/.gitkeep b/contracts/deployments/.gitkeep new file mode 100644 index 0000000..3244708 --- /dev/null +++ b/contracts/deployments/.gitkeep @@ -0,0 +1 @@ +# This directory stores deployment records. diff --git a/contracts/rust-toolchain.toml b/contracts/rust-toolchain.toml new file mode 100644 index 0000000..1e9f454 --- /dev/null +++ b/contracts/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.82.0" +targets = ["wasm32-unknown-unknown"] +components = ["rustfmt", "clippy"] diff --git a/contracts/scripts/build-verify.sh b/contracts/scripts/build-verify.sh new file mode 100644 index 0000000..21a9c82 --- /dev/null +++ b/contracts/scripts/build-verify.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# build-verify.sh — Deterministic build and artifact verification for the escrow contract. +# +# Outputs: +# contracts/artifacts/escrow.wasm — optimised WASM binary +# contracts/artifacts/escrow.wasm.sha256 — SHA-256 hash of the binary +# contracts/artifacts/build-info.json — build provenance record +# +# Exit codes: +# 0 — success +# 1 — build or verification failure + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTRACTS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ARTIFACTS_DIR="$CONTRACTS_ROOT/artifacts" +WASM_RELEASE_PATH="$CONTRACTS_ROOT/contracts/escrow/target/wasm32-unknown-unknown/release/escrow.wasm" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { echo "[build-verify] $*"; } +fail() { echo "[build-verify] ERROR: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Toolchain validation +# --------------------------------------------------------------------------- +log "Validating Rust toolchain..." +RUST_VERSION=$(rustc --version 2>/dev/null) \ + || fail "rustc not found. Install Rust from https://rustup.rs" +log " rustc: $RUST_VERSION" + +CARGO_VERSION=$(cargo --version 2>/dev/null) \ + || fail "cargo not found." +log " cargo: $CARGO_VERSION" + +rustup target list --installed 2>/dev/null | grep -q "wasm32-unknown-unknown" \ + || fail "wasm32-unknown-unknown target not installed. Run: rustup target add wasm32-unknown-unknown" + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- +log "Building escrow contract (release, wasm32-unknown-unknown)..." +cd "$CONTRACTS_ROOT" + +# Use RUSTFLAGS for reproducibility: no embedded paths, no timestamps +RUSTFLAGS="-C strip=symbols" \ + cargo build \ + --manifest-path "$CONTRACTS_ROOT/Cargo.toml" \ + --package escrow \ + --release \ + --target wasm32-unknown-unknown \ + --locked \ + || fail "cargo build failed" + +[ -f "$WASM_RELEASE_PATH" ] || fail "WASM artifact not found at $WASM_RELEASE_PATH" + +# --------------------------------------------------------------------------- +# Artifact staging +# --------------------------------------------------------------------------- +mkdir -p "$ARTIFACTS_DIR" +cp "$WASM_RELEASE_PATH" "$ARTIFACTS_DIR/escrow.wasm" +WASM_SIZE=$(wc -c < "$ARTIFACTS_DIR/escrow.wasm") +log "WASM artifact staged: $ARTIFACTS_DIR/escrow.wasm ($WASM_SIZE bytes)" + +# --------------------------------------------------------------------------- +# Hash computation +# --------------------------------------------------------------------------- +log "Computing SHA-256 hash..." +if command -v sha256sum >/dev/null 2>&1; then + WASM_HASH=$(sha256sum "$ARTIFACTS_DIR/escrow.wasm" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + WASM_HASH=$(shasum -a 256 "$ARTIFACTS_DIR/escrow.wasm" | awk '{print $1}') +else + fail "Neither sha256sum nor shasum found. Cannot compute artifact hash." +fi + +echo "$WASM_HASH escrow.wasm" > "$ARTIFACTS_DIR/escrow.wasm.sha256" +log " WASM hash: $WASM_HASH" + +# --------------------------------------------------------------------------- +# Build provenance record +# --------------------------------------------------------------------------- +GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") +GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") +GIT_DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') +BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +SOROBAN_SDK_VERSION=$(grep -A1 'name = "soroban-sdk"' "$CONTRACTS_ROOT/Cargo.lock" \ + | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "unknown") + +cat > "$ARTIFACTS_DIR/build-info.json" <.json — immutable deployment record + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTRACTS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ARTIFACTS_DIR="$CONTRACTS_ROOT/artifacts" +DEPLOYMENTS_DIR="$CONTRACTS_ROOT/deployments" +NETWORK="mainnet" +SOROBAN_RPC_URL="${SOROBAN_RPC_URL_MAINNET:-https://soroban-mainnet.stellar.org}" +NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" + +# --------------------------------------------------------------------------- +log() { echo "[deploy-mainnet] $*"; } +fail() { echo "[deploy-mainnet] ERROR: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Hard guard: reject if RPC points to Testnet +# --------------------------------------------------------------------------- +if echo "$SOROBAN_RPC_URL" | grep -qi "testnet"; then + fail "SOROBAN_RPC_URL appears to point to Testnet. Aborting Mainnet deployment." +fi + +# --------------------------------------------------------------------------- +# Secrets validation +# --------------------------------------------------------------------------- +[ -n "${MAINNET_DEPLOYER_SECRET:-}" ] \ + || fail "MAINNET_DEPLOYER_SECRET is not set." + +# Ensure Testnet key is not reused for Mainnet +if [ -n "${TESTNET_DEPLOYER_SECRET:-}" ] && \ + [ "$MAINNET_DEPLOYER_SECRET" = "$TESTNET_DEPLOYER_SECRET" ]; then + fail "MAINNET_DEPLOYER_SECRET must not be the same as TESTNET_DEPLOYER_SECRET." +fi + +# --------------------------------------------------------------------------- +# Manual approval gate +# --------------------------------------------------------------------------- +if [ "${MAINNET_DEPLOY_CONFIRMED:-}" != "yes" ]; then + echo "" + echo " ╔══════════════════════════════════════════════════════════╗" + echo " ║ *** MAINNET DEPLOYMENT WARNING *** ║" + echo " ║ You are about to deploy to the Stellar Mainnet. ║" + echo " ║ This action incurs real transaction fees and is ║" + echo " ║ irreversible once the contract is deployed. ║" + echo " ╚══════════════════════════════════════════════════════════╝" + echo "" + printf " Type exactly 'CONFIRM MAINNET DEPLOY' to proceed: " + read -r CONFIRMATION + if [ "$CONFIRMATION" != "CONFIRM MAINNET DEPLOY" ]; then + fail "Confirmation not received. Aborting." + fi +fi + +log "Mainnet deployment confirmed. Proceeding..." + +# --------------------------------------------------------------------------- +# Build step +# --------------------------------------------------------------------------- +if [ "${SKIP_BUILD:-0}" != "1" ]; then + log "Running build-verify.sh..." + bash "$SCRIPT_DIR/build-verify.sh" +fi + +WASM_PATH="$ARTIFACTS_DIR/escrow.wasm" +HASH_FILE="$ARTIFACTS_DIR/escrow.wasm.sha256" +BUILD_INFO="$ARTIFACTS_DIR/build-info.json" + +[ -f "$WASM_PATH" ] || fail "Artifact not found: $WASM_PATH" +[ -f "$HASH_FILE" ] || fail "Hash file not found: $HASH_FILE" +[ -f "$BUILD_INFO" ] || fail "Build info not found: $BUILD_INFO" + +WASM_HASH=$(awk '{print $1}' "$HASH_FILE") +log "WASM hash: $WASM_HASH" + +# --------------------------------------------------------------------------- +# Tooling check +# --------------------------------------------------------------------------- +command -v stellar >/dev/null 2>&1 \ + || fail "'stellar' CLI not found. Install: https://github.com/stellar/stellar-cli" + +# --------------------------------------------------------------------------- +# Network validation +# --------------------------------------------------------------------------- +log "Validating network connectivity to $SOROBAN_RPC_URL ..." +curl -sf --max-time 10 "$SOROBAN_RPC_URL" -o /dev/null \ + || fail "Cannot reach Mainnet RPC endpoint $SOROBAN_RPC_URL" +log "Network check passed." + +DEPLOYER_ADDRESS=$(stellar keys address --secret-key "$MAINNET_DEPLOYER_SECRET" 2>/dev/null) \ + || fail "Could not derive public key from MAINNET_DEPLOYER_SECRET." +log "Deployer address: $DEPLOYER_ADDRESS" + +# --------------------------------------------------------------------------- +# Upload WASM +# --------------------------------------------------------------------------- +log "Uploading WASM to Mainnet..." +UPLOAD_OUTPUT=$(stellar contract upload \ + --wasm "$WASM_PATH" \ + --source "$MAINNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + 2>&1) || fail "WASM upload failed:\n$UPLOAD_OUTPUT" + +WASM_ID=$(echo "$UPLOAD_OUTPUT" | grep -Eo '[0-9a-fA-F]{64}' | tail -1 || true) +log "WASM ID (on-chain): ${WASM_ID:-}" + +# --------------------------------------------------------------------------- +# Deploy contract +# --------------------------------------------------------------------------- +log "Deploying contract instance to Mainnet..." +DEPLOY_OUTPUT=$(stellar contract deploy \ + --wasm-hash "${WASM_ID:-}" \ + --source "$MAINNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + 2>&1) || { + log "wasm-hash deploy failed, falling back to --wasm ..." + DEPLOY_OUTPUT=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source "$MAINNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + 2>&1) || fail "Contract deploy failed:\n$DEPLOY_OUTPUT" +} + +CONTRACT_ID=$(echo "$DEPLOY_OUTPUT" | grep -Eo 'C[A-Z0-9]{55}' | head -1 || true) +DEPLOY_TX_HASH=$(echo "$DEPLOY_OUTPUT" | grep -Eo '[0-9a-fA-F]{64}' | head -1 || true) + +[ -n "$CONTRACT_ID" ] \ + || fail "Could not parse contract ID from deploy output:\n$DEPLOY_OUTPUT" + +log "Contract ID: $CONTRACT_ID" +log "Deploy TX : ${DEPLOY_TX_HASH:-}" + +# --------------------------------------------------------------------------- +# Optional: initialize contract +# --------------------------------------------------------------------------- +INIT_RECORD="null" +if [ -n "${INIT_ADMIN:-}" ] && [ -n "${INIT_CLIENT:-}" ] && \ + [ -n "${INIT_FREELANCER:-}" ] && [ -n "${INIT_ARBITER:-}" ] && \ + [ -n "${INIT_TOKEN:-}" ]; then + log "Calling initialize() on deployed contract..." + INIT_OUTPUT=$(stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source "$MAINNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- initialize \ + --admin "$INIT_ADMIN" \ + --client "$INIT_CLIENT" \ + --freelancer "$INIT_FREELANCER" \ + --arbiter "$INIT_ARBITER" \ + --token "$INIT_TOKEN" \ + --milestones '[]' \ + 2>&1) || fail "initialize() call failed:\n$INIT_OUTPUT" + log "initialize() succeeded." + + INIT_RECORD=$(cat </dev/null || echo "unknown") +GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") +RUST_VERSION=$(rustc --version 2>/dev/null || echo "unknown") +SOROBAN_SDK_VERSION=$(grep -A1 'name = "soroban-sdk"' "$CONTRACTS_ROOT/Cargo.lock" \ + | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "unknown") + +cat > "$RECORD_FILE" <}" +log " Timestamp : $DEPLOY_TIMESTAMP" +log " Record : $RECORD_FILE" +log "" +log "Verify with: ./scripts/verify-deployment.sh $RECORD_FILE" diff --git a/contracts/scripts/deploy-testnet.sh b/contracts/scripts/deploy-testnet.sh new file mode 100644 index 0000000..dc89a6d --- /dev/null +++ b/contracts/scripts/deploy-testnet.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# deploy-testnet.sh — Deploy the escrow contract to Stellar Testnet with full +# audit record capture. +# +# Prerequisites: +# - Run ./scripts/build-verify.sh first (or set SKIP_BUILD=1 if already built) +# - stellar CLI available in PATH +# - TESTNET_DEPLOYER_SECRET set in environment (never commit this value) +# +# Environment variables: +# TESTNET_DEPLOYER_SECRET — Stellar secret key for the deployer account (required) +# SKIP_BUILD — Set to 1 to skip the build step +# SOROBAN_RPC_URL — Override RPC endpoint (default: Testnet horizon RPC) +# INIT_ADMIN — Admin address for initialize() call +# INIT_CLIENT — Client address for initialize() call +# INIT_FREELANCER — Freelancer address for initialize() call +# INIT_ARBITER — Arbiter address for initialize() call +# INIT_TOKEN — Token contract address for initialize() call +# +# Outputs: +# contracts/deployments/testnet-.json — immutable deployment record + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTRACTS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ARTIFACTS_DIR="$CONTRACTS_ROOT/artifacts" +DEPLOYMENTS_DIR="$CONTRACTS_ROOT/deployments" +NETWORK="testnet" +SOROBAN_RPC_URL="${SOROBAN_RPC_URL:-https://soroban-testnet.stellar.org}" +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + +# --------------------------------------------------------------------------- +log() { echo "[deploy-testnet] $*"; } +fail() { echo "[deploy-testnet] ERROR: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# Guard: secrets must never be empty +# --------------------------------------------------------------------------- +[ -n "${TESTNET_DEPLOYER_SECRET:-}" ] \ + || fail "TESTNET_DEPLOYER_SECRET is not set. Export it before running this script." + +# Confirm we are targeting Testnet passphrase, never Mainnet +if echo "${STELLAR_NETWORK_PASSPHRASE:-}" | grep -q "Public Global Stellar Network"; then + fail "STELLAR_NETWORK_PASSPHRASE points to Mainnet. Aborting Testnet deployment." +fi + +# --------------------------------------------------------------------------- +# Build step +# --------------------------------------------------------------------------- +if [ "${SKIP_BUILD:-0}" != "1" ]; then + log "Running build-verify.sh..." + bash "$SCRIPT_DIR/build-verify.sh" +fi + +WASM_PATH="$ARTIFACTS_DIR/escrow.wasm" +HASH_FILE="$ARTIFACTS_DIR/escrow.wasm.sha256" +BUILD_INFO="$ARTIFACTS_DIR/build-info.json" + +[ -f "$WASM_PATH" ] || fail "Artifact not found: $WASM_PATH. Run build-verify.sh first." +[ -f "$HASH_FILE" ] || fail "Hash file not found: $HASH_FILE. Run build-verify.sh first." +[ -f "$BUILD_INFO" ] || fail "Build info not found: $BUILD_INFO. Run build-verify.sh first." + +WASM_HASH=$(awk '{print $1}' "$HASH_FILE") +log "WASM hash: $WASM_HASH" + +# --------------------------------------------------------------------------- +# Tooling validation +# --------------------------------------------------------------------------- +command -v stellar >/dev/null 2>&1 \ + || fail "'stellar' CLI not found. Install: https://github.com/stellar/stellar-cli" + +# --------------------------------------------------------------------------- +# Network validation +# --------------------------------------------------------------------------- +log "Validating network connectivity to $SOROBAN_RPC_URL ..." +curl -sf --max-time 10 "$SOROBAN_RPC_URL" -o /dev/null \ + || fail "Cannot reach RPC endpoint $SOROBAN_RPC_URL. Check connectivity." +log "Network check passed." + +# Derive deployer public key from secret +DEPLOYER_ADDRESS=$(stellar keys address --secret-key "$TESTNET_DEPLOYER_SECRET" 2>/dev/null) \ + || fail "Could not derive public key from TESTNET_DEPLOYER_SECRET. Verify the key is valid." +log "Deployer address: $DEPLOYER_ADDRESS" + +# --------------------------------------------------------------------------- +# Upload (install) WASM +# --------------------------------------------------------------------------- +log "Uploading WASM to Testnet..." +UPLOAD_OUTPUT=$(stellar contract upload \ + --wasm "$WASM_PATH" \ + --source "$TESTNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + 2>&1) || fail "WASM upload failed:\n$UPLOAD_OUTPUT" + +WASM_ID=$(echo "$UPLOAD_OUTPUT" | grep -Eo '[0-9a-fA-F]{64}' | tail -1 || true) +log "WASM ID (on-chain hash): ${WASM_ID:-}" + +# --------------------------------------------------------------------------- +# Deploy contract +# --------------------------------------------------------------------------- +log "Deploying contract instance..." +DEPLOY_OUTPUT=$(stellar contract deploy \ + --wasm-hash "${WASM_ID:-}" \ + --source "$TESTNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + 2>&1) || { + # Fallback: deploy directly from WASM file if wasm-hash deploy isn't available + log "wasm-hash deploy failed, falling back to --wasm ..." + DEPLOY_OUTPUT=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source "$TESTNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + 2>&1) || fail "Contract deploy failed:\n$DEPLOY_OUTPUT" +} + +CONTRACT_ID=$(echo "$DEPLOY_OUTPUT" | grep -Eo 'C[A-Z0-9]{55}' | head -1 || true) +DEPLOY_TX_HASH=$(echo "$DEPLOY_OUTPUT" | grep -Eo '[0-9a-fA-F]{64}' | head -1 || true) + +[ -n "$CONTRACT_ID" ] \ + || fail "Could not parse contract ID from deploy output. Output was:\n$DEPLOY_OUTPUT" + +log "Contract ID: $CONTRACT_ID" +log "Deploy TX : ${DEPLOY_TX_HASH:-}" + +# --------------------------------------------------------------------------- +# Optional: initialize contract +# --------------------------------------------------------------------------- +INIT_RECORD="null" +if [ -n "${INIT_ADMIN:-}" ] && [ -n "${INIT_CLIENT:-}" ] && \ + [ -n "${INIT_FREELANCER:-}" ] && [ -n "${INIT_ARBITER:-}" ] && \ + [ -n "${INIT_TOKEN:-}" ]; then + log "Calling initialize() on deployed contract..." + INIT_OUTPUT=$(stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source "$TESTNET_DEPLOYER_SECRET" \ + --rpc-url "$SOROBAN_RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- initialize \ + --admin "$INIT_ADMIN" \ + --client "$INIT_CLIENT" \ + --freelancer "$INIT_FREELANCER" \ + --arbiter "$INIT_ARBITER" \ + --token "$INIT_TOKEN" \ + --milestones '[]' \ + 2>&1) || fail "initialize() call failed:\n$INIT_OUTPUT" + log "initialize() succeeded." + + INIT_RECORD=$(cat </dev/null || echo "unknown") +GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") +RUST_VERSION=$(rustc --version 2>/dev/null || echo "unknown") +SOROBAN_SDK_VERSION=$(grep -A1 'name = "soroban-sdk"' "$CONTRACTS_ROOT/Cargo.lock" \ + | grep version | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "unknown") + +cat > "$RECORD_FILE" <}" +log " Timestamp : $DEPLOY_TIMESTAMP" +log " Record : $RECORD_FILE" +log "" +log "Verify with: ./scripts/verify-deployment.sh $RECORD_FILE" diff --git a/contracts/scripts/verify-deployment.sh b/contracts/scripts/verify-deployment.sh new file mode 100644 index 0000000..caeea59 --- /dev/null +++ b/contracts/scripts/verify-deployment.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# verify-deployment.sh — Verify a deployed contract against its deployment record. +# +# Usage: +# ./scripts/verify-deployment.sh +# +# What it checks: +# 1. Deployment record is valid JSON and contains required fields. +# 2. WASM artifact hash in the record matches the local artifact (if present). +# 3. Network passphrase matches the expected value for the recorded network. +# 4. Contract ID is reachable on the recorded RPC endpoint. +# +# Exit codes: +# 0 — all checks passed +# 1 — one or more checks failed + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTRACTS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +log() { echo "[verify] $*"; } +pass() { echo "[verify] ✓ $*"; } +warn() { echo "[verify] ⚠ $*"; } +fail() { echo "[verify] ✗ FAIL: $*" >&2; FAILURES=$((FAILURES + 1)); } +FAILURES=0 + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +RECORD_FILE="${1:-}" +[ -n "$RECORD_FILE" ] || { echo "Usage: $0 " >&2; exit 1; } +[ -f "$RECORD_FILE" ] || { echo "File not found: $RECORD_FILE" >&2; exit 1; } + +log "Verifying deployment record: $RECORD_FILE" +echo "" + +# --------------------------------------------------------------------------- +# Parse record (requires python3 or jq) +# --------------------------------------------------------------------------- +parse_json() { + local key="$1" + if command -v jq >/dev/null 2>&1; then + jq -r ".$key // empty" "$RECORD_FILE" + else + python3 -c "import json,sys; d=json.load(open('$RECORD_FILE')); print(d.get('$key',''))" 2>/dev/null + fi +} + +REQUIRED_FIELDS="schema_version network network_passphrase rpc_url contract_id wasm_hash_sha256 deployment_timestamp deployer_address git_commit" + +log "1. Checking required fields in deployment record..." +MISSING_FIELDS=0 +for field in $REQUIRED_FIELDS; do + VAL=$(parse_json "$field") + if [ -z "$VAL" ]; then + fail "Missing required field: $field" + MISSING_FIELDS=$((MISSING_FIELDS + 1)) + fi +done +[ "$MISSING_FIELDS" -eq 0 ] && pass "All required fields present." + +# --------------------------------------------------------------------------- +# Read values +# --------------------------------------------------------------------------- +NETWORK=$(parse_json "network") +RECORDED_PASSPHRASE=$(parse_json "network_passphrase") +RPC_URL=$(parse_json "rpc_url") +CONTRACT_ID=$(parse_json "contract_id") +RECORDED_WASM_HASH=$(parse_json "wasm_hash_sha256") +DEPLOY_TIMESTAMP=$(parse_json "deployment_timestamp") +DEPLOYER=$(parse_json "deployer_address") +GIT_COMMIT=$(parse_json "git_commit") + +# --------------------------------------------------------------------------- +# 2. Network passphrase validation +# --------------------------------------------------------------------------- +log "" +log "2. Validating network passphrase..." +EXPECTED_TESTNET_PASSPHRASE="Test SDF Network ; September 2015" +EXPECTED_MAINNET_PASSPHRASE="Public Global Stellar Network ; September 2015" + +if [ "$NETWORK" = "testnet" ]; then + if [ "$RECORDED_PASSPHRASE" = "$EXPECTED_TESTNET_PASSPHRASE" ]; then + pass "Network passphrase matches Testnet." + else + fail "Testnet passphrase mismatch. Recorded: '$RECORDED_PASSPHRASE'" + fi +elif [ "$NETWORK" = "mainnet" ]; then + if [ "$RECORDED_PASSPHRASE" = "$EXPECTED_MAINNET_PASSPHRASE" ]; then + pass "Network passphrase matches Mainnet." + else + fail "Mainnet passphrase mismatch. Recorded: '$RECORDED_PASSPHRASE'" + fi + # Extra guard: reject mainnet record if RPC URL looks like testnet + if echo "$RPC_URL" | grep -qi "testnet"; then + fail "Mainnet record contains a Testnet RPC URL: $RPC_URL" + fi +else + warn "Unknown network '$NETWORK'. Skipping passphrase cross-check." +fi + +# --------------------------------------------------------------------------- +# 3. Local artifact hash check +# --------------------------------------------------------------------------- +log "" +log "3. Verifying local WASM artifact hash..." +LOCAL_WASM="$CONTRACTS_ROOT/artifacts/escrow.wasm" +if [ -f "$LOCAL_WASM" ]; then + if command -v sha256sum >/dev/null 2>&1; then + LOCAL_HASH=$(sha256sum "$LOCAL_WASM" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + LOCAL_HASH=$(shasum -a 256 "$LOCAL_WASM" | awk '{print $1}') + else + warn "No sha256sum or shasum available. Skipping local artifact check." + LOCAL_HASH="" + fi + + if [ -n "$LOCAL_HASH" ]; then + if [ "$LOCAL_HASH" = "$RECORDED_WASM_HASH" ]; then + pass "Local artifact hash matches recorded hash: $RECORDED_WASM_HASH" + else + fail "Artifact hash MISMATCH." + fail " Recorded : $RECORDED_WASM_HASH" + fail " Local : $LOCAL_HASH" + fi + fi +else + warn "Local artifact not found at $LOCAL_WASM. Skipping local hash check." + warn "To verify: run build-verify.sh with the same git commit ($GIT_COMMIT) and compare hashes." +fi + +# --------------------------------------------------------------------------- +# 4. Contract reachability check +# --------------------------------------------------------------------------- +log "" +log "4. Checking contract reachability on $RPC_URL ..." +if curl -sf --max-time 15 "$RPC_URL" -o /dev/null; then + pass "RPC endpoint is reachable." + # Attempt a lightweight getLedgerEntries or getContractData call + LEDGER_CHECK=$(curl -sf --max-time 15 \ + -X POST "$RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"getLatestLedger","params":{}}' \ + 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('result',{}).get('sequence',''))" 2>/dev/null || echo "") + if [ -n "$LEDGER_CHECK" ]; then + pass "RPC responsive. Latest ledger sequence: $LEDGER_CHECK" + else + warn "RPC reachable but getLatestLedger did not return a sequence. Check manually." + fi +else + warn "Cannot reach RPC endpoint $RPC_URL. Skipping reachability check." +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "===== VERIFICATION SUMMARY =====" +echo " Record : $RECORD_FILE" +echo " Network : $NETWORK" +echo " Contract ID : $CONTRACT_ID" +echo " WASM hash : $RECORDED_WASM_HASH" +echo " Deployer : $DEPLOYER" +echo " Git commit : $GIT_COMMIT" +echo " Deployed at : $DEPLOY_TIMESTAMP" +echo "" + +if [ "$FAILURES" -eq 0 ]; then + echo " Result: ALL CHECKS PASSED" + exit 0 +else + echo " Result: $FAILURES CHECK(S) FAILED — see errors above." + exit 1 +fi diff --git a/docs/escrow-deployment-guide.md b/docs/escrow-deployment-guide.md new file mode 100644 index 0000000..9007fc4 --- /dev/null +++ b/docs/escrow-deployment-guide.md @@ -0,0 +1,354 @@ +# Escrow Contract — Reproducible Deployment Guide + +This document covers building, deploying, verifying, and redeploying the `escrow` Soroban +contract on Stellar Testnet and Mainnet. All procedures are designed for deterministic +builds, full artifact traceability, and independent verification. + +--- + +## Table of Contents + +1. [Environment Setup](#1-environment-setup) +2. [Pinned Toolchain](#2-pinned-toolchain) +3. [Building the Contract](#3-building-the-contract) +4. [Deploying to Testnet](#4-deploying-to-testnet) +5. [Deploying to Mainnet](#5-deploying-to-mainnet) +6. [Verifying a Deployment](#6-verifying-a-deployment) +7. [Deployment Records](#7-deployment-records) +8. [CI Pipeline](#8-ci-pipeline) +9. [Redeployment](#9-redeployment) +10. [Security Considerations](#10-security-considerations) +11. [Independent Verification](#11-independent-verification) + +--- + +## 1. Environment Setup + +### Required tools + +| Tool | Minimum version | Install | +|------|----------------|---------| +| Rust | 1.82.0 (pinned) | https://rustup.rs | +| wasm32 target | — | `rustup target add wasm32-unknown-unknown` | +| stellar CLI | latest | https://github.com/stellar/stellar-cli | +| curl | any | system package manager | +| sha256sum / shasum | any | system package manager | +| jq or python3 | any | used by verify script | + +### Verify your setup + +```bash +rustc --version # should match contracts/rust-toolchain.toml +cargo --version +rustup target list --installed | grep wasm32 +stellar --version +``` + +--- + +## 2. Pinned Toolchain + +The Rust toolchain is pinned in `contracts/rust-toolchain.toml`: + +```toml +[toolchain] +channel = "1.82.0" +targets = ["wasm32-unknown-unknown"] +components = ["rustfmt", "clippy"] +``` + +Cargo will automatically select this version when run from the `contracts/` directory. +The Soroban SDK version is pinned in `contracts/Cargo.toml` (`soroban-sdk = "25"`) and +locked to `25.3.1` in `contracts/Cargo.lock`. **Never update Cargo.lock without a +corresponding review and re-verification of the artifact hash.** + +--- + +## 3. Building the Contract + +Run the build-and-verify script from the `contracts/` directory: + +```bash +cd contracts +bash scripts/build-verify.sh +``` + +This script: +- Validates the Rust toolchain version +- Builds the WASM artifact with `--locked` (exact dependency versions from Cargo.lock) +- Stages the artifact to `contracts/artifacts/escrow.wasm` +- Computes and records the SHA-256 hash in `contracts/artifacts/escrow.wasm.sha256` +- Writes a build provenance JSON to `contracts/artifacts/build-info.json` + +### Build outputs + +| File | Description | +|------|-------------| +| `artifacts/escrow.wasm` | Compiled WASM binary | +| `artifacts/escrow.wasm.sha256` | SHA-256 hash of the binary | +| `artifacts/build-info.json` | Full build provenance record | + +The WASM binary is not committed to source control. The `build-info.json` and deployment +records are committed. + +--- + +## 4. Deploying to Testnet + +### Prerequisites + +- Build completed (or set `SKIP_BUILD=1` if already built) +- `TESTNET_DEPLOYER_SECRET` exported in environment + +```bash +export TESTNET_DEPLOYER_SECRET="S..." # never commit this value + +cd contracts +bash scripts/deploy-testnet.sh +``` + +### Optional initialization parameters + +If you want `initialize()` called immediately after deployment: + +```bash +export INIT_ADMIN="G..." +export INIT_CLIENT="G..." +export INIT_FREELANCER="G..." +export INIT_ARBITER="G..." +export INIT_TOKEN="C..." # token contract address + +bash scripts/deploy-testnet.sh +``` + +### What the script records + +A file `contracts/deployments/testnet-.json` is created containing: + +```json +{ + "schema_version": "1", + "network": "testnet", + "network_passphrase": "Test SDF Network ; September 2015", + "rpc_url": "https://soroban-testnet.stellar.org", + "contract_id": "C...", + "wasm_hash_sha256": "", + "wasm_id_on_chain": "", + "deploy_tx_hash": "", + "deployer_address": "G...", + "deployment_timestamp": "2026-10-17T12:00:00Z", + "git_commit": "", + "git_tag": "", + "rust_version": "rustc 1.82.0 ...", + "soroban_sdk_version": "25.3.1", + "initialization_params": null +} +``` + +Commit this file to preserve the immutable deployment audit trail. + +--- + +## 5. Deploying to Mainnet + +Mainnet deployment requires an **explicit manual approval** step. + +### Required environment variables + +| Variable | Description | +|----------|-------------| +| `MAINNET_DEPLOYER_SECRET` | Stellar secret key — must differ from Testnet key | +| `MAINNET_DEPLOY_CONFIRMED` | Set to `yes` in CI after human approval gate passes | + +Mainnet credentials must be stored separately from Testnet credentials and must never +share the same secret key. + +```bash +export MAINNET_DEPLOYER_SECRET="S..." # isolated from Testnet secret + +cd contracts +bash scripts/deploy-mainnet.sh +``` + +The script will prompt: + +``` + Type exactly 'CONFIRM MAINNET DEPLOY' to proceed: +``` + +Type that phrase exactly to proceed. In CI, the `mainnet-approval` environment gate +(which requires a repository maintainer to approve) handles this; the script receives +`MAINNET_DEPLOY_CONFIRMED=yes` automatically. + +A deployment record `contracts/deployments/mainnet-.json` is saved with the +same schema as the Testnet record. + +--- + +## 6. Verifying a Deployment + +```bash +cd contracts +bash scripts/verify-deployment.sh deployments/testnet-20261017T120000Z.json +``` + +The script checks: + +1. All required fields are present in the record +2. The network passphrase matches the expected value for the recorded network +3. Mainnet records do not reference a Testnet RPC endpoint +4. The local artifact hash (if present) matches the recorded hash +5. The RPC endpoint is reachable and responsive + +Exit code `0` means all checks passed. Exit code `1` means at least one check failed. + +### Independent verification without the local WASM + +If you do not have the compiled artifact locally, you can reproduce it independently: + +```bash +git checkout +cd contracts +bash scripts/build-verify.sh +# Compare the printed hash with the recorded wasm_hash_sha256 +``` + +Any authorized contributor with the same toolchain will produce the same hash from the +same commit, verifying the deployed contract matches the reviewed source code. + +--- + +## 7. Deployment Records + +Deployment records live in `contracts/deployments/`. Files are named: + +``` +testnet-.json +mainnet-.json +``` + +Each record is written once and treated as immutable. Redeployments create new records; +historical records are never modified or deleted. Commit all records to source control. + +### Verification fields captured per deployment + +| Field | Purpose | +|-------|---------| +| `git_commit` | Ties the artifact to exact reviewed source code | +| `git_tag` | Human-readable version reference | +| `rust_version` | Reproduces the build environment | +| `soroban_sdk_version` | Pinned SDK version used | +| `wasm_hash_sha256` | Independently verifiable artifact fingerprint | +| `wasm_id_on_chain` | On-chain WASM blob identifier | +| `network` + `network_passphrase` | Prevents network confusion | +| `contract_id` | Deployed contract address | +| `deploy_tx_hash` | On-chain proof of deployment transaction | +| `deployer_address` | Accountable deployer identity | +| `deployment_timestamp` | Audit timeline | +| `initialization_params` | Records parameters passed to `initialize()` | + +--- + +## 8. CI Pipeline + +The `.github/workflows/contracts-ci.yml` workflow runs on every push and PR that +touches `contracts/`: + +### Jobs + +| Job | Trigger | Description | +|-----|---------|-------------| +| `build` | always | Builds WASM with pinned toolchain, computes hash, uploads artifact | +| `test` | always | Runs `cargo test --workspace --locked` | +| `validate-config` | `workflow_dispatch` with deploy target | Validates required secrets | +| `deploy-testnet` | `workflow_dispatch` → `testnet` | Deploys to Testnet after build+test | +| `mainnet-approval` | `workflow_dispatch` → `mainnet` | Requires human approval via GitHub environment | +| `deploy-mainnet` | after `mainnet-approval` | Deploys to Mainnet | + +### CI deployment gates + +CI blocks deployment when: +- Contract tests fail (`cargo test` reports `FAILED`) +- Build fails or WASM artifact is not produced +- Artifact hash does not match between build and deploy jobs +- Required secrets are missing +- Network configuration is invalid (Mainnet passphrase on Testnet or vice versa) +- Testnet and Mainnet secrets are identical + +### GitHub environment configuration + +Create two GitHub environments in the repository settings: + +- **`testnet`** — no required reviewers; Testnet secrets only +- **`mainnet-approval`** — required reviewers (at least one maintainer) +- **`mainnet`** — Mainnet secrets only; protected branch rules enforced + +Store secrets in the appropriate environment, never at repository level: + +| Environment | Secret | +|-------------|--------| +| `testnet` | `TESTNET_DEPLOYER_SECRET` | +| `mainnet` | `MAINNET_DEPLOYER_SECRET` | + +--- + +## 9. Redeployment + +Redeployments follow the same process as initial deployments. Each run generates a +new timestamped deployment record. Historical records are preserved. + +Before redeploying, verify the most recent deployment record is committed: + +```bash +git log --oneline contracts/deployments/ +``` + +Then run the deploy script as normal. The new record will reference the new contract ID +and transaction hash while the old records remain unchanged. + +--- + +## 10. Security Considerations + +- **Secrets**: `TESTNET_DEPLOYER_SECRET` and `MAINNET_DEPLOYER_SECRET` must never be + committed to source control or logged. Use GitHub encrypted secrets exclusively. +- **Credential isolation**: Testnet and Mainnet must use different key pairs. The + deployment scripts enforce this check. +- **Immutable records**: Deployment JSON records must not be edited after creation. + Treat them as append-only audit logs. +- **Source-to-artifact traceability**: Every deployment record includes the `git_commit` + hash, so any deployment can be traced back to exact reviewed source code. +- **Network guard**: The Mainnet deploy script rejects execution if the RPC URL contains + "testnet", and the verification script cross-checks the passphrase against the + expected value for each network. +- **Manual gate**: Mainnet deployments always require an explicit human approval in CI + (via the `mainnet-approval` GitHub environment) or interactive confirmation when + running locally. + +--- + +## 11. Independent Verification + +Any authorized contributor can independently verify that a deployed contract matches +the reviewed source code: + +```bash +# 1. Check out the exact commit from the deployment record +git checkout + +# 2. Confirm toolchain matches +cat contracts/rust-toolchain.toml + +# 3. Build from source +cd contracts +bash scripts/build-verify.sh + +# 4. Compare the printed hash with the recorded wasm_hash_sha256 +cat artifacts/build-info.json | grep wasm_hash_sha256 +``` + +If the hashes match, the deployed contract is identical to the artifact produced from +the reviewed source at that commit. + +To verify the on-chain contract ID against the WASM hash, use the Stellar Explorer +or the `stellar contract info` command with the contract ID from the deployment record.