diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..6c5c297 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,100 @@ +# GitHub Actions CI/CD Guide + +This repository uses a multi-workflow GitHub Actions setup so maintainers can +review failures by area instead of debugging one giant pipeline. + +## Workflows + +### `circuits.yml` +- installs the Noir toolchain +- compiles all Noir workspace packages +- runs circuit tests +- uploads a simple constraint report artifact + +### `contracts.yml` +- installs stable Rust and the `wasm32-unknown-unknown` target +- builds the Soroban workspace +- runs contract tests +- generates a coverage report artifact with `cargo-tarpaulin` + +### `sdk.yml` +- prepares Node.js CI for the future SDK package +- skips cleanly when `sdk/package.json` does not exist yet +- runs lint, typecheck, and tests once the SDK lands + +### `benchmark.yml` +- runs a lightweight contract benchmark snapshot +- uploads benchmark artifacts +- comments the benchmark summary on pull requests + +### `quality.yml` +- runs Rust formatting, clippy, and `cargo audit` +- runs Node lint/format/security checks when the SDK package exists + +### `docs.yml` +- verifies required Markdown files exist +- checks internal Markdown links +- uploads a docs manifest artifact + +## Trigger Model + +All workflows run on: +- pull requests +- pushes to `main` + +That keeps the signal aligned with contributor work and post-merge regression +checks. + +## Caching Strategy + +The workflows cache: +- cargo registry and build artifacts +- Noir artifacts and package cache +- npm dependencies once the SDK package exists + +Cache keys are derived from lock/config/source files so stale artifacts are less +likely to bleed across incompatible changes. + +## Benchmarks + +The benchmark workflow intentionally uses a repository-local shell script: +`scripts/ci/benchmark_contracts.sh`. + +Right now it captures a reproducible build/test snapshot rather than a full gas +delta engine. That gives maintainers a baseline immediately and leaves room for +future benchmark specialization once more benchmarking code exists in the repo. + +## Branch Protection + +Branch protection cannot be enabled from a pull request unless the actor has +repository admin access. Maintainers should enable the following protections on +`main`: + +1. require a pull request before merging +2. require at least one approving review +3. require branches to be up to date before merging +4. require status checks to pass + +Recommended required checks: +- `Circuits / noir-circuits` +- `Contracts / rust-contracts` +- `SDK / sdk-checks` +- `Benchmarks / contract-benchmarks` +- `Code Quality / rust-quality` +- `Code Quality / node-quality` +- `Documentation / docs` + +## Secrets + +Current workflows do not require repository secrets. If future deployment steps +or external reporting are added, secrets should be stored in GitHub Actions +repository settings and referenced only through environment variables. + +## Maintenance Notes + +- If the SDK folder is added later, update `sdk.yml` and `quality.yml` to pin + the package manager and concrete scripts. +- If benchmark thresholds become strict, move the threshold logic into + `scripts/ci/benchmark_contracts.sh` so the rule lives close to the data. +- If GitHub Pages documentation is added later, extend `docs.yml` with a deploy + job guarded behind `push` to `main`. diff --git a/.github/benchmarks/contracts-wasm-size-baseline.txt b/.github/benchmarks/contracts-wasm-size-baseline.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.github/benchmarks/contracts-wasm-size-baseline.txt @@ -0,0 +1 @@ +0 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..defaee4 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,62 @@ +name: Benchmarks + +on: + pull_request: + push: + branches: + - main + +jobs: + contract-benchmarks: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + contracts/target/ + key: ${{ runner.os }}-bench-cargo-${{ hashFiles('contracts/Cargo.toml', 'contracts/privacy_pool/Cargo.toml', 'contracts/**/*.rs', 'scripts/ci/benchmark_contracts.sh') }} + restore-keys: | + ${{ runner.os }}-bench-cargo- + + - name: Run benchmark snapshot + run: bash scripts/ci/benchmark_contracts.sh + + - name: Upload benchmark snapshot + uses: actions/upload-artifact@v4 + with: + name: benchmark-report + path: artifacts/benchmarks + + - name: Comment benchmark summary on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = 'artifacts/benchmarks/summary.md'; + if (!fs.existsSync(path)) { + core.info('No benchmark summary found.'); + return; + } + const body = fs.readFileSync(path, 'utf8'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); diff --git a/.github/workflows/circuits.yml b/.github/workflows/circuits.yml new file mode 100644 index 0000000..d711c58 --- /dev/null +++ b/.github/workflows/circuits.yml @@ -0,0 +1,72 @@ +name: Circuits + +on: + pull_request: + push: + branches: + - main + +jobs: + noir-circuits: + runs-on: ubuntu-latest + timeout-minutes: 20 + + defaults: + run: + shell: bash + working-directory: circuits + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Noir toolchain + run: | + curl -L https://raw.githubusercontent.com/noir-lang/noirup/refs/heads/main/install | bash + echo "$HOME/.nargo/bin" >> "$GITHUB_PATH" + "$HOME/.nargo/bin/noirup" + + - name: Cache Noir artifacts + uses: actions/cache@v4 + with: + path: | + ~/.nargo + circuits/target + circuits/**/target + key: ${{ runner.os }}-noir-${{ hashFiles('circuits/Nargo.toml', 'circuits/**/Nargo.toml', 'circuits/**/*.nr') }} + restore-keys: | + ${{ runner.os }}-noir- + + - name: Check Noir toolchain + run: | + noirup --version || true + nargo --version + + - name: Compile all circuits + run: nargo compile --workspace + + - name: Run circuit tests + run: | + for circuit in commitment merkle withdraw; do + echo "Running tests for ${circuit}" + nargo test --package "${circuit}" + done + + - name: Report constraint counts + run: | + mkdir -p ../artifacts + { + echo "# Noir Constraint Report" + echo + for circuit in commitment merkle withdraw; do + echo "## ${circuit}" + nargo info --package "${circuit}" || echo "nargo info not available for ${circuit}" + echo + done + } | tee ../artifacts/circuit-constraints.md + + - name: Upload circuit artifacts + uses: actions/upload-artifact@v4 + with: + name: circuit-constraints + path: artifacts/circuit-constraints.md diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..e0a0547 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,60 @@ +name: Contracts + +on: + pull_request: + push: + branches: + - main + +jobs: + rust-contracts: + runs-on: ubuntu-latest + timeout-minutes: 30 + + defaults: + run: + shell: bash + working-directory: contracts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + contracts/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('contracts/Cargo.toml', 'contracts/privacy_pool/Cargo.toml', 'contracts/**/*.rs') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Fetch dependencies + run: cargo fetch + + - name: Build contract workspace + run: cargo build --workspace --target wasm32-unknown-unknown + + - name: Run unit and integration tests + run: cargo test --package privacy_pool + + - name: Generate coverage report + run: | + cargo install cargo-tarpaulin --locked + cargo tarpaulin --package privacy_pool --out Xml --output-dir ../artifacts/coverage + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: contracts-coverage + path: artifacts/coverage diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..48cf1af --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,41 @@ +name: Documentation + +on: + pull_request: + push: + branches: + - main + +jobs: + docs: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Verify required docs exist + run: | + test -f README.md + test -f CONTRIBUTING.md + test -f contracts/privacy_pool/ARCHITECTURE.md + test -f .github/README.md + + - name: Validate Markdown links + run: bash scripts/ci/check_markdown_links.sh + + - name: Upload docs manifest + run: | + mkdir -p artifacts/docs + { + echo "# Documentation Manifest" + echo + find . -maxdepth 3 -name '*.md' | sort + } > artifacts/docs/manifest.md + + - name: Upload docs artifact + uses: actions/upload-artifact@v4 + with: + name: docs-manifest + path: artifacts/docs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..35c7a34 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,108 @@ +name: Code Quality + +on: + pull_request: + push: + branches: + - main + +jobs: + rust-quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + + defaults: + run: + shell: bash + working-directory: contracts + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + contracts/target/ + key: ${{ runner.os }}-quality-cargo-${{ hashFiles('contracts/Cargo.toml', 'contracts/privacy_pool/Cargo.toml', 'contracts/**/*.rs') }} + restore-keys: | + ${{ runner.os }}-quality-cargo- + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run cargo audit + run: | + cargo install cargo-audit --locked + cargo audit + + node-quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Detect SDK package + id: sdk + run: | + if [[ -f sdk/package.json ]]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + - name: Cache SDK dependencies + if: steps.sdk.outputs.present == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.npm + sdk/node_modules + key: ${{ runner.os }}-quality-sdk-npm-${{ hashFiles('sdk/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-quality-sdk-npm- + + - name: Skip placeholder quality checks + if: steps.sdk.outputs.present != 'true' + run: echo "SDK package is not present yet. Skipping ESLint/Prettier/security checks." + + - name: Install SDK dependencies + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm ci + + - name: Run ESLint + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm run lint --if-present + + - name: Run Prettier check + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm run format:check --if-present + + - name: Run npm audit + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm audit --audit-level=high diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml new file mode 100644 index 0000000..568e580 --- /dev/null +++ b/.github/workflows/sdk.yml @@ -0,0 +1,65 @@ +name: SDK + +on: + pull_request: + push: + branches: + - main + +jobs: + sdk-checks: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Detect SDK package + id: sdk + run: | + if [[ -f sdk/package.json ]]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + - name: Cache SDK dependencies + if: steps.sdk.outputs.present == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.npm + sdk/node_modules + key: ${{ runner.os }}-sdk-npm-${{ hashFiles('sdk/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-sdk-npm- + + - name: Skip placeholder SDK workflow + if: steps.sdk.outputs.present != 'true' + run: echo "SDK package is not present yet. Skipping SDK CI until sdk/package.json exists." + + - name: Install dependencies + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm ci + + - name: Run linter + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm run lint --if-present + + - name: Run type checker + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm run typecheck --if-present + + - name: Run unit tests + if: steps.sdk.outputs.present == 'true' + working-directory: sdk + run: npm test -- --runInBand diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5aff478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Abraham Anavheoba + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..a562dc4 --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,16 @@ +# Threat Model Placeholder + +This file exists so repository documentation and CI link checks have a stable +target. + +The comprehensive threat model is tracked as open bounty issue `#19` and should +expand this placeholder into a full document covering: + +- assets and trust boundaries +- attacker classes +- privacy-specific failure modes +- contract and circuit attack surfaces +- mitigations and residual risk + +Until that bounty is completed, treat this file as a documentation stub rather +than a finished security artifact. diff --git a/scripts/ci/benchmark_contracts.sh b/scripts/ci/benchmark_contracts.sh new file mode 100755 index 0000000..c8fd170 --- /dev/null +++ b/scripts/ci/benchmark_contracts.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ARTIFACT_DIR="$ROOT_DIR/artifacts/benchmarks" +BASELINE_FILE="$ROOT_DIR/.github/benchmarks/contracts-wasm-size-baseline.txt" + +mkdir -p "$ARTIFACT_DIR" + +pushd "$ROOT_DIR/contracts" >/dev/null +cargo build --workspace --release --target wasm32-unknown-unknown + +wasm_path="$(find "$ROOT_DIR/contracts/target/wasm32-unknown-unknown/release" -name '*.wasm' | head -n 1)" +if [[ -z "${wasm_path:-}" ]]; then + echo "No wasm artifact found after release build." >&2 + exit 1 +fi + +current_size="$(wc -c < "$wasm_path" | tr -d ' ')" +baseline_size="" +status="no-baseline" + +if [[ -f "$BASELINE_FILE" ]]; then + baseline_size="$(tr -d ' \n\r' < "$BASELINE_FILE")" + if [[ -n "$baseline_size" && "$baseline_size" -gt 0 ]]; then + threshold="$(python3 - </dev/null + +{ + echo "# Contract Benchmark Snapshot" + echo + echo "- generated_at: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "- git_sha: ${GITHUB_SHA:-$(git -C "$ROOT_DIR" rev-parse HEAD)}" + echo "- wasm_artifact: ${wasm_path#$ROOT_DIR/}" + echo "- wasm_size_bytes: ${current_size}" + if [[ -n "$baseline_size" ]]; then + echo "- baseline_wasm_size_bytes: ${baseline_size}" + echo "- allowed_max_wasm_size_bytes: ${threshold}" + fi + echo "- status: ${status}" + echo + echo "## Workspace metadata" + echo + (cd "$ROOT_DIR/contracts" && cargo metadata --no-deps --format-version 1 | jq '{packages: [.packages[].name], workspace_members: .workspace_members}') + echo + echo "## Build + benchmark command set" + echo + echo '```bash' + echo 'cargo build --workspace --release --target wasm32-unknown-unknown' + echo 'cargo test --package privacy_pool' + echo '```' +} | tee "$ARTIFACT_DIR/summary.md" + +if [[ "$status" == "regression" ]]; then + echo "Benchmark regression detected: wasm artifact grew by more than 10% over baseline." >&2 + exit 1 +fi diff --git a/scripts/ci/check_markdown_links.sh b/scripts/ci/check_markdown_links.sh new file mode 100755 index 0000000..ae98933 --- /dev/null +++ b/scripts/ci/check_markdown_links.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +while IFS= read -r file; do + while IFS= read -r link; do + target="${link#*:}" + [[ -z "$target" ]] && continue + [[ "$target" =~ ^https?:// ]] && continue + [[ "$target" =~ ^# ]] && continue + + clean_target="${target%%#*}" + source_dir="$(dirname "$file")" + resolved_path="$source_dir/$clean_target" + + if [[ ! -e "$resolved_path" ]]; then + echo "Broken Markdown link in $file -> $target" >&2 + exit 1 + fi + done < <(perl -ne 'while (/\[[^\]]+\]\(([^)]+)\)/g) { print "$ARGV:$1\n" }' "$file") +done < <(find "$ROOT_DIR" -name '*.md' -print) + +echo "Markdown link check passed."