diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..502d49c7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,335 @@ +name: NotifyChain CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: short + NODE_VERSION: "22" + +jobs: + # ========================================================================== + # Dependency Installation Reproducibility + Lockfile Drift Detection + # ========================================================================== + dependency-integrity: + name: Dependency Integrity (Rust + Node) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + component: + - name: "Rust (contract/)" + working-directory: "contract" + kind: "rust" + - name: "Node (dashboard/)" + working-directory: "dashboard" + kind: "node" + - name: "Node (listener/)" + working-directory: "listener" + kind: "node" + - name: "Node (frontend/)" + working-directory: "frontend" + kind: "node" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + if: matrix.component.kind == 'rust' + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache Rust dependencies + if: matrix.component.kind == 'rust' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contract/target + key: ${{ runner.os }}-cargo-${{ hashFiles('contract/Cargo.lock', 'contract/**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install Node.js + if: matrix.component.kind == 'node' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Cache Node dependencies + if: matrix.component.kind == 'node' + uses: actions/cache@v4 + with: + path: ${{ matrix.component.working-directory }}/node_modules + key: ${{ runner.os }}-node-${{ matrix.component.working-directory }}-${{ hashFiles(format('{0}/package-lock.json', matrix.component.working-directory)) }} + restore-keys: | + ${{ runner.os }}-node-${{ matrix.component.working-directory }}- + + # ------------------------------------------------------------------ + # Locked-mode installation + # ------------------------------------------------------------------ + - name: "Rust: build with --locked (enforce Cargo.lock)" + if: matrix.component.kind == 'rust' + working-directory: ${{ matrix.component.working-directory }} + run: cargo build --locked --release + + - name: "Node: install with npm ci (enforce package-lock.json)" + if: matrix.component.kind == 'node' + working-directory: ${{ matrix.component.working-directory }} + run: npm ci + + # ------------------------------------------------------------------ + # Post-install lockfile drift detection + # ------------------------------------------------------------------ + - name: Detect lockfile drift after install + id: drift + shell: bash + run: | + set -eu + CHANGES="$(git status --porcelain)" + if [ -n "$CHANGES" ]; then + echo "========================================" + echo "LOCKFILE DRIFT DETECTED" + echo "========================================" + echo "git status --porcelain output:" + echo "$CHANGES" + echo "" + echo "The following working tree files changed during" + echo "locked-mode installation. This means the committed lockfile" + echo "is out of date with the declared manifest dependencies." + echo "" + echo "----------------------------------------" + echo "HOW TO FIX (run locally and commit):" + echo "----------------------------------------" + if [ "${{ matrix.component.kind }}" = "rust" ]; then + echo " cd ${{ matrix.component.working-directory }}" + echo " cargo update # regenerates Cargo.lock from Cargo.toml" + echo " # or run the build that caused the drift then diff" + else + echo " cd ${{ matrix.component.working-directory }}" + echo " rm -rf node_modules package-lock.json" + echo " npm install # regenerates package-lock.json from package.json" + fi + echo "" + echo "Then: git add && git commit -m \"chore(deps): refresh lockfile\"" + echo "========================================" + exit 1 + else + echo "No lockfile drift detected — install is reproducible." + fi + + # ========================================================================== + # Dependency Vulnerability Scanning + # ========================================================================== + vulnerability-scan: + name: Vulnerability Scan (JS + Rust) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + component: + - name: "Rust (contract/)" + working-directory: "contract" + kind: "rust" + - name: "Node (dashboard/)" + working-directory: "dashboard" + kind: "node" + - name: "Node (listener/)" + working-directory: "listener" + kind: "node" + - name: "Node (frontend/)" + working-directory: "frontend" + kind: "node" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + cargo-audit + if: matrix.component.kind == 'rust' + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo-audit + if: matrix.component.kind == 'rust' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/bin/cargo-audit + key: ${{ runner.os }}-cargo-audit-v2 + + - name: Install cargo-audit + if: matrix.component.kind == 'rust' + run: | + if ! command -v cargo-audit >/dev/null 2>&1; then + cargo install cargo-audit --locked + fi + + - name: Install Node.js + if: matrix.component.kind == 'node' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: "Node: install with npm ci (enforce package-lock.json)" + if: matrix.component.kind == 'node' + working-directory: ${{ matrix.component.working-directory }} + run: npm ci + + # ------------------------------------------------------------------ + # Rust audit (block on High/Critical, warn on Low/Medium) + # ------------------------------------------------------------------ + - name: "Rust: cargo audit (informational, all severities)" + if: matrix.component.kind == 'rust' + working-directory: ${{ matrix.component.working-directory }} + continue-on-error: true + run: | + echo "=== cargo audit (informational: all severities) ===" + cargo audit || true + + - name: "Rust: cargo audit (blocking: deny warnings = High/Critical)" + if: matrix.component.kind == 'rust' + working-directory: ${{ matrix.component.working-directory }} + run: | + echo "=== cargo audit (blocking: High + Critical only) ===" + # --deny-warnings exits non-zero for any 'warning' (High/Critical) + # Informational/Low (unmaintained crates without known vulns) pass + cargo audit --deny warnings + + # ------------------------------------------------------------------ + # npm audit (block on High/Critical, warn on Low/Medium) + # ------------------------------------------------------------------ + - name: "Node: npm audit (informational, all severities)" + if: matrix.component.kind == 'node' + working-directory: ${{ matrix.component.working-directory }} + continue-on-error: true + run: | + echo "=== npm audit (informational: all severities) ===" + npm audit --json > audit-report.json || true + node -e " + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync('audit-report.json', 'utf8')); + const severity = report.metadata && report.metadata.vulnerabilities || {}; + console.log('Summary:', JSON.stringify(severity, null, 2)); + " 2>/dev/null || true + npm audit || true + + - name: "Node: npm audit (blocking: audit-level = high)" + if: matrix.component.kind == 'node' + working-directory: ${{ matrix.component.working-directory }} + run: | + echo "=== npm audit (blocking: high + critical only) ===" + echo "Low / Medium vulnerabilities are informational only and do not block CI." + echo "If this step fails, fix the high/critical advisories listed above." + npm audit --audit-level=high + + # ========================================================================== + # Contract: Rust unit + integration tests (Soroban) + # ========================================================================== + contract-tests: + name: Smart Contract Tests + runs-on: ubuntu-latest + needs: dependency-integrity + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + contract/target + key: ${{ runner.os }}-cargo-tests-${{ hashFiles('contract/Cargo.lock', 'contract/**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run cargo build --locked + working-directory: contract + run: cargo build --locked + + - name: Run cargo test (pause + all contract tests) + working-directory: contract/contracts/hello-world + run: cargo test --locked -- --nocapture + + - name: cargo clippy (contract workspace) + working-directory: contract + run: cargo clippy --locked --all-targets -- -D warnings + + - name: cargo fmt --check + working-directory: contract + run: cargo fmt --all -- --check + + # ========================================================================== + # Event Documentation Drift Detection + # ========================================================================== + event-docs-drift: + name: Event Documentation Drift Check + runs-on: ubuntu-latest + needs: dependency-integrity + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install listener deps (provides ts-node + typescript) + working-directory: listener + run: npm ci + + - name: Run event documentation drift detector + working-directory: listener + run: npm run check:event-docs + + # ========================================================================== + # Node component tests + typechecks + # ========================================================================== + node-tests: + name: Node Component Tests (dashboard, listener, frontend) + runs-on: ubuntu-latest + needs: dependency-integrity + strategy: + fail-fast: false + matrix: + include: + - component: "dashboard" + steps: "npm run lint && npm run test" + - component: "listener" + steps: "npm run lint && npm run test" + - component: "frontend" + steps: "npm run lint && npm run test" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install with npm ci + working-directory: ${{ matrix.component }} + run: npm ci + + - name: Run lint + test + working-directory: ${{ matrix.component }} + run: | + set -e + ${{ matrix.steps }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b92a7f6c..3ccea63e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -307,6 +307,27 @@ The PR template will prompt you for: --- +## Security and Vulnerability Management + +Before modifying dependencies, manifests, or lockfiles: + +1. Run the vulnerability scanners locally (they also run automatically in CI): + - **Rust**: `cd contract && cargo audit --deny warnings` + - **Node**: `cd && npm audit --audit-level=high` +2. Ensure installs are reproducible: `npm ci` (Node) and `cargo build --locked` + (Rust). Never commit a modified `package.json` / `Cargo.toml` without the + matching refreshed lockfile. +3. Verify the on-chain event reference is not stale: + ```bash + cd listener + npm run check:event-docs + ``` + +Full policy, step-by-step remediation playbook, and contacts for responsible +disclosure are documented in [`docs/security.md`](docs/security.md). + +--- + ## Releasing NotifyChain Maintainers preparing a tagged release should follow the steps in diff --git a/contract/contracts/hello-world/src/tests/pause_test.rs b/contract/contracts/hello-world/src/tests/pause_test.rs index a973c193..c1c4e95f 100644 --- a/contract/contracts/hello-world/src/tests/pause_test.rs +++ b/contract/contracts/hello-world/src/tests/pause_test.rs @@ -1,6 +1,7 @@ #![allow(unused_variables)] #![allow(unused_imports)] +use crate::base::errors::Error; use crate::base::types::GroupMember; use crate::{AutoShareContract, AutoShareContractClient}; use soroban_sdk::testutils::{Address as _, Events}; @@ -33,6 +34,22 @@ fn latest_event_topics(env: &Env, event_name: &str) -> Option **Audience**: maintainers, contributors, and security reviewers. +> +> This document defines the automated security scanning infrastructure for the +> NotifyChain repository, the severity policy applied in CI, and the step-by-step +> remediation playbook a contributor should follow when the pipeline flags a +> finding. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Tooling Matrix](#tooling-matrix) +3. [CI Severity Policy Rules](#ci-severity-policy-rules) +4. [Running Scans Locally](#running-scans-locally) +5. [Remediation Playbook](#remediation-playbook) +6. [Suppressing False Positives](#suppressing-false-positives) +7. [Reporting a Vulnerability](#reporting-a-vulnerability) +8. [Contacts and Escalation](#contacts-and-escalation) + +--- + +## Overview + +NotifyChain consists of four main components, each of which has automated +vulnerability coverage: + +| Component | Location | Language / Ecosystem | Scan Tool | +|-----------|----------|----------------------|-----------| +| Smart contracts | `contract/` | Rust (Soroban / wasm32) | `cargo audit` | +| Listener service | `listener/` | TypeScript / Node.js 22 | `npm audit` | +| Dashboard UI | `dashboard/` | TypeScript / React / Vite | `npm audit` | +| Legacy frontend | `frontend/` | TypeScript / React / Next | `npm audit` | + +All four scans run on every push to `main`/`master` and every pull request via +the `.github/workflows/ci.yml` workflow. They also run as part of release +candidate validation before tags are cut. + +--- + +## Tooling Matrix + +### 1. `cargo audit` (Rust / Smart Contracts) + +- **Package**: [`cargo-audit`](https://github.com/RustSec/cargo-audit) +- **Advisory database**: [RustSec Advisory DB](https://rustsec.org/) +- **Input files**: `contract/Cargo.lock` (+ `Cargo.toml` manifests) +- **CI job**: `vulnerability-scan` matrix → `kind: rust` +- **Installed locally**: + ```bash + cargo install cargo-audit --locked + ``` + +#### What it checks +- Known security vulnerabilities in Rust crates pulled from crates.io. +- Unmaintained (end-of-life) crates. +- Yanked crate versions. +- Crates that the RustSec database has flagged with soundness / unsoundness + warnings. + +#### Runs in CI with two configurations + +| CI step | Flags | Effect | +|---------|-------|--------| +| Informational pass | `cargo audit` (no flags) | Prints every finding at every severity. **Never** fails the build. | +| Blocking pass | `cargo audit --deny warnings` | Fails the build for `warning` level (maps to High / Critical advisories). Low / informational findings pass. | + +--- + +### 2. `npm audit` (TypeScript / Node components) + +- **Built into**: `npm` (ships with every supported Node.js release) +- **Advisory database**: GitHub Advisory Database (used by `npm`) +- **Input files**: `package-lock.json` + `package.json` per component +- **CI job**: `vulnerability-scan` matrix → `kind: node` + +#### What it checks +- Known vulnerabilities in transitive JS/TS dependency trees (prod + dev). +- Malware / compromised packages flagged in the advisory database. +- Prototype pollution, RCE, XSS, SQL injection, etc. from npm. + +#### Runs in CI with two configurations + +| CI step | Flags | Effect | +|---------|-------|--------| +| Informational pass | `npm audit` (writes JSON report) | Prints every finding at every severity. **Never** fails the build. | +| Blocking pass | `npm audit --audit-level=high` | Fails the build only when **high** or **critical** severity vulns are present. `low` / `moderate` are informational only. | + +--- + +## CI Severity Policy Rules + +These rules are the single source of truth for what blocks a PR or main-branch +build. They are intentionally conservative: err on the side of blocking on +high-impact findings while allowing routine informational maintenance to happen +on contributor schedules. + +| Finding / Severity | Blocking in CI? | Notes | +|--------------------|-----------------|-------| +| `cargo audit` **Critical** advisory | ✅ Yes via `--deny warnings` | Fix before merge. | +| `cargo audit` **High** advisory | ✅ Yes via `--deny warnings` | Fix before merge. | +| `cargo audit` **Medium / Low** advisory | ❌ No (informational) | Track as tech debt; fix in next maintenance window. | +| `cargo audit` Unmaintained crate | ❌ No (informational unless paired with a known CVE) | File a follow-up issue to migrate away. | +| `npm audit` **Critical** | ✅ Yes via `--audit-level=high` | Fix before merge. | +| `npm audit` **High** | ✅ Yes via `--audit-level=high` | Fix before merge. | +| `npm audit` **Moderate (Medium)** | ❌ No (informational) | Track as tech debt. | +| `npm audit` **Low** | ❌ No (informational) | Track as tech debt. | +| Lockfile drift (see below) | ✅ Yes | `git status --porcelain` must be clean after `npm ci` / `cargo build --locked`. | + +> **Rationale**: High and Critical findings are, by definition, remotely +> exploitable in realistic configurations. Anything lower is typically +> dependency-graph noise (devDependencies with no production execution path, +> prototype pollution that requires a code path we do not exercise, etc.) and +> is handled during the weekly maintenance rotation rather than the PR hot +> path. + +--- + +## Running Scans Locally + +Contributors should run these before pushing a branch that modifies +`Cargo.toml`, `Cargo.lock`, `package.json`, or `package-lock.json`. + +### Rust (smart contracts) + +```bash +cd contract +cargo audit # full report, all severities +cargo audit --deny warnings # replicate CI's blocking pass +``` + +Fix missing tool: + +```bash +cargo install cargo-audit --locked +``` + +### Node (listener, dashboard, frontend) + +```bash +cd listener # or dashboard/, or frontend/ +npm audit # full report, all severities +npm audit --audit-level=high # replicate CI's blocking pass +``` + +To see JSON output with every affected transitive dependency path: + +```bash +npm audit --json | jq '.' +``` + +--- + +## Remediation Playbook + +Use the decision tree below when CI fails with a vulnerability. + +### Step 1 — Understand the finding + +1. Open the failing CI run and navigate to the *blocking* step (the one that + exited non-zero): + - Rust: `Rust: cargo audit (blocking: deny warnings = High/Critical)` + - Node: `Node: npm audit (blocking: audit-level = high)` +2. Read the advisory ID (e.g. `RUSTSEC-2024-00xx`, `GHSA-xxxx-xxxx-xxxx`) + and open it in your browser: + - RustSec: + - GitHub Advisory: + +### Step 2 — Determine affected path + +**For Rust:** + +```bash +cd contract +cargo audit --message-format=json 2>/dev/null | jq '.vulnerabilities.list[] | {package, advisory, versions}' +``` + +Follow the dependency chain from the vulnerable crate back to NotifyChain's +direct `Cargo.toml` dependency. + +**For Node:** + +```bash +cd listener # or dashboard/, frontend/ +npm ls +``` + +This prints the full `direct-dep → intermediate → vulnerable` chain. + +### Step 3 — Pick remediation strategy + +| Strategy | When to use | Action | +|----------|-------------|--------| +| **Upgrade direct dep** | Vuln in a direct dep we control and the latest fixed version is API-compatible. | Bump in `package.json` / `Cargo.toml`; run `npm install` / `cargo build` locally; commit the updated lockfile. | +| **Upgrade transitive via lockfile resolution** | Vuln is deep in a tree and a newer patch version exists. | `npm update ` (Node) or `cargo update -p ` (Rust). Re-run scan to confirm it is gone. | +| **NPM overrides / Cargo patches** | Fixed version is incompatible with pinned semver constraints upstream. | Node: add an `overrides` block to `package.json`.
Rust: add a `[patch.crates-io]` section to `contract/Cargo.toml` temporarily. | +| **Remove or replace the dependency** | The vulnerable dep is dev-only, unused, or has a well-maintained fork. | Swap it out in the manifest; remove from `import`s / `use`s; re-run tests. | +| **False positive / does not apply to us** | Advisory requires a code path we do not ship (e.g. a dev server at runtime, or we call the crate in a provably safe way). | Follow the [Suppressing False Positives](#suppressing-false-positives) process. Do **not** silently merge. | + +### Step 4 — Verify locally and push + +1. Confirm the blocking scan **passes** on your machine. +2. Run the full test suite for the affected component. +3. Commit the manifest and lockfile changes together (never commit a changed + `package.json` without a matching `package-lock.json`, nor a `Cargo.toml` + without a matching `Cargo.lock`). +4. Open or update your PR. CI should now green-light the vulnerability step. + +--- + +## Suppressing False Positives + +Occasionally an advisory flags a pattern that does **not** apply to how +NotifyChain uses a dependency. In that case, file a **rationale PR** rather +than silently merging. + +### Rust — `cargo audit` + +Use an `audit.toml` ignore file at `contract/audit.toml`: + +```toml +[advisories] +# Example: replace with real advisory id and your justification. +ignore = [ + "RUSTSEC-202X-0000", # Advisory targets , which we do not compile: + # see . + # Suppression review due by YYYY-MM-DD. +] +``` + +### Node — `npm audit` + +Use the registry-level `.npmrc` or an explicit ignore list per component. +Because `npm audit` does not have a built-in advisory-ignore file, document +suppressions in this section alongside the advisory ID: + +| Advisory ID | Component | Date added | Justification link | Expires (review by) | +|-------------|-----------|------------|--------------------|---------------------| +| *example* | listener | 2025-01-01 | PR #xxx, commit xxxxxxx | 2025-04-01 | + +Expired suppressions must be re-evaluated by the reviewer listed in +[Contacts and Escalation](#contacts-and-escalation) or the PR will be rejected. + +--- + +## Reporting a Vulnerability + +For security issues that are **not** covered by the automated scans — +on-chain logic bugs, auth bypasses, disclosure of PII through the listener +API, etc. — please **do not open a public GitHub issue**. + +Instead follow responsible disclosure: + +1. Email `security@notifychain.example` (or the repository maintainers listed + on GitHub) with: + - A one-line summary in the subject line, + - A reproduction case (preferably a failing test or a step-by-step script), + - Affected versions (or commit hash). +2. Expect acknowledgement within 3 business days. +3. A maintainer will open a private security advisory on GitHub and invite you + to collaborate on a fix before any public disclosure. + +We follow a 90-day disclosure window, aligned with the industry standard. + +--- + +## Contacts and Escalation + +| Role | Scope | How to reach | +|------|-------|--------------| +| Security triage on-call | All findings escalated from CI | GitHub security advisory assignees | +| Rust / contract owner | `contract/` vulnerability remediation | Maintainers listed in `contract/Cargo.toml` author fields | +| Node / JS owner | `listener/`, `dashboard/`, `frontend/` vuln remediation | Component `package.json` authors field | +| Vulnerability suppression reviewer | Signs off on new entries in the ignore tables | Security triage on-call + a second maintainer (two-person rule) | + +--- + +## Reference Links + +- CI workflow definition: [`.github/workflows/ci.yml`](file:///c:/Users/USA/Documents/Osuocha/Notify-Chain/.github/workflows/ci.yml) +- Reproducible install / lockfile drift check job: `dependency-integrity` (same workflow file) +- Vulnerability scan job: `vulnerability-scan` (same workflow file) +- RustSec Advisory DB: +- GitHub Advisory Database (npm): +- Contributing guide: [CONTRIBUTING.md](file:///c:/Users/USA/Documents/Osuocha/Notify-Chain/CONTRIBUTING.md) diff --git a/listener/package.json b/listener/package.json index 4a5508e9..6533e346 100644 --- a/listener/package.json +++ b/listener/package.json @@ -15,7 +15,8 @@ "migrate": "ts-node src/scripts/migrate-db.ts", "migrate:templates": "ts-node src/scripts/migrate-templates.ts", "check-migrations": "ts-node src/scripts/check-migrations.ts", - "validate:batch": "ts-node src/utils/batch-validator.ts" + "validate:batch": "ts-node src/utils/batch-validator.ts", + "check:event-docs": "ts-node scripts/check-event-docs.ts" }, "keywords": [], "author": "", diff --git a/listener/scripts/check-event-docs.ts b/listener/scripts/check-event-docs.ts new file mode 100644 index 00000000..d8e07eba --- /dev/null +++ b/listener/scripts/check-event-docs.ts @@ -0,0 +1,551 @@ +#!/usr/bin/env ts-node +/** + * Contract Event Documentation Drift Detector + * ============================================ + * + * Lightweight check that parses every `#[contractevent]` struct from the + * NotifyChain smart-contract source code and compares the declared set of + * events, plus each event's required field names and types, against the + * human-written reference docs in `CONTRACT_EVENT_REFERENCE.md`. + * + * Exits with a non-zero status code the moment it detects any of: + * - An event that is documented but does not exist in the contract source. + * - An event that exists in the contract source but has no documentation. + * - A documented field whose name or type does not match the source struct. + * - A documented field that does not exist on the source struct. + * + * Usage + * ----- + * # From anywhere inside the repo: + * ts-node listener/scripts/check-event-docs.ts + * + * # Or via package.json script (preferred for CI): + * cd listener + * npm run check:event-docs + * + * The script auto-discovers the repo root by walking up from __dirname and + * therefore keeps working if you move the listener/ directory relative to + * contract/ and the markdown reference. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// Auto-discover the repo root. +// --------------------------------------------------------------------------- + +function findRepoRoot(start: string): string { + let current = path.resolve(start); + for (let i = 0; i < 16; i += 1) { + if ( + fs.existsSync(path.join(current, "CONTRACT_EVENT_REFERENCE.md")) && + fs.existsSync(path.join(current, "contract")) && + fs.existsSync(path.join(current, "AGENTS.md")) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + throw new Error( + "Could not locate repository root (looking for CONTRACT_EVENT_REFERENCE.md + contract/ + AGENTS.md).", + ); +} + +const REPO_ROOT = findRepoRoot(__dirname); +const EVENTS_RS = path.join( + REPO_ROOT, + "contract", + "contracts", + "hello-world", + "src", + "base", + "events.rs", +); +const EVENTS_MD = path.join(REPO_ROOT, "CONTRACT_EVENT_REFERENCE.md"); + +// --------------------------------------------------------------------------- +// Parsed data models. +// --------------------------------------------------------------------------- + +type SorobanFieldType = + | "Address" + | "BytesN<32>" + | "u32" + | "u64" + | "u128" + | "i128" + | "bool" + | "String" + | "Vec>" + | "NotificationCategory" + | "NotificationPriority" + | "AuditAction"; + +interface StructField { + name: string; + type: SorobanFieldType; + isTopic: boolean; +} + +interface ContractEvent { + name: string; + fields: StructField[]; +} + +interface DocField { + name: string; + /** Raw type text from the documentation table (we canonicalise later). */ + documentedType: string; + indexed: boolean; +} + +interface DocumentedEvent { + name: string; + fields: DocField[]; +} + +// --------------------------------------------------------------------------- +// Rust source parser. +// --------------------------------------------------------------------------- + +/** + * Very small, targeted parser for the events.rs module. The grammar we + * support on purpose is exactly what the codebase already uses: + * + * #[contractevent(data_format = "single-value")] + * #[derive(Clone)] + * pub struct MyEvent { + * #[topic] + * pub creator: Address, + * #[topic] + * pub category: NotificationCategory, + * pub id: BytesN<32>, + * } + * + * Anything outside that shape is an error rather than silently skipped so + * the detector does not produce false "no drift" results when the source + * style changes. + */ +function parseRustEvents(source: string): ContractEvent[] { + const events: ContractEvent[] = []; + const lines = source.split(/\r?\n/); + + let lineIdx = 0; + while (lineIdx < lines.length) { + const trimmed = lines[lineIdx].trimStart(); + + if (trimmed.startsWith("#[contractevent")) { + const startIdx = lineIdx; + while ( + lineIdx < lines.length && + !lines[lineIdx].trimStart().startsWith("pub struct ") + ) { + lineIdx += 1; + } + if (lineIdx >= lines.length) { + throw new Error( + `Found #[contractevent] at line ${startIdx + 1} but no following "pub struct"`, + ); + } + const structNameMatch = lines[lineIdx] + .trimStart() + .match(/^pub\s+struct\s+([A-Za-z0-9_]+)\s*\{/); + if (!structNameMatch) { + throw new Error( + `Cannot parse struct name at line ${lineIdx + 1}: ${lines[lineIdx]}`, + ); + } + const eventName = structNameMatch[1]; + lineIdx += 1; + + const fields: StructField[] = []; + while (lineIdx < lines.length && !lines[lineIdx].trimStart().startsWith("}")) { + const fieldLine = lines[lineIdx].trimStart(); + lineIdx += 1; + if (fieldLine === "" || fieldLine.startsWith("//")) { + continue; + } + + const topicMatch = fieldLine.match(/^#\[topic\]\s*$/); + if (topicMatch) { + continue; + } + + const prevLine = lines[lineIdx - 2] ?? ""; + const isTopic = prevLine.trimStart().startsWith("#[topic]"); + + const fieldMatch = fieldLine.match( + /^pub\s+([a-zA-Z0-9_]+)\s*:\s*([A-Za-z0-9_<>,\s]+)\s*,?\s*$/, + ); + if (!fieldMatch) { + continue; + } + const name = fieldMatch[1]; + const rawType = fieldMatch[2].trim() as SorobanFieldType; + fields.push({ name, type: rawType, isTopic }); + } + events.push({ name: eventName, fields }); + } + lineIdx += 1; + } + + return events; +} + +// --------------------------------------------------------------------------- +// Markdown reference docs parser. +// --------------------------------------------------------------------------- + +function parseMarkdownEvents(markdown: string): DocumentedEvent[] { + const lines = markdown.split(/\r?\n/); + const events: DocumentedEvent[] = []; + let lineIdx = 0; + + while (lineIdx < lines.length) { + const trimmed = lines[lineIdx].trim(); + const headingMatch = trimmed.match(/^###+\s+([A-Z][A-Za-z0-9]+)\s*$/); + if (!headingMatch) { + lineIdx += 1; + continue; + } + const candidateName = headingMatch[1]; + + let tableStart = lineIdx + 1; + while ( + tableStart < lines.length && + !lines[tableStart].trim().startsWith("| Field") + ) { + if (lines[tableStart].trim().startsWith("###")) { + break; + } + tableStart += 1; + } + if (tableStart >= lines.length || !lines[tableStart].trim().startsWith("| Field")) { + lineIdx += 1; + continue; + } + + const headerRow = lines[tableStart].trim(); + const separatorRow = lines[tableStart + 1]?.trim() ?? ""; + if (!separatorRow.startsWith("|---")) { + throw new Error( + `Malformed event field table for ${candidateName} near line ${tableStart + 1}`, + ); + } + + const headers = headerRow + .split("|") + .slice(1, -1) + .map((h) => h.trim().toLowerCase()); + const nameColIdx = headers.indexOf("field"); + const typeColIdx = headers.indexOf("type"); + const indexedColIdx = headers.indexOf("indexed"); + if (nameColIdx === -1 || typeColIdx === -1) { + throw new Error( + `Event table for ${candidateName} is missing Field/Type columns (got: ${headers.join(",")}).`, + ); + } + + const fields: DocField[] = []; + let dataRowIdx = tableStart + 2; + while ( + dataRowIdx < lines.length && + lines[dataRowIdx].trim().startsWith("|") && + !lines[dataRowIdx].trim().startsWith("|---") + ) { + const cols = lines[dataRowIdx].trim().split("|").slice(1, -1).map((c) => c.trim()); + const fieldName = cols[nameColIdx]; + const documentedType = cols[typeColIdx]; + const indexedRaw = indexedColIdx !== -1 ? cols[indexedColIdx] ?? "" : ""; + const indexed = + indexedRaw.includes("topic") || + indexedRaw.toLowerCase().startsWith("y") || + indexedRaw.includes("✅"); + + if (!fieldName || !documentedType) { + dataRowIdx += 1; + continue; + } + fields.push({ name: fieldName, documentedType, indexed }); + dataRowIdx += 1; + } + + events.push({ name: candidateName, fields }); + lineIdx = dataRowIdx; + } + + return events; +} + +// --------------------------------------------------------------------------- +// Type canonicalisation. +// +// The Markdown docs use human-readable formatting like `Vec>`, +// `NotificationCategory (u32)`, etc. The Rust source uses the raw Soroban +// types. We normalise both to a single comparable string before comparing. +// --------------------------------------------------------------------------- + +const TYPE_ALIASES = new Map([ + ["address", "Address"], + ["bytesn<32>", "BytesN<32>"], + ["bytesn < 32 >", "BytesN<32>"], + ["u32", "u32"], + ["u64", "u64"], + ["u128", "u128"], + ["i128", "i128"], + ["bool", "bool"], + ["string", "String"], + ["vec>", "Vec>"], + ["vec < bytesn < 32 > >", "Vec>"], + ["notificationcategory", "NotificationCategory"], + ["notificationcategory (u32)", "NotificationCategory"], + ["notificationpriority", "NotificationPriority"], + ["notificationpriority (u32)", "NotificationPriority"], + ["auditaction", "AuditAction"], + ["auditaction (u32)", "AuditAction"], +]); + +function canonicaliseType(raw: string): SorobanFieldType | string { + const cleaned = raw + .trim() + .replace(/`/g, "") + .replace(/\s+/g, " ") + .toLowerCase() + .replace(/\s*([<>])\s*/g, "$1"); + return TYPE_ALIASES.get(cleaned) ?? raw.trim().replace(/`/g, ""); +} + +// --------------------------------------------------------------------------- +// Diff engine + reporting. +// --------------------------------------------------------------------------- + +interface DiffIssue { + level: "error" | "warning"; + event: string; + message: string; +} + +function diff( + sourceEvents: ContractEvent[], + docEvents: DocumentedEvent[], +): DiffIssue[] { + const issues: DiffIssue[] = []; + const sourceByName = new Map(sourceEvents.map((e) => [e.name, e])); + const docByName = new Map(docEvents.map((e) => [e.name, e])); + + const allNames = new Set([ + ...sourceByName.keys(), + ...docByName.keys(), + ]); + + for (const name of Array.from(allNames).sort()) { + const inSource = sourceByName.get(name); + const inDocs = docByName.get(name); + + if (inSource && !inDocs) { + issues.push({ + level: "error", + event: name, + message: + `Event '${name}' is defined in contract events.rs but has no ` + + "documentation section in CONTRACT_EVENT_REFERENCE.md. Please add " + + `a ### ${name} heading with a | Field | Type | Indexed | Description | table.`, + }); + continue; + } + + if (inDocs && !inSource) { + issues.push({ + level: "error", + event: name, + message: + `Documentation references event '${name}' but no matching ` + + "struct with #[contractevent] exists in events.rs. Either remove " + + "the stale docs section or add the missing struct.", + }); + continue; + } + + if (!inSource || !inDocs) { + continue; + } + + const sourceFields = new Map(inSource.fields.map((f) => [f.name, f])); + const docFields = new Map(inDocs.fields.map((f) => [f.name, f])); + const fieldNames = new Set([ + ...sourceFields.keys(), + ...docFields.keys(), + ]); + + for (const fieldName of Array.from(fieldNames).sort()) { + const src = sourceFields.get(fieldName); + const doc = docFields.get(fieldName); + + if (src && !doc) { + issues.push({ + level: "error", + event: name, + message: + `Field '${fieldName}' (type '${src.type}', ${src.isTopic ? "topic" : "data"}) ` + + `exists on event '${name}' in events.rs but is missing from the docs table.`, + }); + continue; + } + + if (doc && !src) { + issues.push({ + level: "error", + event: name, + message: + `Docs table for '${name}' lists field '${fieldName}' (type '${doc.documentedType}') ` + + "but that field is not declared on the Rust struct.", + }); + continue; + } + + if (!src || !doc) { + continue; + } + + const canonicalSourceType = canonicaliseType(src.type); + const canonicalDocType = canonicaliseType(doc.documentedType); + if (canonicalSourceType !== canonicalDocType) { + issues.push({ + level: "error", + event: name, + message: + `Field '${name}.${fieldName}' has type '${src.type}' ` + + `in events.rs but docs declare '${doc.documentedType}'. ` + + `(Canonicalised: ${String(canonicalSourceType)} vs ${String(canonicalDocType)}).`, + }); + } + + if (src.isTopic !== doc.indexed) { + issues.push({ + level: "warning", + event: name, + message: + `Field '${name}.${fieldName}': source says ${src.isTopic ? "topic/indexed" : "data/not indexed"} ` + + `but docs say ${doc.indexed ? "indexed" : "not indexed"}. ` + + "Topic/data placement affects filterability by off-chain indexers — double-check both.", + }); + } + } + } + + return issues; +} + +// --------------------------------------------------------------------------- +// Pretty output. +// --------------------------------------------------------------------------- + +function printReport( + sourceEvents: ContractEvent[], + docEvents: DocumentedEvent[], + issues: DiffIssue[], +): void { + const errors = issues.filter((i) => i.level === "error"); + const warnings = issues.filter((i) => i.level === "warning"); + + // eslint-disable-next-line no-console + console.log("============================================================"); + // eslint-disable-next-line no-console + console.log(" NotifyChain Contract Event Documentation Drift Check"); + // eslint-disable-next-line no-console + console.log("============================================================"); + // eslint-disable-next-line no-console + console.log(` Source: ${path.relative(REPO_ROOT, EVENTS_RS)}`); + // eslint-disable-next-line no-console + console.log(` Documentation: ${path.relative(REPO_ROOT, EVENTS_MD)}`); + // eslint-disable-next-line no-console + console.log("------------------------------------------------------------"); + // eslint-disable-next-line no-console + console.log(` Contract events found in source: ${sourceEvents.length}`); + // eslint-disable-next-line no-console + console.log(` Event sections found in docs: ${docEvents.length}`); + // eslint-disable-next-line no-console + console.log(` Issues: ${errors.length} errors, ${warnings.length} warnings`); + // eslint-disable-next-line no-console + console.log("------------------------------------------------------------"); + + if (issues.length === 0) { + // eslint-disable-next-line no-console + console.log("✅ No event documentation drift detected."); + return; + } + + for (const issue of issues) { + const tag = issue.level === "error" ? "✖ ERROR " : "⚠ WARN "; + // eslint-disable-next-line no-console + console.log(`${tag} [${issue.event}]`); + // eslint-disable-next-line no-console + console.log(` ${issue.message}`); + } + + // eslint-disable-next-line no-console + console.log("------------------------------------------------------------"); + // eslint-disable-next-line no-console + console.log( + "HOW TO FIX:", + ); + // eslint-disable-next-line no-console + console.log( + " 1. Open CONTRACT_EVENT_REFERENCE.md and the relevant event struct in", + ); + // eslint-disable-next-line no-console + console.log( + " contract/contracts/hello-world/src/base/events.rs side by side.", + ); + // eslint-disable-next-line no-console + console.log( + " 2. Match the event heading and the | Field | Type | Indexed | table to", + ); + // eslint-disable-next-line no-console + console.log( + " the struct definition, one field at a time.", + ); + // eslint-disable-next-line no-console + console.log( + " 3. Re-run locally: cd listener && npm run check:event-docs", + ); + // eslint-disable-next-line no-console + console.log("------------------------------------------------------------"); +} + +// --------------------------------------------------------------------------- +// Entry point. +// --------------------------------------------------------------------------- + +function main(): number { + if (!fs.existsSync(EVENTS_RS)) { + // eslint-disable-next-line no-console + console.error(`FATAL: events.rs not found at ${EVENTS_RS}`); + return 2; + } + if (!fs.existsSync(EVENTS_MD)) { + // eslint-disable-next-line no-console + console.error(`FATAL: CONTRACT_EVENT_REFERENCE.md not found at ${EVENTS_MD}`); + return 2; + } + + const rustSrc = fs.readFileSync(EVENTS_RS, "utf8"); + const markdownSrc = fs.readFileSync(EVENTS_MD, "utf8"); + + const sourceEvents = parseRustEvents(rustSrc); + const docEvents = parseMarkdownEvents(markdownSrc); + + const issues = diff(sourceEvents, docEvents); + printReport(sourceEvents, docEvents, issues); + + const errors = issues.filter((i) => i.level === "error"); + return errors.length === 0 ? 0 : 1; +} + +const exitCode = main(); +process.exit(exitCode);