diff --git a/.github/SECURITY_SCANNING.md b/.github/SECURITY_SCANNING.md new file mode 100644 index 00000000..45e0cfd2 --- /dev/null +++ b/.github/SECURITY_SCANNING.md @@ -0,0 +1,72 @@ +# Security Scanning in CI + +This repository runs automated dependency vulnerability scanning on every +pull request. Results are visible in the `security-scan` job of the CI +workflow (`.github/workflows/ci.yml`). + +## Tooling + +| Ecosystem | Tool | Source of truth database | +|-----------|--------------|--------------------------------------| +| Rust | `cargo-audit`| RustSec Advisory Database | +| JS / TS | `npm audit` | GitHub Advisory Database (registry) | + +Both tools are invoked from the GitHub Actions runner; no external SaaS +account or API token is required. + +## Severity policy + +Each scan reports a count of findings grouped by CVSS-level severity: +`critical`, `high`, `moderate`, `low`, and `info`. + +| Severity | Blocks the build? | Action required | +|-------------|-------------------|--------------------------------------------------------------------| +| **Critical**| ✅ Yes | Patch immediately or add a temporary suppress-and-ticket entry. | +| **High** | ✅ Yes | Patch before merge. | +| Moderate | ❌ No | Track for the next release window. | +| Low | ❌ No | Triage at team discretion. | +| Info | ❌ No | Informational only – no change required. | + +Informational findings (moderate / low / info) are printed to the CI log +for visibility but never fail the pipeline. This keeps the signal-to-noise +ratio high while ensuring the team is still aware of the backlog. + +## Running locally + +### Rust + +```bash +cargo install cargo-audit # one-time install +cd contract +cargo audit +``` + +To replicate the CI blocking behaviour (only critical/high fail): + +```bash +cd contract +cargo audit --json | jq -e ' + [.vulnerabilities.list[] | select(.severity == "critical" or .severity == "high")] + | length == 0 +' +``` + +### JavaScript / TypeScript (dashboard, listener) + +```bash +cd dashboard +npm audit # or: npm audit --json +``` + +CI uses a wrapper script that: +1. Runs `npm audit --json`. +2. Exits `0` if the set of **critical** and **high** findings is empty. +3. Exits `1` (blocking) otherwise, printing the filtered findings. + +## False positives / suppressions + +If a finding is a confirmed false positive or mitigated through another +control, document the rationale in the PR description. For Rust, +`cargo audit` supports per-advisory suppression via `.cargo/audit.toml`. +For npm, `npm audit` supports `auditignore` entries in `package.json` on +recent npm versions. Every suppression must carry an expiry date. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73410dac..aa7707ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,47 +2,355 @@ name: CI on: pull_request: + push: + branches: + - main + - develop + +env: + CARGO_TERM_COLOR: always + NODE_VERSION: "18" + RUST_TOOLCHAIN: stable jobs: + # -------------------------------------------------------------------------- + # Frontend (dashboard): reproducible install → lint → typecheck → test + # -------------------------------------------------------------------------- frontend: - name: Frontend (lint, typecheck, test) + name: Frontend (dashboard · lint · typecheck · test) runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard steps: - - uses: actions/checkout@v4 - - name: Setup Node + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: - node-version: 18 - cache: 'npm' - - name: Install dependencies - working-directory: dashboard - run: npm ci - - name: Run lint - working-directory: dashboard + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: dashboard/package-lock.json + + # Issue 2 · frozen-lockfile install — fails fast if package-lock.json + # has drifted from package.json or is missing committed entries. + - name: Install dependencies (locked) + run: | + echo "::group::npm ci (frozen-lockfile install)" + npm ci --no-audit --no-fund + echo "::endgroup::" + + - name: Lint (ESLint, zero warnings) run: npm run lint - - name: TypeScript check (build) - working-directory: dashboard + + - name: Typecheck & build (Vite) run: npm run build - - name: Run tests - working-directory: dashboard - run: npm test --silent + - name: Unit tests (Jest) + run: npm test --silent -- --ci + + # -------------------------------------------------------------------------- + # Listener service: reproducible install → test → build + # -------------------------------------------------------------------------- + listener: + name: Listener (test · build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: listener + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: listener/package-lock.json + + - name: Install dependencies (locked) + run: | + echo "::group::npm ci (frozen-lockfile install)" + npm ci --no-audit --no-fund + echo "::endgroup::" + + - name: Unit & integration tests (Jest) + run: npm test -- --ci + + - name: TypeScript build (tsc) + run: npm run build + + # -------------------------------------------------------------------------- + # Rust contracts: formatting → locked fetch → test suite + # -------------------------------------------------------------------------- rust: - name: Rust (fmt check, tests) + name: Rust (fmt · locked fetch · test suite) runs-on: ubuntu-latest + defaults: + run: + working-directory: contract steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain (${{ env.RUST_TOOLCHAIN }}) + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + profile: minimal + components: rustfmt + override: true + + - name: Cache cargo registry & build artifacts + uses: actions/cache@v4 + with: + path: | + contract/target + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ hashFiles('contract/Cargo.lock', 'contract/**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting (rustfmt) + run: cargo fmt --all -- --check + + # Issue 2 · cargo fetch --locked + # Aborts the build with a clear, actionable error if Cargo.lock is + # missing committed entries or has drifted from the workspace + # Cargo.toml(s). This is the Rust equivalent of `npm ci`. + - name: Fetch dependencies (locked — fail on Cargo.lock drift) + run: | + echo "::group::cargo fetch --locked" + cargo fetch --locked \ + || { + echo "::error title=Cargo.lock drift::Cargo.lock does not match the workspace Cargo.toml(s). Run \`cargo generate-lockfile\` or \`cargo update -p \` locally and commit the updated Cargo.lock." + exit 1 + } + echo "::endgroup::" + + - name: Test suite (workspace, all features, locked) + run: cargo test --workspace --all-features --locked --verbose + + # -------------------------------------------------------------------------- + # Issue 3 · Automated vulnerability scanning + # - cargo audit (RustSec advisory DB) + # - npm audit (GH advisory DB — dashboard + listener) + # Severity gating: only critical / high block the build. + # Moderate / low / info are reported to the log as informational. + # -------------------------------------------------------------------------- + security-scan: + name: Security scan (Rust + JS/TS) + runs-on: ubuntu-latest + needs: [frontend, listener, rust] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ----- Rust dependencies (for cargo-audit) ----- - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: - toolchain: stable + toolchain: ${{ env.RUST_TOOLCHAIN }} profile: minimal override: true - - name: Check formatting - working-directory: contract + + - name: Install cargo-audit + uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + + # ----- Node (for npm audit) ----- + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Install jq (severity filtering) run: | - rustup component add rustfmt || true - cargo fmt --all -- --check - - name: Run tests + sudo apt-get update -y + sudo apt-get install -y jq + + # ====================================================================== + # Rust: cargo audit + # ====================================================================== + - name: Rust audit (cargo-audit · RustSec) working-directory: contract - run: cargo test --workspace --all-features --verbose + id: rust_audit + run: | + echo "::group::cargo audit (RustSec advisory database)" + set +e + cargo audit --json --no-fetch --output rust-audit.json 2>rust-audit.err + AUDIT_EXIT=$? + set -e + # cargo-audit returns non-zero on any findings; severity is + # evaluated below via jq, so swallow the exit here. + echo "Raw cargo-audit exit code: $AUDIT_EXIT" + if [ ! -f rust-audit.json ]; then + # Fallback: advisory DB may be fresh & empty -> produce empty list. + echo '{"vulnerabilities":{"list":[]}}' > rust-audit.json + fi + echo "::endgroup::" + + echo "::group::Rust audit summary (all severities)" + jq -r ' + [.vulnerabilities.list[]?] | group_by(.severity) + | .[] | "\(.[0].severity // \"unknown\"): \(length)" + ' rust-audit.json + echo "::endgroup::" + + echo "::group::Rust audit: critical / high findings" + BLOCKING=$(jq -r ' + [.vulnerabilities.list[]? + | select(.severity == "critical" or .severity == "high")] + | length + ' rust-audit.json) + echo "blocking_count=$BLOCKING" >> "$GITHUB_OUTPUT" + if [ "$BLOCKING" -gt 0 ]; then + jq -C '.vulnerabilities.list[]? + | select(.severity == "critical" or .severity == "high")' \ + rust-audit.json || true + else + echo "(none — informational-only findings, if any, are printed above)" + fi + echo "::endgroup::" + + # ====================================================================== + # JavaScript/TypeScript: npm audit per package + # ====================================================================== + - name: JS/TS audit (dashboard · npm audit) + working-directory: dashboard + id: npm_audit_dashboard + run: | + echo "::group::npm audit (dashboard) — advisory DB" + set +e + npm audit --json > ../dashboard-audit.json 2>../dashboard-audit.err + AUDIT_EXIT=$? + set -e + echo "Raw npm audit exit code: $AUDIT_EXIT" + echo "::endgroup::" + + echo "::group::Dashboard audit summary (all severities)" + jq -r ' + .metadata.vulnerabilities as $v + | "critical: \($v.critical // 0) + high: \($v.high // 0) + moderate: \($v.moderate // 0) + low: \($v.low // 0) + info: \($v.info // 0)" + ' ../dashboard-audit.json + echo "::endgroup::" + + echo "::group::Dashboard audit: critical / high findings" + BLOCKING=$(jq -r ' + (.metadata.vulnerabilities.critical // 0) + + (.metadata.vulnerabilities.high // 0) + ' ../dashboard-audit.json) + echo "blocking_count=$BLOCKING" >> "$GITHUB_OUTPUT" + if [ "$BLOCKING" -gt 0 ]; then + jq -C '[.vulnerabilities[]? + | select(.severity == "critical" or .severity == "high")]' \ + ../dashboard-audit.json || true + else + echo "(none — informational-only findings, if any, are printed above)" + fi + echo "::endgroup::" + + - name: JS/TS audit (listener · npm audit) + working-directory: listener + id: npm_audit_listener + run: | + echo "::group::npm audit (listener) — advisory DB" + set +e + npm audit --json > ../listener-audit.json 2>../listener-audit.err + AUDIT_EXIT=$? + set -e + echo "Raw npm audit exit code: $AUDIT_EXIT" + echo "::endgroup::" + + echo "::group::Listener audit summary (all severities)" + jq -r ' + .metadata.vulnerabilities as $v + | "critical: \($v.critical // 0) + high: \($v.high // 0) + moderate: \($v.moderate // 0) + low: \($v.low // 0) + info: \($v.info // 0)" + ' ../listener-audit.json + echo "::endgroup::" + + echo "::group::Listener audit: critical / high findings" + BLOCKING=$(jq -r ' + (.metadata.vulnerabilities.critical // 0) + + (.metadata.vulnerabilities.high // 0) + ' ../listener-audit.json) + echo "blocking_count=$BLOCKING" >> "$GITHUB_OUTPUT" + if [ "$BLOCKING" -gt 0 ]; then + jq -C '[.vulnerabilities[]? + | select(.severity == "critical" or .severity == "high")]' \ + ../listener-audit.json || true + else + echo "(none — informational-only findings, if any, are printed above)" + fi + echo "::endgroup::" + + # ====================================================================== + # Blocking decision + # ====================================================================== + - name: Severity gating — block on critical / high + if: always() + env: + RUST_BLOCKING: ${{ steps.rust_audit.outputs.blocking_count }} + DASH_BLOCKING: ${{ steps.npm_audit_dashboard.outputs.blocking_count }} + LIST_BLOCKING: ${{ steps.npm_audit_listener.outputs.blocking_count }} + run: | + TOTAL=$(( RUST_BLOCKING + DASH_BLOCKING + LIST_BLOCKING )) + echo "Blocking vulnerability counts:" + echo " Rust (RustSec): ${RUST_BLOCKING}" + echo " Dashboard (npm): ${DASH_BLOCKING}" + echo " Listener (npm): ${LIST_BLOCKING}" + echo " -------------------------" + echo " Total critical/high: ${TOTAL}" + + if [ "$TOTAL" -gt 0 ]; then + echo "::error title=Blocking vulnerabilities detected::Found ${TOTAL} critical/high severity dependency vulnerabilities. See the per-scan log groups above for details. Patch before merge, or file a documented suppression per .github/SECURITY_SCANNING.md." + exit 1 + fi + echo "✅ No blocking vulnerabilities. Informational findings (moderate / low / info) are visible in the audit summary log groups above." + + # -------------------------------------------------------------------------- + # Issue 4 · Contract event documentation drift check + # Parses Rust #[contractevent] structs and diffs them against + # contract/contract_events_docs.json via scripts/check_events.ts. + # -------------------------------------------------------------------------- + event-docs-check: + name: Contract event docs ↔ implementation sync check + runs-on: ubuntu-latest + needs: [listener] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: listener/package-lock.json + + # listener already pins ts-node as a devDep; reuse its toolchain so we + # don't introduce a separate install just for this script. + - name: Install listener dependencies (provides ts-node) + working-directory: listener + run: npm ci --no-audit --no-fund + + - name: Validate contract events vs documentation + run: | + TS_NODE="$(pwd)/listener/node_modules/.bin/ts-node" + "$TS_NODE" \ + scripts/check_events.ts \ + --rust contract/contracts/hello-world/src/base/events.rs \ + --docs contract/contract_events_docs.json diff --git a/contract/contract_events_docs.json b/contract/contract_events_docs.json new file mode 100644 index 00000000..d4e92c67 --- /dev/null +++ b/contract/contract_events_docs.json @@ -0,0 +1,103 @@ +{ + "$schema": "./contract_events_docs.schema.json", + "sourceRustFile": "contracts/hello-world/src/base/events.rs", + "description": "Canonical list of events emitted by the AutoShare smart contract. This file is the source of truth for off-chain consumers (listener, dashboard) and is validated against the Rust implementation in CI via scripts/check_events.ts.", + "events": [ + { + "structName": "AutoshareCreated", + "eventSymbol": "autoshare_created", + "dataFormat": "single-value", + "category": "Group", + "fields": [ + { "name": "creator", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "id", "type": "BytesN<32>", "isTopic": false, "required": true } + ] + }, + { + "structName": "ContractPaused", + "eventSymbol": "contract_paused", + "dataFormat": "struct", + "category": "Admin", + "fields": [ + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true } + ] + }, + { + "structName": "ContractUnpaused", + "eventSymbol": "contract_unpaused", + "dataFormat": "struct", + "category": "Admin", + "fields": [ + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true } + ] + }, + { + "structName": "AutoshareUpdated", + "eventSymbol": "autoshare_updated", + "dataFormat": "single-value", + "category": "Group", + "fields": [ + { "name": "updater", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "id", "type": "BytesN<32>", "isTopic": false, "required": true } + ] + }, + { + "structName": "GroupDeactivated", + "eventSymbol": "group_deactivated", + "dataFormat": "single-value", + "category": "Group", + "fields": [ + { "name": "creator", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "id", "type": "BytesN<32>", "isTopic": false, "required": true } + ] + }, + { + "structName": "GroupActivated", + "eventSymbol": "group_activated", + "dataFormat": "single-value", + "category": "Group", + "fields": [ + { "name": "creator", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "id", "type": "BytesN<32>", "isTopic": false, "required": true } + ] + }, + { + "structName": "AdminTransferred", + "eventSymbol": "admin_transferred", + "dataFormat": "single-value", + "category": "Admin", + "fields": [ + { "name": "old_admin", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "new_admin", "type": "Address", "isTopic": false, "required": true } + ] + }, + { + "structName": "Withdrawal", + "eventSymbol": "withdrawal", + "dataFormat": "single-value", + "category": "Financial", + "fields": [ + { "name": "token", "type": "Address", "isTopic": true, "required": true }, + { "name": "recipient", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "amount", "type": "i128", "isTopic": false, "required": true } + ] + }, + { + "structName": "AuthorizationFailure", + "eventSymbol": "authorization_failure", + "dataFormat": "single-value", + "category": "Admin", + "fields": [ + { "name": "caller", "type": "Address", "isTopic": true, "required": true }, + { "name": "category", "type": "NotificationCategory", "isTopic": true, "required": true }, + { "name": "action", "type": "String", "isTopic": false, "required": true } + ] + } + ] +} diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 6742f3c5..75602503 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -265,4 +265,7 @@ mod tests { #[path = "../tests/notification_test.rs"] mod notification_test; + + #[path = "../tests/state_transitions.rs"] + mod state_transitions; } diff --git a/contract/contracts/hello-world/src/tests/state_transitions.rs b/contract/contracts/hello-world/src/tests/state_transitions.rs new file mode 100644 index 00000000..124316bd --- /dev/null +++ b/contract/contracts/hello-world/src/tests/state_transitions.rs @@ -0,0 +1,486 @@ +#![allow(unused_variables)] +#![allow(unused_imports)] + +use crate::base::events::NotificationCategory; +use crate::mock_token::{MockToken, MockTokenClient}; +use crate::{AutoShareContract, AutoShareContractClient}; +use soroban_sdk::{ + testutils::Address as _, testutils::Events, Address, BytesN, Env, IntoVal, String, Symbol, + TryFromVal, Val, Vec, +}; + +fn deploy_mock_token(env: &Env, name: &String, symbol: &String) -> (Address, MockTokenClient<'_>) { + let contract_id = env.register(MockToken, ()); + let client = MockTokenClient::new(env, &contract_id); + let admin = Address::generate(env); + client.initialize(&admin, &7, name, symbol); + (contract_id, client) +} + +fn setup_basic_env() -> (Env, AutoShareContractClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(AutoShareContract, ()); + let client = AutoShareContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let user = Address::generate(&env); + (env, client, admin, user) +} + +fn setup_with_token( + env: &Env, + client: &AutoShareContractClient, + admin: &Address, +) -> (Address, MockTokenClient<'_>) { + let (token_address, token_client) = deploy_mock_token( + env, + &String::from_str(env, "Test Token"), + &String::from_str(env, "TEST"), + ); + client.add_supported_token(&token_address, admin); + (token_address, token_client) +} + +fn create_test_group( + env: &Env, + client: &AutoShareContractClient, + creator: &Address, + token: &Address, + token_client: &MockTokenClient, +) -> BytesN<32> { + let id = BytesN::from_array(env, &[1u8; 32]); + let name = String::from_str(env, "Test Group"); + token_client.mint(creator, &10000000); + client.create(&id, &name, creator, &100u32, token); + id +} + +fn count_events(env: &Env, event_name: &str) -> usize { + let target = Symbol::new(env, event_name); + env.events() + .all() + .iter() + .filter(|(_addr, topics, _data)| { + if topics.is_empty() { + return false; + } + let first = topics.get(0).unwrap(); + Symbol::try_from_val(env, &first) + .map(|s| s == target) + .unwrap_or(false) + }) + .count() +} + +// --------------------------------------------------------------------------- +// 1. Initial State Tests +// --------------------------------------------------------------------------- + +#[test] +fn test_initial_state_before_admin_initialization() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(AutoShareContract, ()); + let client = AutoShareContractClient::new(&env, &contract_id); + + assert!( + !client.get_paused_status(), + "Contract should be active (not paused) immediately after deployment" + ); +} + +#[test] +fn test_initial_state_after_admin_initialization() { + let (env, client, admin, _user) = setup_basic_env(); + + assert!( + !client.get_paused_status(), + "Contract should still be active after admin initialization" + ); + + client.initialize_admin(&admin); + + assert!( + !client.get_paused_status(), + "Contract must start unpaused (active) after initialize_admin" + ); +} + +// --------------------------------------------------------------------------- +// 2. Pause Transition Tests +// --------------------------------------------------------------------------- + +#[test] +fn test_pause_transition_by_admin() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + assert!( + !client.get_paused_status(), + "Precondition: contract must be active before pause" + ); + + client.pause(&admin); + + assert!( + client.get_paused_status(), + "Contract must be paused immediately after admin calls pause" + ); +} + +#[test] +fn test_pause_emits_contract_paused_event() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + + assert_eq!( + count_events(&env, "contract_paused"), + 1, + "Exactly one contract_paused event must be emitted" + ); +} + +#[test] +fn test_pause_event_carries_admin_category() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + + let events = env.events().all(); + let last = events.last().expect("at least one event"); + let (_addr, topics, _data) = last; + let last_topic = topics.last().expect("event has trailing category topic"); + let category = + NotificationCategory::try_from_val(&env, &last_topic).expect("category decodes"); + assert_eq!(category, NotificationCategory::Admin); +} + +#[test] +#[should_panic] +fn test_pause_transition_rejects_non_admin() { + let (env, client, admin, user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&user); +} + +#[test] +#[should_panic] +fn test_pause_transition_rejects_double_pause() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + client.pause(&admin); +} + +// --------------------------------------------------------------------------- +// 3. Unpause Transition Tests +// --------------------------------------------------------------------------- + +#[test] +fn test_unpause_transition_by_admin() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + assert!( + client.get_paused_status(), + "Precondition: contract must be paused before unpause" + ); + + client.unpause(&admin); + + assert!( + !client.get_paused_status(), + "Contract must be active immediately after admin calls unpause" + ); +} + +#[test] +fn test_unpause_emits_contract_unpaused_event() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + client.unpause(&admin); + + assert_eq!( + count_events(&env, "contract_unpaused"), + 1, + "Exactly one contract_unpaused event must be emitted" + ); +} + +#[test] +fn test_unpause_event_carries_admin_category() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + client.unpause(&admin); + + let events = env.events().all(); + let last = events.last().expect("at least one event"); + let (_addr, topics, _data) = last; + let last_topic = topics.last().expect("event has trailing category topic"); + let category = + NotificationCategory::try_from_val(&env, &last_topic).expect("category decodes"); + assert_eq!(category, NotificationCategory::Admin); +} + +#[test] +#[should_panic] +fn test_unpause_transition_rejects_non_admin() { + let (env, client, admin, user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.pause(&admin); + client.unpause(&user); +} + +#[test] +#[should_panic] +fn test_unpause_transition_rejects_unpause_when_active() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + client.unpause(&admin); +} + +// --------------------------------------------------------------------------- +// 4. Restricted Operations While Paused +// --------------------------------------------------------------------------- + +#[test] +#[should_panic] +fn test_create_group_blocked_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + token_client.mint(&creator, &10000000); + client.pause(&admin); + + let id = BytesN::from_array(&env, &[2u8; 32]); + let name = String::from_str(&env, "Should Fail"); + client.create(&id, &name, &creator, &100u32, &token); +} + +#[test] +#[should_panic] +fn test_update_members_blocked_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + let id = create_test_group(&env, &client, &creator, &token, &token_client); + client.pause(&admin); + + let mut members = Vec::new(&env); + members.push_back(crate::base::types::GroupMember { + address: Address::generate(&env), + percentage: 100, + }); + client.update_members(&id, &creator, &members); +} + +#[test] +#[should_panic] +fn test_add_group_member_blocked_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + let id = create_test_group(&env, &client, &creator, &token, &token_client); + client.pause(&admin); + + let new_member = Address::generate(&env); + client.add_group_member(&id, &creator, &new_member, &50u32); +} + +#[test] +#[should_panic] +fn test_deactivate_group_blocked_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + let id = create_test_group(&env, &client, &creator, &token, &token_client); + client.pause(&admin); + + client.deactivate_group(&id, &creator); +} + +#[test] +#[should_panic] +fn test_activate_group_blocked_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + let id = create_test_group(&env, &client, &creator, &token, &token_client); + client.deactivate_group(&id, &creator); + client.pause(&admin); + + client.activate_group(&id, &creator); +} + +#[test] +#[should_panic] +fn test_topup_subscription_blocked_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + let id = create_test_group(&env, &client, &creator, &token, &token_client); + client.pause(&admin); + + let payer = Address::generate(&env); + token_client.mint(&payer, &10000000); + client.topup_subscription(&id, &10u32, &token, &payer); +} + +#[test] +fn test_read_operations_succeed_when_paused() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + let id = create_test_group(&env, &client, &creator, &token, &token_client); + client.pause(&admin); + + let _ = client.get(&id); + let _ = client.get_all_groups(); + let _ = client.get_groups_by_creator(&creator); + let _ = client.get_group_members(&id); + let _ = client.is_group_member(&id, &creator); + let _ = client.get_paused_status(); + let _ = client.get_admin(); + let _ = client.get_supported_tokens(); + let _ = client.is_token_supported(&token); + let _ = client.get_usage_fee(); + let _ = client.get_remaining_usages(&id); + let _ = client.get_total_usages_paid(&id); + let _ = client.get_user_payment_history(&creator); + let _ = client.get_group_payment_history(&id); + let _ = client.is_group_active(&id); + let _ = client.get_contract_balance(&token); + let _ = client.version(); +} + +// --------------------------------------------------------------------------- +// 5. Repeated State Transitions +// --------------------------------------------------------------------------- + +#[test] +fn test_repeated_pause_unpause_cycles() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + const CYCLES: u32 = 3; + for cycle in 1..=CYCLES { + assert!( + !client.get_paused_status(), + "Cycle {} start: contract must be active", + cycle + ); + + client.pause(&admin); + assert!( + client.get_paused_status(), + "Cycle {} after pause: contract must be paused", + cycle + ); + + client.unpause(&admin); + assert!( + !client.get_paused_status(), + "Cycle {} after unpause: contract must be active", + cycle + ); + } +} + +#[test] +fn test_repeated_pause_unpause_event_count() { + let (env, client, admin, _user) = setup_basic_env(); + client.initialize_admin(&admin); + + const CYCLES: u32 = 5; + for _ in 1..=CYCLES { + client.pause(&admin); + client.unpause(&admin); + } + + assert_eq!( + count_events(&env, "contract_paused"), + CYCLES as usize, + "Expected {} contract_paused events across {} cycles", + CYCLES, + CYCLES + ); + assert_eq!( + count_events(&env, "contract_unpaused"), + CYCLES as usize, + "Expected {} contract_unpaused events across {} cycles", + CYCLES, + CYCLES + ); +} + +#[test] +fn test_operations_alternate_correctly_across_transitions() { + let (env, client, admin, creator) = setup_basic_env(); + client.initialize_admin(&admin); + let (token, token_client) = setup_with_token(&env, &client, &admin); + + client.pause(&admin); + client.unpause(&admin); + + let id1 = BytesN::from_array(&env, &[0x11u8; 32]); + token_client.mint(&creator, &10000000); + client.create( + &id1, + &String::from_str(&env, "Group 1"), + &creator, + &100u32, + &token, + ); + + client.pause(&admin); + let id_blocked = BytesN::from_array(&env, &[0x22u8; 32]); + let result = std::panic::catch_unwind(|| { + client.create( + &id_blocked, + &String::from_str(&env, "Blocked"), + &creator, + &50u32, + &token, + ); + }); + assert!(result.is_err(), "Create must fail while paused"); + + client.unpause(&admin); + let id2 = BytesN::from_array(&env, &[0x33u8; 32]); + client.create( + &id2, + &String::from_str(&env, "Group 2"), + &creator, + &50u32, + &token, + ); + + client.pause(&admin); + let mut members = Vec::new(&env); + members.push_back(crate::base::types::GroupMember { + address: Address::generate(&env), + percentage: 100, + }); + let update_result = std::panic::catch_unwind(|| { + client.update_members(&id1, &creator, &members.clone()); + }); + assert!(update_result.is_err(), "Update members must fail while paused"); + + client.unpause(&admin); + client.update_members(&id1, &creator, &members); + + assert_eq!(client.get_all_groups().len(), 2); + assert!(!client.get_paused_status()); +} diff --git a/scripts/check_events.ts b/scripts/check_events.ts new file mode 100644 index 00000000..25a2041e --- /dev/null +++ b/scripts/check_events.ts @@ -0,0 +1,368 @@ +#!/usr/bin/env ts-node +/** + * check_events.ts + * ------------------- + * Lightweight parser & validator that reconciles Rust `#[contractevent]` + * struct definitions against a JSON documentation manifest. + * + * Usage: + * ts-node scripts/check_events.ts \ + * --rust contract/contracts/hello-world/src/base/events.rs \ + * --docs contract/contract_events_docs.json + * + * Exit codes: + * 0 - documentation and implementation are in sync + * 1 - drift detected (undocumented event, missing/extra field, etc.) + * 2 - CLI / IO error + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ParsedField { + name: string; + type: string; + isTopic: boolean; +} + +interface ParsedEvent { + structName: string; + eventSymbol: string; + dataFormat: 'struct' | 'single-value'; + fields: ParsedField[]; +} + +interface DocField { + name: string; + type: string; + isTopic: boolean; + required?: boolean; +} + +interface DocEvent { + structName: string; + eventSymbol: string; + dataFormat?: 'struct' | 'single-value'; + category?: string; + fields: DocField[]; +} + +interface DocsManifest { + sourceRustFile?: string; + description?: string; + events: DocEvent[]; +} + +type Severity = 'error' | 'warning' | 'info'; +interface Finding { + severity: Severity; + message: string; + event?: string; +} + +// --------------------------------------------------------------------------- +// CLI parsing +// --------------------------------------------------------------------------- + +function parseArgs(argv: string[]): { rustPath: string; docsPath: string } { + let rustPath: string | undefined; + let docsPath: string | undefined; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--rust' && argv[i + 1]) { + rustPath = argv[++i]; + } else if (a === '--docs' && argv[i + 1]) { + docsPath = argv[++i]; + } else if (a === '--help' || a === '-h') { + printUsage(); + process.exit(0); + } + } + if (!rustPath || !docsPath) { + printUsage(); + process.exit(2); + } + return { rustPath, docsPath }; +} + +function printUsage(): void { + const script = path.basename(process.argv[1] ?? 'check_events.ts'); + process.stderr.write( + `Usage: ts-node ${script} --rust --docs \n` + ); +} + +// --------------------------------------------------------------------------- +// Rust source parser (regex-based, sufficient for the Soroban #[contractevent] +// shape used by this repository). +// --------------------------------------------------------------------------- + +const CONTRACT_EVENT_ATTR_RE = + /#\[contractevent(\s*\(\s*data_format\s*=\s*"(?single-value|struct)"\s*\))?\]/; +const STRUCT_RE = /^\s*pub\s+struct\s+(?[A-Za-z0-9_]+)\s*\{/; +const FIELD_ATTR_TOPIC_RE = /#\[topic\]/; +const FIELD_RE = /^\s*pub\s+(?[A-Za-z0-9_]+)\s*:\s*(?[^,]+?),?\s*$/; + +/** + * Converts a PascalCase struct identifier into the snake_case event symbol + * that `soroban-sdk`'s `#[contractevent]` macro derives as the first topic. + * ContractPaused -> contract_paused + * AutoshareCreated -> autoshare_created + */ +function structNameToEventSymbol(structName: string): string { + return structName + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); +} + +function trimTrailingComma(s: string): string { + const t = s.trim(); + return t.endsWith(',') ? t.slice(0, -1).trim() : t; +} + +function parseRustEvents(rustSource: string): ParsedEvent[] { + const lines = rustSource.split(/\r?\n/); + const events: ParsedEvent[] = []; + + let i = 0; + while (i < lines.length) { + const attrMatch = lines[i].match(CONTRACT_EVENT_ATTR_RE); + if (!attrMatch) { + i++; + continue; + } + + // Skip any intermediate doc comments / attributes between #[contractevent] + // and the struct declaration (e.g. #[derive(...)] lines). + let structLineIdx = -1; + for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) { + if (STRUCT_RE.test(lines[j])) { + structLineIdx = j; + break; + } + } + if (structLineIdx === -1) { + i++; + continue; + } + + const structName = lines[structLineIdx].match(STRUCT_RE)!.groups!.name; + const dataFormat = (attrMatch.groups?.df as 'single-value' | 'struct') ?? 'struct'; + + // Collect fields until the matching closing brace of the struct body. + const fields: ParsedField[] = []; + let pendingTopic = false; + let braceDepth = 0; + for (let k = structLineIdx; k < lines.length; k++) { + const line = lines[k]; + const open = (line.match(/\{/g) ?? []).length; + const close = (line.match(/\}/g) ?? []).length; + braceDepth += open - close; + + if (k === structLineIdx) { + if (braceDepth === 0) break; + continue; + } + + if (FIELD_ATTR_TOPIC_RE.test(line)) { + pendingTopic = true; + } + + const fieldMatch = line.match(FIELD_RE); + if (fieldMatch && fieldMatch.groups) { + const { name, type } = fieldMatch.groups; + fields.push({ + name, + type: trimTrailingComma(type), + isTopic: pendingTopic, + }); + pendingTopic = false; + } + + if (braceDepth === 0) break; + } + + events.push({ + structName, + eventSymbol: structNameToEventSymbol(structName), + dataFormat, + fields, + }); + + i = structLineIdx + 1; + } + + return events; +} + +// --------------------------------------------------------------------------- +// Diff engine +// --------------------------------------------------------------------------- + +function compare( + parsed: ParsedEvent[], + docs: DocsManifest +): Finding[] { + const findings: Finding[] = []; + const docByName = new Map(docs.events.map((e) => [e.structName, e])); + const parsedByName = new Map(parsed.map((e) => [e.structName, e])); + + // Undocumented events (Rust has something the docs don't list) + for (const p of parsed) { + if (!docByName.has(p.structName)) { + findings.push({ + severity: 'error', + event: p.structName, + message: `Event struct '${p.structName}' is emitted by the contract but is MISSING from contract_events_docs.json. Add it to the docs to fix this drift.`, + }); + } + } + + // Documented-but-not-implemented events (docs have something Rust doesn't emit) + for (const d of docs.events) { + if (!parsedByName.has(d.structName)) { + findings.push({ + severity: 'error', + event: d.structName, + message: `Event '${d.structName}' is listed in contract_events_docs.json but no matching #[contractevent] struct exists in the Rust source. Remove the stale doc entry or implement the event.`, + }); + } + } + + // Field-level diff on events that exist in both. + for (const p of parsed) { + const d = docByName.get(p.structName); + if (!d) continue; + + if (d.eventSymbol !== p.eventSymbol) { + findings.push({ + severity: 'error', + event: p.structName, + message: `Event '${p.structName}' has documented eventSymbol='${d.eventSymbol}' but the Rust implementation derives '${p.eventSymbol}'. Update one of them to match.`, + }); + } + + const documentedFormat = d.dataFormat ?? 'struct'; + if (documentedFormat !== p.dataFormat) { + findings.push({ + severity: 'warning', + event: p.structName, + message: `Event '${p.structName}' data_format mismatch: docs='${documentedFormat}' vs Rust='${p.dataFormat}'. This changes the wire layout.`, + }); + } + + const docFieldMap = new Map(d.fields.map((f) => [f.name, f])); + const parsedFieldMap = new Map(p.fields.map((f) => [f.name, f])); + + for (const pf of p.fields) { + const df = docFieldMap.get(pf.name); + if (!df) { + findings.push({ + severity: 'error', + event: p.structName, + message: `Field '${pf.name}' (type '${pf.type}', topic=${pf.isTopic}) exists in Rust event '${p.structName}' but is NOT documented. Add it to the docs manifest.`, + }); + continue; + } + if (df.type !== pf.type) { + findings.push({ + severity: 'error', + event: p.structName, + message: `Field '${pf.name}' in event '${p.structName}' type mismatch: docs='${df.type}' vs Rust='${pf.type}'.`, + }); + } + if (!!df.isTopic !== pf.isTopic) { + findings.push({ + severity: 'error', + event: p.structName, + message: `Field '${pf.name}' in event '${p.structName}' isTopic mismatch: docs=${df.isTopic} vs Rust=${pf.isTopic}. Topic ordering changes subscription semantics for off-chain listeners.`, + }); + } + } + + for (const df of d.fields) { + if (!parsedFieldMap.has(df.name)) { + findings.push({ + severity: df.required === false ? 'info' : 'error', + event: p.structName, + message: `Documented field '${df.name}' (type '${df.type}') is MISSING from Rust event '${p.structName}'. Add the field to the struct or remove it from the docs.`, + }); + } + } + } + + return findings; +} + +// --------------------------------------------------------------------------- +// Report & exit +// --------------------------------------------------------------------------- + +function report(findings: Finding[]): void { + if (findings.length === 0) { + process.stdout.write('✅ contract event documentation is in sync with implementation.\n'); + return; + } + process.stdout.write( + `⚠️ ${findings.length} contract event documentation finding(s):\n\n` + ); + const bySeverity: Record = { error: [], warning: [], info: [] }; + for (const f of findings) bySeverity[f.severity].push(f); + + for (const sev of ['error', 'warning', 'info'] as Severity[]) { + const list = bySeverity[sev]; + if (list.length === 0) continue; + const tag = sev === 'error' ? '❌ ERROR' : sev === 'warning' ? '⚠️ WARN ' : 'ℹ️ INFO '; + for (const f of list) { + const ctx = f.event ? `[${f.event}] ` : ''; + process.stdout.write(` ${tag} ${ctx}${f.message}\n`); + } + process.stdout.write('\n'); + } +} + +function main(): void { + const { rustPath, docsPath } = parseArgs(process.argv); + + let rustSource: string; + let docsJson: string; + try { + rustSource = fs.readFileSync(rustPath, 'utf8'); + } catch (e) { + process.stderr.write(`Failed to read Rust source '${rustPath}': ${(e as Error).message}\n`); + process.exit(2); + } + try { + docsJson = fs.readFileSync(docsPath, 'utf8'); + } catch (e) { + process.stderr.write(`Failed to read docs manifest '${docsPath}': ${(e as Error).message}\n`); + process.exit(2); + } + + let docs: DocsManifest; + try { + docs = JSON.parse(docsJson); + } catch (e) { + process.stderr.write(`Failed to parse docs JSON '${docsPath}': ${(e as Error).message}\n`); + process.exit(2); + } + + if (!docs.events || !Array.isArray(docs.events)) { + process.stderr.write('Docs manifest must contain an "events" array.\n'); + process.exit(2); + } + + const parsed = parseRustEvents(rustSource); + const findings = compare(parsed, docs); + report(findings); + + const errors = findings.filter((f) => f.severity === 'error').length; + process.exit(errors > 0 ? 1 : 0); +} + +main();