diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 6674ba3b..399a395b 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -52,13 +52,13 @@ jobs: stellar contract build - name: Run contract test suite (flat weight fixture) - run: ACCORD_WEIGHT_FIXTURE=flat cargo test -- --nocapture + run: ACCORD_WEIGHT_FIXTURE=flat cargo test --locked -- --nocapture - name: Run contract test suite (skewed weight fixture) - run: ACCORD_WEIGHT_FIXTURE=skewed cargo test -- --nocapture + run: ACCORD_WEIGHT_FIXTURE=skewed cargo test --locked -- --nocapture - name: Run RBAC tests - run: cargo test -- rbac -- --nocapture + run: cargo test --locked -- rbac -- --nocapture - name: Generate wasm-manifest.json run: | @@ -210,7 +210,7 @@ jobs: - name: Measure contract coverage run: | - cargo llvm-cov -p accord --lib --no-report --ignore-run-fail \ + cargo llvm-cov --locked -p accord --lib --no-report --ignore-run-fail \ --ignore-filename-regex '(test|bench)\.rs$' - name: Write coverage reports @@ -262,7 +262,7 @@ jobs: cache: true - name: Run resource benchmarks - run: cargo test -p accord --lib bench_hot_entrypoints -- --nocapture + run: cargo test --locked -p accord --lib bench_hot_entrypoints -- --nocapture - name: Report benchmark deltas if: always() diff --git a/.github/workflows/indexer-analytics.yml b/.github/workflows/indexer-analytics.yml index d6dc2866..acae8276 100644 --- a/.github/workflows/indexer-analytics.yml +++ b/.github/workflows/indexer-analytics.yml @@ -136,12 +136,12 @@ jobs: run: cargo fmt -p accord-analytics-api -- --check - name: Run Clippy - run: cargo clippy -p accord-analytics-api --all-targets -- -D warnings + run: cargo clippy --locked -p accord-analytics-api --all-targets -- -D warnings - name: Run API tests env: TEST_DATABASE_URL: postgres://accord:accord@localhost:5432/accord_analytics_test - run: cargo test -p accord-analytics-api + run: cargo test --locked -p accord-analytics-api - name: Verify the OpenAPI spec is valid JSON run: node -e "const s = JSON.parse(require('fs').readFileSync('docs/analytics-api.openapi.json', 'utf8')); if (s.openapi !== '3.0.3' || !Object.keys(s.paths).length) process.exit(1)" @@ -151,4 +151,4 @@ jobs: # spec is reported by name rather than buried in the test output. env: TEST_DATABASE_URL: postgres://accord:accord@localhost:5432/accord_analytics_test - run: cargo test -p accord-analytics-api openapi + run: cargo test --locked -p accord-analytics-api openapi diff --git a/.github/workflows/launch-checklist.yml b/.github/workflows/launch-checklist.yml new file mode 100644 index 00000000..5389604b --- /dev/null +++ b/.github/workflows/launch-checklist.yml @@ -0,0 +1,22 @@ +name: Launch checklist + +on: + pull_request: + paths: + - launch/** + - scripts/check-mainnet-checklist.mjs + - .github/workflows/launch-checklist.yml + +permissions: + contents: read + +jobs: + structure: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + - run: node scripts/check-mainnet-checklist.mjs --structure diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index fac6592e..7d12abc7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -37,13 +37,13 @@ jobs: target: wasm32v1-none - name: Run contract test suite (flat weight fixture) - run: ACCORD_WEIGHT_FIXTURE=flat cargo test -p accord -- --nocapture + run: ACCORD_WEIGHT_FIXTURE=flat cargo test --locked -p accord -- --nocapture - name: Run contract test suite (skewed weight fixture) - run: ACCORD_WEIGHT_FIXTURE=skewed cargo test -p accord -- --nocapture + run: ACCORD_WEIGHT_FIXTURE=skewed cargo test --locked -p accord -- --nocapture - name: Run RBAC tests - run: cargo test -p accord -- rbac -- --nocapture + run: cargo test --locked -p accord -- rbac -- --nocapture frontend: name: "Frontend: unit tests & coverage" @@ -151,12 +151,12 @@ jobs: run: cargo fmt -p accord-analytics-api -- --check - name: Run Clippy - run: cargo clippy -p accord-analytics-api --all-targets -- -D warnings + run: cargo clippy --locked -p accord-analytics-api --all-targets -- -D warnings - name: Run API tests env: TEST_DATABASE_URL: postgres://accord:accord@localhost:5432/accord_analytics_test - run: cargo test -p accord-analytics-api + run: cargo test --locked -p accord-analytics-api e2e: name: "Frontend: E2E (Playwright)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03b5c16b..6d68b399 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,13 +46,13 @@ jobs: target: wasm32v1-none - name: Run contract test suite (flat weight fixture) - run: ACCORD_WEIGHT_FIXTURE=flat cargo test -p accord -- --nocapture + run: ACCORD_WEIGHT_FIXTURE=flat cargo test --locked -p accord -- --nocapture - name: Run contract test suite (skewed weight fixture) - run: ACCORD_WEIGHT_FIXTURE=skewed cargo test -p accord -- --nocapture + run: ACCORD_WEIGHT_FIXTURE=skewed cargo test --locked -p accord -- --nocapture - name: Run RBAC tests - run: cargo test -p accord -- rbac -- --nocapture + run: cargo test --locked -p accord -- rbac -- --nocapture frontend-tests: name: "Gate: frontend tests" @@ -122,9 +122,31 @@ jobs: # --------------------------------------------------------------------------- # Build the contract artifact + manifest and publish the GitHub Release. # --------------------------------------------------------------------------- + mainnet-checklist: + name: "Gate: mainnet launch checklist" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: "22" + + # Tags with a pre-release suffix (v1.0.0-rc.1) only get a structure check; + # any other v* tag is a mainnet release and needs a complete, signed-off checklist. + - name: Enforce launch checklist + run: | + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + node scripts/check-mainnet-checklist.mjs --structure + else + node scripts/check-mainnet-checklist.mjs --release "$GITHUB_REF_NAME" + fi + release: name: Build artifact & publish release - needs: [contract-tests, frontend-tests, indexer-tests] + needs: [contract-tests, frontend-tests, indexer-tests, mainnet-checklist] runs-on: ubuntu-latest steps: - name: Checkout repository @@ -218,7 +240,7 @@ jobs: # --------------------------------------------------------------------------- publish-sdk: name: Publish SDK to GitHub Packages - needs: [contract-tests, frontend-tests, indexer-tests] + needs: [contract-tests, frontend-tests, indexer-tests, mainnet-checklist] runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b531bd75..c05e31cb 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -31,9 +31,13 @@ jobs: - name: Install gitleaks run: | - curl -sSfL \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" gitleaks + base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}" + archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSfL -o "$RUNNER_TEMP/$archive" "$base/$archive" + curl -sSfL -o "$RUNNER_TEMP/checksums.txt" "$base/gitleaks_${GITLEAKS_VERSION}_checksums.txt" + # Provenance: refuse a binary that does not match the published checksum. + (cd "$RUNNER_TEMP" && grep " $archive\$" checksums.txt | sha256sum -c -) + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" gitleaks echo "$RUNNER_TEMP" >> "$GITHUB_PATH" "$RUNNER_TEMP/gitleaks" version diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 00000000..04a73497 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,101 @@ +name: Supply-chain policy + +# Enforces docs/SUPPLY_CHAIN_POLICY.md. pull_request_review re-runs only the +# exception-review gate when a review lands. +on: + pull_request: + pull_request_review: + types: [submitted, dismissed] + push: + branches: [main] + +permissions: + contents: read + +jobs: + policy: + name: Pinning policy + if: github.event_name != 'pull_request_review' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + - name: Check lockfiles, sources, action pins and exceptions + run: node scripts/check-supply-chain.mjs + + npm-provenance: + name: npm provenance (${{ matrix.dir }}) + if: github.event_name != 'pull_request_review' + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + dir: [frontend, indexer] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + - name: Install from the lockfile without running install scripts + working-directory: ${{ matrix.dir }} + run: npm ci --ignore-scripts + - name: Verify registry signatures and provenance attestations + working-directory: ${{ matrix.dir }} + run: npm audit signatures + + cargo-sources: + name: Cargo sources and lockfile + if: github.event_name != 'pull_request_review' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions-rust-lang/setup-rust-toolchain@v2 + - name: Lockfile is complete and up to date + run: cargo metadata --locked --format-version 1 > /dev/null + - name: Install cargo-deny + run: cargo install cargo-deny --locked + - name: Only crates.io is an allowed source + run: cargo deny check sources + + exception-review: + name: Exception changes need an approving review + if: github.event_name != 'push' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + const watched = ['supply-chain/exceptions.json', 'scripts/check-supply-chain.mjs']; + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, pull_number: pr.number, per_page: 100, + }); + const touched = files.map((f) => f.filename).filter((f) => watched.includes(f)); + if (touched.length === 0) { + core.info('No supply-chain exception or policy changes.'); + return; + } + const reviews = await github.paginate(github.rest.pulls.listReviews, { + ...context.repo, pull_number: pr.number, per_page: 100, + }); + // Latest decisive review per reviewer wins; the author cannot approve their own change. + const latest = new Map(); + for (const r of reviews) { + if (r.user.login !== pr.user.login && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state)) { + latest.set(r.user.login, r.state); + } + } + const approved = [...latest.values()].includes('APPROVED'); + const blocked = [...latest.values()].includes('CHANGES_REQUESTED'); + if (!approved || blocked) { + core.setFailed(`${touched.join(', ')} changed: needs an approving review from someone other than the author.`); + } diff --git a/Cargo.lock b/Cargo.lock index 4e2c8de2..44207841 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,9 @@ dependencies = [ "accord", "libfuzzer-sys", "soroban-sdk", +] + +[[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/contracts/accord/src/invariants.rs b/contracts/accord/src/invariants.rs new file mode 100644 index 00000000..bf415497 --- /dev/null +++ b/contracts/accord/src/invariants.rs @@ -0,0 +1,225 @@ +#![cfg(test)] +#![allow(dead_code)] + +//! Randomized-operation property test for the invariants in `docs/INVARIANTS.md`. +//! After every step all state invariants are re-asserted; messages carry the +//! invariant ID so a failure points straight at the doc. + +extern crate std; + +use super::*; +use proptest::prelude::*; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{token, Address, Env, String, Vec}; +use std::collections::BTreeMap; +use std::vec::Vec as StdVec; + +const NOW: u64 = 1_000; +const POOL: usize = 8; +const MINTED: i128 = 1_000_000_000_000; +const TIMELOCK: u64 = 60; + +fn set_timestamp(env: &Env, ts: u64) { + let mut ledger = env.ledger().get(); + ledger.timestamp = ts; + env.ledger().set(ledger); +} + +/// Per-proposal facts the harness tracks independently of contract storage. +struct Tracked { + quorum: u32, + paid: Option, + recorded: BTreeMap, +} + +struct Harness { + env: Env, + client: AccordContractClient<'static>, + token: Address, + treasury: Address, + pool: StdVec
, + recipients: StdVec
, + proposals: StdVec, +} + +impl Harness { + fn new(owner_count: usize, weight: u32, threshold_seed: u32) -> Self { + let env = Env::new_with_config(soroban_sdk::testutils::EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env.mock_all_auths(); + set_timestamp(&env, NOW); + + let treasury = env.register(AccordContract, ()); + let client = AccordContractClient::new(&env, &treasury); + let token = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + token::StellarAssetClient::new(&env, &token).mint(&treasury, &MINTED); + + let pool: StdVec
= (0..POOL).map(|_| Address::generate(&env)).collect(); + let mut owners = Vec::new(&env); + let mut weights = Vec::new(&env); + for owner in pool.iter().take(owner_count) { + owners.push_back(owner.clone()); + weights.push_back(weight); + } + let threshold = threshold_seed % (weight * owner_count as u32) + 1; + client.initialize(&owners, &weights, &threshold, &TIMELOCK); + + Harness { env, client, token, treasury, pool, recipients: StdVec::new(), proposals: StdVec::new() } + } + + fn balance(&self, who: &Address) -> i128 { + token::Client::new(&self.env, &self.token).balance(who) + } + + fn create(&mut self, actor: usize, target: usize, value: u32) { + let proposer = self.pool[actor].clone(); + let subject = self.pool[target].clone(); + let deadline = self.env.ledger().timestamp() + 86_400; + let text = String::from_str(&self.env, "invariant step"); + let threshold = self.client.get_threshold(); + let amount = 1 + i128::from(value); + let recipient = Address::generate(&self.env); + + let mut transfers = Vec::new(&self.env); + transfers.push_back(Transfer { to: recipient.clone(), token: self.token.clone(), amount }); + let small = 1 + value % 3; + let result = match value % 5 { + 0 | 1 => self.client.try_create_proposal(&proposer, &transfers, &text, &deadline, &ProposalCategory::Transfer), + 2 => self.client.try_create_add_owner_proposal(&proposer, &subject, &small, &text, &deadline), + 3 => self.client.try_create_remove_owner_proposal(&proposer, &subject, &text, &deadline), + _ if value % 2 == 0 => self.client.try_create_change_threshold_proposal(&proposer, &small, &text, &deadline), + _ => self.client.try_create_change_weight_proposal(&proposer, &subject, &small, &text, &deadline), + }; + if let Ok(Ok(id)) = result { + let is_transfer = value % 5 < 2; + if is_transfer { + self.recipients.push(recipient); + } + assert_eq!(id, self.proposals.len() as u64 + 1, "INV-09: ids are sequential"); + self.proposals.push(Tracked { + quorum: threshold, + paid: is_transfer.then_some(amount), + recorded: BTreeMap::new(), + }); + } + } + + fn approve_or_revoke(&mut self, actor: usize, id: u64, approve: bool) { + let who = self.pool[actor].clone(); + let was_owner = self.client.is_owner(&who); + let weight = if was_owner { self.client.get_owner_weight(&who) } else { 0 }; + let had = self.client.has_approved(&id, &who); + let accepted = if approve { + matches!(self.client.try_approve(&who, &id), Ok(Ok(_))) + } else { + matches!(self.client.try_revoke(&who, &id), Ok(Ok(_))) + }; + let tracked = &mut self.proposals[id as usize - 1]; + if approve { + assert!(!(accepted && had), "INV-04: owner approved twice"); + assert!(!accepted || was_owner, "INV-04: non-owner approved"); + if accepted { + tracked.recorded.insert(actor, weight); + } + } else if accepted { + assert!(had, "INV-04: revoked without approving"); + tracked.recorded.remove(&actor); + } + } + + fn execute(&mut self, actor: usize, id: u64) { + let who = self.pool[actor].clone(); + let before = self.client.get_proposal(&id); + let now = self.env.ledger().timestamp(); + if matches!(self.client.try_execute(&who, &id), Ok(Ok(_))) { + assert!( + matches!(before.status, ProposalStatus::Pending | ProposalStatus::Ready), + "INV-06: executed twice" + ); + assert!(before.approvals >= before.quorum_weight, "INV-06: executed below quorum"); + assert!(now <= before.deadline, "INV-06: executed after deadline"); + assert!(now >= before.ready_at + TIMELOCK, "INV-07: executed inside time-lock"); + } + } + + fn check(&self) -> Result<(), TestCaseError> { + let weights = self.client.get_owner_weights(); + let sum: u32 = weights.iter().map(|w| w.weight).sum(); + let total = self.client.get_total_weight(); + let threshold = self.client.get_threshold(); + prop_assert_eq!(sum, total, "INV-01: total weight != sum of owner weights"); + prop_assert!(threshold >= 1 && threshold <= total, "INV-02: threshold {} outside 1..={}", threshold, total); + prop_assert!(weights.len() >= 1 && weights.len() <= MAX_OWNERS, "INV-03: owner count out of bounds"); + + let now = self.env.ledger().timestamp(); + let (mut live, mut paid_total) = (0u32, 0i128); + for (index, tracked) in self.proposals.iter().enumerate() { + let id = index as u64 + 1; + let p = self.client.get_proposal(&id); + prop_assert_eq!(p.quorum_weight, tracked.quorum, "INV-05: quorum of {} changed", id); + if matches!(p.status, ProposalStatus::Pending | ProposalStatus::Ready) { + let expected: u32 = (0..POOL) + .filter(|i| self.client.has_approved(&id, &self.pool[*i])) + .map(|i| tracked.recorded.get(&i).copied().unwrap_or(0)) + .sum(); + prop_assert_eq!(p.approvals, expected, "INV-04: approvals of {} != sum of approver weights", id); + live += u32::from(now <= p.deadline); + } + if p.status == ProposalStatus::Executed { + paid_total += tracked.paid.unwrap_or(0); + } + } + prop_assert!(live <= MAX_ACTIVE_PROPOSALS, "INV-08: {} live proposals exceed the cap", live); + + let received: i128 = self.recipients.iter().map(|r| self.balance(r)).sum(); + prop_assert_eq!(self.balance(&self.treasury), MINTED - paid_total, "INV-10: treasury != minted - executed transfers"); + prop_assert_eq!(received, paid_total, "INV-10: recipients received other than executed transfers"); + Ok(()) + } +} + +proptest! { + // Soroban builds a fresh Env per case and each step re-reads full state, so keep cases modest. + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Every invariant in docs/INVARIANTS.md holds after every step of a random + /// operation sequence (create, approve, revoke, execute, time passing). + #[test] + fn invariants_hold_under_randomized_operations( + owner_count in 3usize..=5, + weight in 1u32..=3, + threshold_seed in 0u32..1_000, + steps in proptest::collection::vec( + (0u8..5, 0usize..POOL, 0usize..POOL, 0u32..1_000_000, 0u32..6), + 1..=40 + ), + ) { + let mut h = Harness::new(owner_count, weight, threshold_seed); + h.check()?; + for (kind, actor, target, value, recent) in steps { + let total = h.proposals.len() as u64; + let id = (total > 0).then(|| total - u64::from(recent) % total.min(6)); + match (kind, id) { + (0, _) => h.create(actor, target, value), + (1, Some(id)) => h.approve_or_revoke(actor, id, true), + (2, Some(id)) => h.approve_or_revoke(actor, id, false), + (3, Some(id)) => { + // Everyone approves, then someone executes. + for owner in 0..POOL { + h.approve_or_revoke(owner, id, true); + } + h.execute(actor, id); + } + (4, _) => { + let now = h.env.ledger().timestamp(); + set_timestamp(&h.env, now + [1u64, 61, 3_600, 100_000][value as usize % 4]); + } + _ => {} + } + h.check()?; + } + } +} diff --git a/contracts/accord/src/lib.rs b/contracts/accord/src/lib.rs index 8427828d..5b8f3ac9 100644 --- a/contracts/accord/src/lib.rs +++ b/contracts/accord/src/lib.rs @@ -4017,6 +4017,14 @@ impl AccordContract { return Err(ContractError::SingleOwnerWeightCapExceeded); } + // Invariant (INV-02): threshold <= total weight. The active-proposal + // scan below only sees quorum snapshots taken at creation, so a + // threshold raised after this proposal was created must be checked + // against the live value. + if new_total < read_threshold(&env)? { + return Err(ContractError::WouldBreakThreshold); + } + // Invariant: ensure no active (Pending/Ready) proposal would // become un-quorumable (quorum_weight > new_total_weight). let active_ids = read_active_ids(&env); @@ -5757,4 +5765,6 @@ impl AccordContract { mod test; #[cfg(test)] mod bench; +#[cfg(test)] +mod invariants; // TODO: Ensure tests verify event parsing and decoding into typed records diff --git a/deny.toml b/deny.toml index bec3759f..33dcd70d 100644 --- a/deny.toml +++ b/deny.toml @@ -23,6 +23,12 @@ name = "darling" [[bans.skip]] name = "spin" +# Supply-chain policy (docs/SUPPLY_CHAIN_POLICY.md): crates.io is the only allowed source. +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] + [licenses] # Only licenses that actually appear in the current dependency tree. allow = [ diff --git a/docs/CI.md b/docs/CI.md index 6b4ec5b1..e0352f27 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -13,6 +13,8 @@ For how to run any suite locally, see [CONTRIBUTING.md](../CONTRIBUTING.md) → | --- | --- | --- | --- | | Smart Contract — Build & CI | `.github/workflows/contract.yml` | push to `main`/`v*` tags, PRs | Builds the Soroban contract, runs the weight-fixture and RBAC suites, enforces coverage and resource-benchmark gates, and (on push/tags) runs the testnet smoke test and weight-invariant gate. | | Security Checks | `.github/workflows/security.yml` | PRs, pushes to `main` | Runs the tracked-file secret heuristic and Gitleaks over the full git history; any finding fails the job. | +| Supply-chain policy | `.github/workflows/supply-chain.yml` | PRs, pushes to `main`, PR reviews | Enforces [`SUPPLY_CHAIN_POLICY.md`](SUPPLY_CHAIN_POLICY.md): committed lockfiles, allowed sources, SHA-pinned third-party actions, `--locked`/`npm ci` installs, npm signature/provenance verification, and approving review for any policy exception. | +| Launch checklist | `.github/workflows/launch-checklist.yml` | PRs touching `launch/**` | Validates the structure of the mainnet launch checklist. The strict, signed-off check runs as the `mainnet-checklist` gate in Release (see [`MAINNET_LAUNCH.md`](MAINNET_LAUNCH.md)). | | Frontend CI & Coverage | `.github/workflows/frontend-ci.yml` | pushes/PRs touching `frontend/**` | Type-check, lint (advisory), unit tests with coverage thresholds, and responsive screenshot comparisons. | | Frontend Storybook & Visual Regression | `.github/workflows/frontend-storybook.yml` | pushes/PRs touching `frontend/**` | Visual snapshot tests and a Storybook build. | | Indexer & Analytics CI | `.github/workflows/indexer-analytics.yml` | pushes/PRs touching indexer/analytics paths | Indexer tests against SQLite and Postgres, the analytics test suite, and the analytics API Rust checks (fmt, Clippy, tests). | diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md new file mode 100644 index 00000000..866abf14 --- /dev/null +++ b/docs/INVARIANTS.md @@ -0,0 +1,28 @@ +# Protocol Invariants + +Each security claim below is enforced in the contract and asserted after every +step of a randomized operation sequence by +[`invariants_hold_under_randomized_operations`](../contracts/accord/src/invariants.rs). +A violation fails the test with the invariant ID in the message. Run it with: + +```bash +cargo test -p accord --locked invariants +``` + +| ID | Invariant | Enforced in (`contracts/accord/src/lib.rs`) | Property test | +| --- | --- | --- | --- | +| INV-01 | `total_weight == sum(owner weights)` | `execute` (AddOwner, RemoveOwner, ChangeOwnerWeight) | `invariants_hold_under_randomized_operations`; also `total_weight_matches_all_current_owner_weights` | +| INV-02 | `1 <= threshold <= total_weight` | `validate_threshold`, `execute` (ChangeThreshold, RemoveOwner, ChangeOwnerWeight) | `invariants_hold_under_randomized_operations` | +| INV-03 | `1 <= owner count <= MAX_OWNERS (20)` | `initialize`, `execute` (AddOwner, RemoveOwner) | `invariants_hold_under_randomized_operations` | +| INV-04 | A proposal's `approvals` equals the sum of the weights recorded for its current approvers; an owner approves at most once, only owners approve, and a revoke needs a standing approval | `approve`, `revoke`, `execute` (RemoveOwner clears the removed owner's approvals) | `invariants_hold_under_randomized_operations`; also `approval_weight_always_equals_sum_of_approved_weights` | +| INV-05 | `quorum_weight` is fixed at creation and never changes | `create_*_proposal`, `derive_status` | `invariants_hold_under_randomized_operations` | +| INV-06 | A proposal executes at most once, only with `approvals >= quorum_weight`, and not after its deadline | `execute`, `derive_status` | `invariants_hold_under_randomized_operations` | +| INV-07 | A proposal executes only once `ready_at + time_lock_delay <= now` | `execute` | `invariants_hold_under_randomized_operations` | +| INV-08 | At most `MAX_ACTIVE_PROPOSALS (50)` live proposals | `register_active_proposal` | `invariants_hold_under_randomized_operations`; also `active_count_never_exceeds_max` | +| INV-09 | Proposal ids are sequential from 1 | `create_*_proposal`, `write_next_id` | `invariants_hold_under_randomized_operations` | +| INV-10 | Value is conserved: `treasury == minted - sum(executed transfers)` and recipients hold exactly what was paid | `execute` (Transfer) | `invariants_hold_under_randomized_operations` | + +`ChangeOwnerWeight` previously checked only the quorum snapshots of active +proposals, so a threshold raised after the proposal was created could be left +above the new total weight (INV-02). `execute` now also checks the live +threshold. diff --git a/docs/MAINNET_LAUNCH.md b/docs/MAINNET_LAUNCH.md new file mode 100644 index 00000000..9c4dac01 --- /dev/null +++ b/docs/MAINNET_LAUNCH.md @@ -0,0 +1,44 @@ +# Mainnet Launch Gate + +A mainnet release is blocked until [`launch/mainnet-checklist.json`](../launch/mainnet-checklist.json) +is complete and signed off. The gate is +[`scripts/check-mainnet-checklist.mjs`](../scripts/check-mainnet-checklist.mjs), +run by the `mainnet-checklist` job in [`release.yml`](../.github/workflows/release.yml), +which the `release` and `publish-sdk` jobs depend on. + +## What counts as a mainnet release + +Any `v*` tag **without** a pre-release suffix (`v1.0.0`). Tags such as +`v1.0.0-rc.1` only get a structure check. Pull requests that touch `launch/` +get the same structure check from `launch-checklist.yml`. + +## Checklist items + +Each item has an `id`, a `category` (`code`, `tests`, `operations`), a +`requirement`, one `evidence` reference, a `status` and a `signOff`: + +```json +{ + "id": "C-1", + "category": "code", + "requirement": "…", + "evidence": "docs/audit/REMEDIATION.md", + "status": "done", + "signOff": { "by": "github-handle", "date": "2026-11-01" } +} +``` + +- **Evidence** is a repo-relative path (checked to exist) or an `https://` link + to something a reviewer can open: an audit report, a CI run, a drill record. + Replace the placeholder path with the specific artifact when you mark an item done. +- **Sign-off** is the person who verified the evidence and the date. The gate + rejects a `done` item without both, or with a future date. + +## Each launch + +1. Set `"release"` to the tag being cut (the gate fails if it differs, so the + checklist is re-reviewed for every launch rather than carried over). +2. Reset every item to `pending`, then re-verify each one against current evidence. +3. Mark items `done` with evidence and sign-off, and merge through a normal PR + so the sign-offs are reviewed. +4. Push the tag. The release jobs only run if the gate passes. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 10063aad..f1c98be8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -114,6 +114,8 @@ The tests in `contracts/accord/src/test.rs` use two mock tokens: `AdversarialTok ## Threat Model +> RBAC, delegation and recurring payments are analysed in full, with code links and residual risks, in [THREAT_MODEL_RBAC_DELEGATION_RECURRING.md](./THREAT_MODEL_RBAC_DELEGATION_RECURRING.md). + ### Attack Surfaces The protocol's attack surface includes the following external entry points: diff --git a/docs/SUPPLY_CHAIN_POLICY.md b/docs/SUPPLY_CHAIN_POLICY.md new file mode 100644 index 00000000..31b29780 --- /dev/null +++ b/docs/SUPPLY_CHAIN_POLICY.md @@ -0,0 +1,96 @@ +# Supply-Chain Policy + +Accord Protocol controls real funds, so an unexpected or malicious dependency +update is a direct risk to them. This policy defines what we pin, what we verify, +and how a deviation gets approved. It is enforced in CI by +[`scripts/check-supply-chain.mjs`](../scripts/check-supply-chain.mjs) and the +[Supply-chain policy workflow](../.github/workflows/supply-chain.yml). + +## Rationale + +| Threat | Control | +| --- | --- | +| A maintainer account or registry is compromised and a new version is published under an existing name | Resolved versions are pinned in committed lockfiles; CI installs only what the lockfile says (`--locked`, `npm ci`). Upgrades are explicit reviewed PRs. | +| A package is swapped or tampered with after review | Lockfiles carry content hashes (Cargo `checksum`, npm `sha512` `integrity`); the check fails if any are missing. | +| A dependency is redirected to a git branch, tarball URL or alternate registry | Only crates.io and registry.npmjs.org are allowed sources. | +| A mutable CI action tag (`@v2`, `@stable`) is repointed | Third-party actions must be pinned to a full commit SHA. | +| A release binary used in CI is replaced | Downloaded tools are verified against the publisher's checksum file. | +| A malicious install script runs in CI | Provenance job installs with `--ignore-scripts`. | + +## Rules + +Each rule has an ID used by exceptions. + +| Rule ID | Requirement | Enforced by | +| --- | --- | --- | +| `lockfile-missing` | `Cargo.lock` and a `package-lock.json` next to every tracked `package.json` are committed. | `check-supply-chain.mjs` | +| `lockfile-integrity` | Every npm lockfile package resolves from `registry.npmjs.org` and has a `sha512` integrity hash (packages bundled inside a verified parent tarball are exempt). | `check-supply-chain.mjs` | +| `cargo-source` | Every package in `Cargo.lock` comes from crates.io and has a checksum. `deny.toml` `[sources]` denies unknown registries and git sources. | `check-supply-chain.mjs`, `cargo deny check sources` | +| `manifest-spec` | `package.json` dependencies are registry version ranges: no `*`, `latest`, git, GitHub-shorthand or URL specs. The exact version is fixed by the lockfile. `soroban-sdk` is additionally pinned with `=` in the workspace `Cargo.toml`. | `check-supply-chain.mjs` | +| `cargo-unlocked` | `cargo build/test/check/clippy/run/doc/bench/install/llvm-cov` in workflows pass `--locked`. | `check-supply-chain.mjs` | +| `npm-install` | Workflows use `npm ci`, never `npm install`. | `check-supply-chain.mjs` | +| `action-unpinned` | Third-party GitHub Actions are pinned to a 40-character commit SHA. First-party `actions/*` and `github/*` may use version tags. | `check-supply-chain.mjs` | + +## Provenance verification + +Verification is applied wherever the ecosystem supports it: + +- **npm** — `npm audit signatures` verifies registry signatures and, where a + package publishes one, its provenance attestation, for `frontend/` and + `indexer/` after `npm ci --ignore-scripts`. +- **Cargo** — Cargo verifies each crate against the `checksum` in `Cargo.lock` + when built with `--locked`; `cargo metadata --locked` fails if the lockfile is + out of date, and `cargo deny check sources` rejects non-crates.io sources. + crates.io has no publisher-signature scheme, so checksum + source restriction + is the strongest available check. +- **Release binaries** — the Gitleaks binary is checked against the checksum + file published with its release before it runs. +- **Advisories and licenses** — `cargo audit` and `cargo deny check` run in + [`contract.yml`](../.github/workflows/contract.yml); Dependabot opens weekly + update PRs for Cargo, npm and GitHub Actions + ([`dependabot.yml`](../.github/dependabot.yml)). + +## Updating dependencies + +1. Update through a PR (usually Dependabot). The lockfile diff is part of the review. +2. Review new or changed packages, especially new transitive packages and any + that gained install scripts. +3. CI must pass the supply-chain workflow before merge. + +## Exceptions + +A rule may be bypassed only through an entry in +[`supply-chain/exceptions.json`](../supply-chain/exceptions.json): + +```json +{ + "id": "SC-014", + "rule": "action-unpinned", + "target": "owner/action@ref", + "reason": "Why the rule cannot be met and what removes the exception.", + "requestedBy": "github-handle", + "added": "2026-10-23", + "expires": "2027-04-01" +} +``` + +- `rule` and `target` must match the finding exactly (the checker prints both). +- Exceptions expire: `expires` is at most 180 days after `added`. An expired + exception fails CI until the finding is fixed or the exception is renewed + through review. +- A stale exception (one that no longer matches a finding) fails CI, so + exceptions are removed as soon as they are fixed. +- **Review is enforced.** The `exception-review` job fails any PR that changes + `supply-chain/exceptions.json` or `scripts/check-supply-chain.mjs` until + someone other than the author has approved it. The PR approval is the + recorded sign-off. Mark that job as a required status check in branch + protection. + +## Known gaps (tracked as exceptions) + +- Third-party Actions are still tag-pinned. Each is listed as an `action-unpinned` + exception; resolve the SHA, pin it, and delete the entry. Dependabot keeps SHA + pins current. +- `packages/sdk` and `examples/multisig-dapp` have no committed lockfile. +- CI tool installs (`cargo install cargo-audit/cargo-deny --locked`) use the + latest release; their dependencies are locked but the tool version is not. diff --git a/docs/THREAT_MODEL_RBAC_DELEGATION_RECURRING.md b/docs/THREAT_MODEL_RBAC_DELEGATION_RECURRING.md new file mode 100644 index 00000000..9d1cd82e --- /dev/null +++ b/docs/THREAT_MODEL_RBAC_DELEGATION_RECURRING.md @@ -0,0 +1,103 @@ +# Threat Model Extension: RBAC, Delegation and Recurring Payments + +This extends the [Threat Model in SECURITY.md](./SECURITY.md#threat-model), which +predates these three features in full. Code links point at +[`contracts/accord/src/lib.rs`](../contracts/accord/src/lib.rs); line numbers +drift, so the function name is the stable anchor. + +Residual-risk status: **Accepted** (documented, operator-managed), **Open** +(a code change would close it and is not yet made). + +## Shared trust assumptions + +- Owner keys are secret, and fewer than `threshold` weight is colluding. +- The ledger timestamp is accurate within normal Stellar close-time skew. +- Token contracts follow the token interface and are not maliciously upgraded. +- Anything gated by a proposal is only as safe as the approvers' review of the + proposal payload. + +## 1. Role-Based Access Control + +**Surfaces:** role-gated entrypoints ([`require_role`](../contracts/accord/src/lib.rs#L1233), +[`has_role`](../contracts/accord/src/lib.rs#L1223)); role changes via +[`create_grant_role_proposal`](../contracts/accord/src/lib.rs#L4890) / +[`create_revoke_role_proposal`](../contracts/accord/src/lib.rs#L4976) and their +execution in `execute` (`ProposalKind::GrantRole` / `RevokeRole`); role storage +([`update_owner_roles`](../contracts/accord/src/lib.rs#L1183)); non-owner roles. + +**Trust assumptions:** roles never grant voting weight, only owners vote; +admin actions (`upgrade`, `set_guardian`, `unfreeze`, migrations) are not +role-gated and need owner weight ([`require_weighted_approvers`](../contracts/accord/src/lib.rs#L1815)). + +| ID | Misuse case | Mitigation (code) | Residual | +| --- | --- | --- | --- | +| RB-1 | Attacker grants themselves `Approver`/`Executor` | Role grants are proposals needing quorum and the `Proposer` role to draft ([`create_grant_role_proposal`](../contracts/accord/src/lib.rs#L4890), `execute` → `GrantRole`) | Accepted: colluding quorum can grant anything | +| RB-2 | Non-owner with `Approver` role votes | `approve` requires both the role and owner membership/weight ([`approve`](../contracts/accord/src/lib.rs#L3509), [`require_owner_and_weight`](../contracts/accord/src/lib.rs#L1782)) | None | +| RB-3 | Revoking `Approver` strands quorum (remaining approver weight < threshold) | Checked at creation ([`create_revoke_role_proposal`](../contracts/accord/src/lib.rs#L4976)) and again at execution (`RevokeRole`, via [`remaining_approver_weight_after_revoke`](../contracts/accord/src/lib.rs#L1283)) → `WouldBreakQuorum`. SECURITY.md still describes this as operator-only; the on-chain guard exists. | Accepted: guard compares to the live threshold, not to snapshotted `quorum_weight` of in-flight proposals | +| RB-4 | Rogue/compromised non-owner `Executor` | Cannot alter payload or fabricate approvals; needs `Ready` status, time-lock, deadline, no freeze ([`execute`](../contracts/accord/src/lib.rs#L3662)); owners can `revoke` before execution | Accepted: executor controls *when* a Ready proposal runs | +| RB-5 | Non-owner `Proposer` floods the proposal cap or sidesteps owner-keyed spending limits | Quorum still required to move funds; `MAX_ACTIVE_PROPOSALS` bounds storage; limits apply to owner proposers only ([`create_proposal`](../contracts/accord/src/lib.rs#L2499)) | Accepted: a non-owner proposer can fill the cap (DoS) until proposals expire or are cancelled | +| RB-6 | Removed owner keeps roles or approvals | Removal clears roles ([`update_owner_roles`](../contracts/accord/src/lib.rs#L1183) with empty set) and strips their approval weight from live proposals (`execute` → `RemoveOwner`) | None | +| RB-7 | One key holds Proposer+Approver+Executor (default) | Default owner roles kept for M-of-N compatibility; single-owner weight cap (`ChangeOwnerWeight`) | Accepted: operators should prune roles after bootstrap (see SECURITY.md RBAC addendum) | + +## 2. Delegation + +**Surfaces:** delegation storage and expiry +([`read_delegation`](../contracts/accord/src/lib.rs#L1331), +[`is_delegation_active`](../contracts/accord/src/lib.rs#L1352)); weight used at +approval time ([`compute_effective_weight`](../contracts/accord/src/lib.rs#L1364), +called by `approve`); cleanup on owner removal (`execute` → `RemoveOwner`, +[`remove_delegation`](../contracts/accord/src/lib.rs#L1346)); views +`get_delegations`, `get_effective_weight`. + +**Scope note:** at this commit nothing in the contract calls `write_delegation`, +so no entrypoint creates a delegation. The analysis below covers the read/approve +path that exists and the risks any creating entrypoint must satisfy. + +**Trust assumptions:** a delegator trusts the delegate with that weight until +revoked or expired; delegation moves weight between owners, it never creates it. + +| ID | Misuse case | Mitigation (code) | Residual | +| --- | --- | --- | --- | +| DL-1 | Weight inflation through chains or cycles | Effective weight is `raw − own outgoing + direct incoming` only; it is not transitive ([`compute_effective_weight`](../contracts/accord/src/lib.rs#L1364)); one outgoing delegation per owner | None | +| DL-2 | Delegation to or from a removed owner survives | `RemoveOwner` deletes the removed owner's outgoing delegation and every incoming one | None | +| DL-3 | Expired delegation still counts | `is_delegation_active` compares expiry to the ledger time on every computation | Accepted: timestamp trust | +| DL-4 | Weight counted twice on one proposal: delegate approves with delegated weight, delegation is then revoked/expires, delegator approves with restored weight | `revoke` reverses the exact weight stored at approval ([`revoke`](../contracts/accord/src/lib.rs#L3581)) | **Open:** `approve` snapshots effective weight, so a later delegation change does not adjust existing approvals and the same weight can count twice. Close it before a creation entrypoint ships (for example block delegation changes while the delegator or delegate has live approvals, or recompute at execute). | +| DL-5 | Delegation used to exceed the single-owner weight cap | Cap applies to raw weights ([`owner_weight_within_cap`](../contracts/accord/src/lib.rs#L843)) only | **Open:** effective weight can pass the cap and reach threshold alone | +| DL-6 | Gas/storage DoS from reading every owner's delegation | Owner count bounded by `MAX_OWNERS` (20), so the loop is bounded | None | + +## 3. Recurring payments + +**Surfaces:** schedule creation/modification by proposal +([`create_recurring_proposal`](../contracts/accord/src/lib.rs#L4469), +[`create_cancel_recurring_proposal`](../contracts/accord/src/lib.rs#L4599), +[`create_pause_recurring_proposal`](../contracts/accord/src/lib.rs#L4666), +[`create_modify_recurring_proposal`](../contracts/accord/src/lib.rs#L4797), +executed in `execute` → `CreateRecurringPayment` / `Cancel…` / `Pause…` / `Modify…`); +permissionless payout ([`disburse_recurring`](../contracts/accord/src/lib.rs#L2679)); +recipient-driven streams ([`claim_stream`](../contracts/accord/src/lib.rs#L2823)). +See also [SECURITY_RECURRING.md](./SECURITY_RECURRING.md). + +**Trust assumptions:** approvers review schedule parameters (amount, interval, +cap, recipient) as carefully as a one-off transfer; keepers are untrusted. + +| ID | Misuse case | Mitigation (code) | Residual | +| --- | --- | --- | --- | +| RC-1 | Unauthorized schedule | Creation, pause, cancel and modify are quorum proposals (`execute` arms above); the old direct entrypoints are deprecated ([`cancel_recurring_schedule`](../contracts/accord/src/lib.rs#L2457) returns `DeprecatedEntrypoint`) | Accepted | +| RC-2 | Keeper redirects or accelerates payouts | `disburse_recurring` pays only the stored recipient/amount, enforces interval, status and `total_cap`; any caller gets the same result | None | +| RC-3 | Many schedules drain the treasury or storage | `MAX_ACTIVE_RECURRING` (20) checked at creation and execution ([line 4529](../contracts/accord/src/lib.rs#L4529), [`CreateRecurringPayment`](../contracts/accord/src/lib.rs#L4057)); interval bounds | Accepted: schedules reserve no balance, so they compete with proposals (escrow is the only reserved balance, [`available_balance`](../contracts/accord/src/lib.rs#L1492)); payout fails with `TransferFailed` when short | +| RC-4 | Spending limits bypassed through schedules | Disbursements are attributed to the proposer's spent tracker | **Open:** limits are checked at creation only; `disburse_recurring` updates the tracker without enforcing the limit, and `Modify` can raise `amount` without a limit check | +| RC-5 | Malicious modify (raise amount, shorten interval) | Needs quorum; `r_mod` event records old and new values; amount, interval and end time are bounds-checked | Accepted: approvers must read the diff | +| RC-6 | Payouts during an incident | `disburse_recurring` blocks on freeze and recovery | **Open:** `claim_stream` blocks on freeze but not recovery, so continuous streams still pay out in recovery mode | +| RC-7 | Fund lock on paused/cancelled schedule | No funds are reserved per schedule, so none can be locked; terminal states reject further payouts | None | +| RC-8 | Token changes behaviour after approval | `validate_token` runs at each disbursement; failed transfers revert without advancing the schedule | Accepted: same token-upgrade gap as one-off transfers | + +## Review record + +The model is reviewed through the pull request that changes this file: it is not +considered reviewed until the PR is approved by someone other than the author. + +| Reviewer | Area | Date | Outcome | +| --- | --- | --- | --- | +| _pending_ | RBAC | | | +| _pending_ | Delegation | | | +| _pending_ | Recurring | | | diff --git a/launch/mainnet-checklist.json b/launch/mainnet-checklist.json new file mode 100644 index 00000000..11462bae --- /dev/null +++ b/launch/mainnet-checklist.json @@ -0,0 +1,109 @@ +{ + "release": "", + "items": [ + { + "id": "C-1", + "category": "code", + "requirement": "Independent third-party audit is complete and every Critical/High finding is fixed and re-verified", + "evidence": "docs/audit/REMEDIATION.md", + "status": "pending", + "signOff": null + }, + { + "id": "C-2", + "category": "code", + "requirement": "Open residual risks in the threat model are fixed or explicitly accepted", + "evidence": "docs/THREAT_MODEL_RBAC_DELEGATION_RECURRING.md", + "status": "pending", + "signOff": null + }, + { + "id": "C-3", + "category": "code", + "requirement": "Protocol invariants are documented and their property test passes on the release commit", + "evidence": "docs/INVARIANTS.md", + "status": "pending", + "signOff": null + }, + { + "id": "C-4", + "category": "code", + "requirement": "Dependencies pass the supply-chain policy with no unreviewed or expired exceptions", + "evidence": "supply-chain/exceptions.json", + "status": "pending", + "signOff": null + }, + { + "id": "C-5", + "category": "code", + "requirement": "Release WASM hash is recorded and the upgrade procedure is reviewed", + "evidence": "docs/UPGRADE_SAFETY.md", + "status": "pending", + "signOff": null + }, + { + "id": "T-1", + "category": "tests", + "requirement": "Contract suite (flat and skewed fixtures), coverage and size gates are green on the release commit", + "evidence": "docs/CI.md", + "status": "pending", + "signOff": null + }, + { + "id": "T-2", + "category": "tests", + "requirement": "Testnet smoke test and upgrade simulation pass on the release commit", + "evidence": "docs/CI.md", + "status": "pending", + "signOff": null + }, + { + "id": "T-3", + "category": "tests", + "requirement": "Manual end-to-end run (create, approve, execute) against the deployed testnet contract is recorded", + "evidence": "docs/DEPLOYMENT.md", + "status": "pending", + "signOff": null + }, + { + "id": "O-1", + "category": "operations", + "requirement": "Owner keys are on hardware wallets and the key-management guide is followed", + "evidence": "docs/guides/owner-key-management.md", + "status": "pending", + "signOff": null + }, + { + "id": "O-2", + "category": "operations", + "requirement": "Guardian is set and a freeze/unfreeze drill has been run", + "evidence": "docs/runbooks/INCIDENT_RESPONSE.md", + "status": "pending", + "signOff": null + }, + { + "id": "O-3", + "category": "operations", + "requirement": "Key rotation and upgrade rollback runbooks are rehearsed", + "evidence": "docs/runbooks/KEY_ROTATION.md", + "status": "pending", + "signOff": null + }, + { + "id": "O-4", + "category": "operations", + "requirement": "Monitoring and alerting for the deployed contract and indexer are live", + "evidence": "docs/OPERATIONS.md", + "status": "pending", + "signOff": null + }, + { + "id": "O-5", + "category": "operations", + "requirement": "Mainnet deployment steps are dry-run and the contract ID is recorded", + "evidence": "docs/DEPLOYMENT.md", + "status": "pending", + "signOff": null + } + ] +} diff --git a/scripts/README.md b/scripts/README.md index 3c973e36..e54551dd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -10,6 +10,7 @@ Helper scripts for Accord Protocol. | `indexer.js` | **Off-chain indexer** — polls contract events, persists to store, and maintains checkpoints | | `keeper-recurring.js` | **Off-chain keeper** — polls due recurring schedules and calls `disburse_recurring` | | `query-analytics.js` | **Analytics query CLI** — queries indexed data and prints human-readable tables for spend, treasury flow, and summary metrics | +| `check-supply-chain.mjs` | **Supply-chain gate** — enforces lockfile, source, action-pinning and `--locked` rules, with reviewed expiring exceptions in `supply-chain/exceptions.json` (see `docs/SUPPLY_CHAIN_POLICY.md`) | | `flaky-tests.mjs` | **Flakiness tracker** — records per-test history, reports the flakiest tests, quarantine status and weekly trends (see CONTRIBUTING.md) | --- diff --git a/scripts/check-mainnet-checklist.mjs b/scripts/check-mainnet-checklist.mjs new file mode 100644 index 00000000..3f6ed512 --- /dev/null +++ b/scripts/check-mainnet-checklist.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// Mainnet launch gate. See docs/MAINNET_LAUNCH.md. +// +// node scripts/check-mainnet-checklist.mjs --release v1.0.0 # strict: every item done and signed off +// node scripts/check-mainnet-checklist.mjs --structure # schema only (PRs, pre-release tags) + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const CATEGORIES = new Set(['code', 'tests', 'operations']); +const args = process.argv.slice(2); +const structureOnly = args.includes('--structure'); +const release = args.includes('--release') ? args[args.indexOf('--release') + 1] : undefined; + +const checklist = JSON.parse(readFileSync(join(ROOT, 'launch/mainnet-checklist.json'), 'utf8')); +const problems = []; + +function evidenceResolves(ref) { + if (/^https:\/\/\S+$/.test(ref)) return true; + const path = normalize(ref.split('#')[0]); + return !path.startsWith('..') && existsSync(join(ROOT, path)); +} + +if (!structureOnly) { + if (!release) problems.push('pass --release (or --structure)'); + else if (checklist.release !== release) { + problems.push(`checklist is for release "${checklist.release}", not "${release}"; review it for this launch and update "release"`); + } +} + +const seen = new Set(); +for (const item of checklist.items ?? []) { + const id = item.id ?? '(no id)'; + if (seen.has(id)) problems.push(`${id}: duplicate id`); + seen.add(id); + if (!CATEGORIES.has(item.category)) problems.push(`${id}: category must be code, tests or operations`); + if (!item.requirement) problems.push(`${id}: missing requirement`); + if (!item.evidence) problems.push(`${id}: missing evidence`); + if (!['pending', 'done'].includes(item.status)) problems.push(`${id}: status must be pending or done`); + if (structureOnly) continue; + + if (item.status !== 'done') problems.push(`${id}: not done — ${item.requirement}`); + else { + if (item.evidence && !evidenceResolves(item.evidence)) problems.push(`${id}: evidence "${item.evidence}" does not resolve`); + const { by, date } = item.signOff ?? {}; + if (!by || !/^\d{4}-\d{2}-\d{2}$/.test(date ?? '') || Date.parse(date) > Date.now()) { + problems.push(`${id}: needs signOff { by, date: YYYY-MM-DD }`); + } + } +} +for (const category of CATEGORIES) { + if (!(checklist.items ?? []).some((i) => i.category === category)) problems.push(`no ${category} items`); +} + +if (problems.length > 0) { + console.error(`Mainnet launch checklist: ${problems.length} problem(s)`); + for (const p of problems) console.error(` - ${p}`); + process.exit(1); +} +console.log(structureOnly ? 'Mainnet checklist structure OK.' : `Mainnet checklist complete for ${release}.`); diff --git a/scripts/check-supply-chain.mjs b/scripts/check-supply-chain.mjs new file mode 100644 index 00000000..2a103987 --- /dev/null +++ b/scripts/check-supply-chain.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Enforces docs/SUPPLY_CHAIN_POLICY.md. Dependency-free so it can run before any install step. +// +// node scripts/check-supply-chain.mjs +// +// Every violation must either be fixed or covered by a live entry in +// supply-chain/exceptions.json. Exits non-zero when any violation is uncovered, +// when an exception is malformed or expired, or when an exception no longer +// matches anything (stale exceptions are removed, not carried). + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const EXCEPTIONS_FILE = 'supply-chain/exceptions.json'; +const MAX_EXCEPTION_DAYS = 180; +const CRATES_IO = 'registry+https://github.com/rust-lang/crates.io-index'; +const NPM_REGISTRY = 'https://registry.npmjs.org/'; +const FIRST_PARTY_ACTION_OWNERS = new Set(['actions', 'github']); +const RULES = new Set([ + 'lockfile-missing', + 'lockfile-integrity', + 'manifest-spec', + 'cargo-source', + 'action-unpinned', + 'npm-install', + 'cargo-unlocked', +]); + +const read = (path) => readFileSync(join(ROOT, path), 'utf8'); +const tracked = () => + execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8' }).split('\0').filter(Boolean); + +/** A violation is identified by `${rule}:${target}`, which is what an exception names. */ +const violation = (rule, target, detail) => ({ rule, target, detail }); + +export function checkCargoLock(text) { + const out = []; + for (const block of text.split(/\n\[\[package\]\]\n/).slice(1)) { + const name = /^name = "(.+)"$/m.exec(block)?.[1] ?? '?'; + const version = /^version = "(.+)"$/m.exec(block)?.[1] ?? '?'; + const source = /^source = "(.+)"$/m.exec(block)?.[1]; + if (source === undefined) continue; // workspace member + if (source !== CRATES_IO) { + out.push(violation('cargo-source', `${name}@${version}`, `source ${source} is not crates.io`)); + } else if (!/^checksum = "[0-9a-f]{64}"$/m.test(block)) { + out.push(violation('cargo-source', `${name}@${version}`, 'registry crate has no checksum')); + } + } + return out; +} + +export function checkNpmLock(dir, lock) { + const out = []; + const packages = lock.packages ?? {}; + if (!lock.lockfileVersion || lock.lockfileVersion < 2) { + out.push(violation('lockfile-integrity', `${dir}/package-lock.json`, 'lockfileVersion must be 2 or 3')); + } + for (const [path, meta] of Object.entries(packages)) { + // Bundled packages ship inside their (integrity-checked) parent tarball. + if (path === '' || meta.link || meta.inBundle) continue; + const label = `${dir}:${path.replace(/^.*node_modules\//, '')}@${meta.version ?? '?'}`; + if (!meta.resolved || !meta.resolved.startsWith(NPM_REGISTRY)) { + out.push(violation('lockfile-integrity', label, `resolved from ${meta.resolved ?? 'nowhere'}, not ${NPM_REGISTRY}`)); + } else if (!/^sha512-/.test(meta.integrity ?? '')) { + out.push(violation('lockfile-integrity', label, 'missing sha512 integrity hash')); + } + } + return out; +} + +export function checkManifest(dir, manifest) { + const out = []; + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, spec] of Object.entries(manifest[field] ?? {})) { + const loose = spec === '*' || spec === '' || spec === 'latest' || spec === 'x'; + const remote = /^(git|github:|https?:|[\w.-]+\/[\w.-]+(#|$))/.test(spec) || spec.includes('://'); + if (loose || remote) { + out.push(violation('manifest-spec', `${dir}:${name}`, `"${spec}" is not a registry version range`)); + } + } + } + return out; +} + +export function checkWorkflow(file, text) { + const out = []; + // Join shell line continuations so a flag on a later line still counts. + const lines = text.replace(/\\\n\s*/g, ' ').split('\n'); + for (const raw of lines) { + const line = raw.replace(/\s+#.*$/, '').trim(); + if (!line || line.startsWith('#') || /^-?\s*name:/.test(line)) continue; + + const uses = /^-?\s*uses:\s*(\S+)/.exec(line)?.[1]; + if (uses && !uses.startsWith('./') && !uses.startsWith('docker://')) { + const [spec, ref = ''] = uses.split('@'); + const owner = spec.split('/')[0]; + const pinned = /^[0-9a-f]{40}$/.test(ref); + if (!pinned && !FIRST_PARTY_ACTION_OWNERS.has(owner)) { + out.push(violation('action-unpinned', uses, `${file}: third-party action must be pinned to a full commit SHA`)); + } + } + + if (/\bnpm\s+(install|i)\b(?!\s+(-g|--global))/.test(line)) { + out.push(violation('npm-install', file, `${file}: use \`npm ci\` so the lockfile is enforced`)); + } + const cargo = /\bcargo\s+(build|test|check|clippy|run|doc|bench|install|llvm-cov(?!\s+report))\b/.exec(line); + if (cargo && !/\s--locked\b/.test(line)) { + out.push(violation('cargo-unlocked', `${file}:cargo ${cargo[1]}`, `${file}: \`cargo ${cargo[1]}\` must pass --locked`)); + } + } + return out; +} + +export function validateExceptions(doc, today = new Date()) { + const errors = []; + const list = doc?.exceptions; + if (!Array.isArray(list)) return ['exceptions.json must contain an "exceptions" array']; + const seen = new Set(); + for (const e of list) { + const id = e?.id ?? '(no id)'; + for (const f of ['id', 'rule', 'target', 'reason', 'requestedBy', 'added', 'expires']) { + if (typeof e?.[f] !== 'string' || e[f].trim() === '') errors.push(`${id}: missing "${f}"`); + } + if (seen.has(e?.id)) errors.push(`${id}: duplicate id`); + seen.add(e?.id); + if (e?.rule && !RULES.has(e.rule)) errors.push(`${id}: unknown rule "${e.rule}"`); + const added = Date.parse(e?.added); + const expires = Date.parse(e?.expires); + if (Number.isNaN(added) || Number.isNaN(expires)) { + errors.push(`${id}: "added" and "expires" must be ISO dates`); + continue; + } + if (expires < today.getTime()) errors.push(`${id}: expired on ${e.expires}; fix the finding or renew through review`); + if ((expires - added) / 86_400_000 > MAX_EXCEPTION_DAYS) { + errors.push(`${id}: lifetime exceeds ${MAX_EXCEPTION_DAYS} days`); + } + } + return errors; +} + +function collect() { + const files = tracked(); + const found = []; + + if (!existsSync(join(ROOT, 'Cargo.lock'))) found.push(violation('lockfile-missing', 'Cargo.lock', 'Cargo.lock is not committed')); + else found.push(...checkCargoLock(read('Cargo.lock'))); + + const manifests = files.filter((f) => /(^|\/)package\.json$/.test(f) && !f.includes('node_modules/')); + for (const manifest of manifests) { + const dir = dirname(manifest); + found.push(...checkManifest(dir, JSON.parse(read(manifest)))); + const lockPath = join(dir, 'package-lock.json'); + if (!files.includes(lockPath)) { + found.push(violation('lockfile-missing', dir, `${dir} has no committed package-lock.json`)); + } else { + found.push(...checkNpmLock(dir, JSON.parse(read(lockPath)))); + } + } + + const wfDir = join(ROOT, '.github/workflows'); + for (const f of readdirSync(wfDir).filter((n) => /\.ya?ml$/.test(n))) { + found.push(...checkWorkflow(`.github/workflows/${f}`, readFileSync(join(wfDir, f), 'utf8'))); + } + return found; +} + +export function main() { + const doc = JSON.parse(read(EXCEPTIONS_FILE)); + const problems = validateExceptions(doc); + + const found = collect(); + const key = (v) => `${v.rule}:${v.target}`; + const excepted = new Map(doc.exceptions.map((e) => [`${e.rule}:${e.target}`, e])); + const used = new Set(); + + for (const v of found) { + if (excepted.has(key(v))) used.add(key(v)); + else problems.push(`${v.rule} ${v.target} — ${v.detail}`); + } + for (const k of excepted.keys()) { + if (!used.has(k)) problems.push(`stale exception ${k} matches nothing; remove it from ${EXCEPTIONS_FILE}`); + } + + if (problems.length > 0) { + console.error(`Supply-chain policy violations (${problems.length}):`); + for (const p of problems) console.error(` - ${p}`); + console.error('\nSee docs/SUPPLY_CHAIN_POLICY.md for the rules and the exception process.'); + process.exit(1); + } + console.log(`Supply-chain policy OK (${found.length} findings, ${used.size} reviewed exceptions).`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main(); diff --git a/supply-chain/exceptions.json b/supply-chain/exceptions.json new file mode 100644 index 00000000..245a6970 --- /dev/null +++ b/supply-chain/exceptions.json @@ -0,0 +1,130 @@ +{ + "exceptions": [ + { + "id": "SC-001", + "rule": "lockfile-missing", + "target": "packages/sdk", + "reason": "Published library with no committed lockfile; generate and commit package-lock.json so sdk-publish.yml `npm ci` is reproducible.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-002", + "rule": "lockfile-missing", + "target": "examples/multisig-dapp", + "reason": "Example app with no committed lockfile; generate and commit package-lock.json, then switch frontend-ci.yml to `npm ci`.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-003", + "rule": "npm-install", + "target": ".github/workflows/frontend-ci.yml", + "reason": "Example dApp build uses `npm install` until its lockfile exists (see the examples/multisig-dapp lockfile exception).", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-004", + "rule": "action-unpinned", + "target": "actions-rust-lang/setup-rust-toolchain@v1", + "reason": "Rust toolchain setup. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-005", + "rule": "action-unpinned", + "target": "actions-rust-lang/setup-rust-toolchain@v2", + "reason": "Rust toolchain setup. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-006", + "rule": "action-unpinned", + "target": "dtolnay/rust-toolchain@stable", + "reason": "Rust toolchain setup (tracks the stable channel by design). Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-007", + "rule": "action-unpinned", + "target": "Swatinem/rust-cache@v2", + "reason": "Cargo build cache. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-008", + "rule": "action-unpinned", + "target": "stellar/stellar-cli@v28.1.0", + "reason": "Stellar CLI install. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-009", + "rule": "action-unpinned", + "target": "taiki-e/install-action@cargo-llvm-cov", + "reason": "cargo-llvm-cov install. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-010", + "rule": "action-unpinned", + "target": "lycheeverse/lychee-action@v2", + "reason": "Docs link checker. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-011", + "rule": "action-unpinned", + "target": "peaceiris/actions-gh-pages@v4", + "reason": "Preview publishing to gh-pages. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-012", + "rule": "action-unpinned", + "target": "softprops/action-gh-release@v3", + "reason": "GitHub Release publishing. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-013", + "rule": "action-unpinned", + "target": "anchore/sbom-action/download-syft@v0", + "reason": "Syft download for SBOM generation. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + }, + { + "id": "SC-014", + "rule": "action-unpinned", + "target": "anchore/scan-action/download-grype@v7", + "reason": "Grype download for vulnerability scan. Pin to a full commit SHA (Dependabot keeps SHA pins current); tag-only until the SHA is resolved and reviewed.", + "requestedBy": "Raymond Abiola", + "added": "2026-10-23", + "expires": "2027-04-01" + } + ] +}