diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..4187bf9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# `docs/` — project documentation + +> Everything that is not source code, organised so judges, contributors, and future-you can find what they need in one click. + +| File | Audience | What it covers | +|---|---|---| +| [`submission.md`](./submission.md) | ETHGlobal judges | One-pager: track, sponsors used, on-chain proof table, decision-engine summary, run instructions, test coverage | +| [`demo-script.md`](./demo-script.md) | Recording team | Verbatim 7-beat walkthrough for the demo video | +| [`architecture.md`](./architecture.md) | Maintainers + reviewers | System design, 8-step decision flow, post-hackathon Rust + Solidity roadmap | +| [`deploy.md`](./deploy.md) | Anyone hosting it | $0 deploy walkthrough on Render + Cloudflare Pages, including CORS + keepalive config | +| [`product-idea.md`](./product-idea.md) | Future-you | Original product framing notes from the planning session | +| [`sponsors/`](./sponsors) | Maintainers + sponsors | Per-sponsor research notes — see [`sponsors/README.md`](./sponsors/README.md) | + +## Reading order + +```mermaid +flowchart LR + A[../README.md
quick orientation] --> B[submission.md
judge one-pager] + B --> C[architecture.md
system design] + C --> D[sponsors/
sponsor research] + D --> E[demo-script.md
recording walkthrough] + E --> F[deploy.md
$0 hosting] +``` + +## Conventions + +- All docs are plain Markdown with optional Mermaid blocks. +- Every doc that references a file uses a relative link so GitHub renders them and offline editors can follow them. +- No emojis anywhere — the same project-wide rule the source code follows. CI scans these files too. +- Live on-chain proof (rootHash, storage tx, block, gas) is duplicated in [`../README.md`](../README.md) and [`submission.md`](./submission.md) so judges can verify before clicking through. + +## Pointers + +| | | +|---|---| +| Project root | [`../README.md`](../README.md) | +| Source | [`../src/`](../src) | +| Tests | [`../tests/`](../tests) | +| Frontend | [`../web/`](../web) | +| Conventions for AI agents | [`../AGENTS.md`](../AGENTS.md) | diff --git a/docs/sponsors/README.md b/docs/sponsors/README.md new file mode 100644 index 0000000..3edc417 --- /dev/null +++ b/docs/sponsors/README.md @@ -0,0 +1,35 @@ +# `docs/sponsors/` — sponsor research notes + +> Per-sponsor research and integration notes captured during planning. Each file is the working spec the adapter was built against, kept after the build so the integration story is reproducible. + +| File | Sponsor | What it covers | Adapter | +|---|---|---|---| +| [`0g.md`](./0g.md) | 0G Labs | Storage SDK shape, Galileo testnet config, Indexer.upload semantics, faucet path, mainnet vs testnet contracts | [`../../src/memory/zeroGStore.ts`](../../src/memory/zeroGStore.ts) | +| [`keeperhub.md`](./keeperhub.md) | KeeperHub | API key types, workflow execute endpoint, response shape variance, auth patterns | [`../../src/playbooks/keeperhub.ts`](../../src/playbooks/keeperhub.ts) | +| [`gensyn-axl.md`](./gensyn-axl.md) | Gensyn | AXL local HTTP bridge model, MCP / A2A support, NAT-friendly mesh, bootstrap-node requirements | [`../../src/transport/axlGossip.ts`](../../src/transport/axlGossip.ts) | + +## Prize matrix + +| Sponsor | Prize pool | Status | +|---|---|---| +| 0G | `$15,000` | Adapter shipped, live anchor verified on Galileo testnet | +| KeeperHub | `$5,000` | Adapter shipped, real workflow fired against live API | +| Gensyn AXL | `$5,000` | Adapter shipped (decision gossip transport), MCP-style publish endpoint | +| **Total addressable** | **`$25,000`** | All three adapters real, tested, env-gated, soft-failure | + +## How to add a new sponsor + +1. Add a research note `.md` in this folder — what it does, what its API looks like, what shape it expects. +2. Pick the right trait seam: `Store`, `Simulator`, `PlaybookRunner`, `NotificationChannel`, or `GossipTransport`. +3. Implement the real adapter in `src//`, mirroring an existing one (`KeeperHubRunner` for HTTP-based, `ZeroGStore` for SDK-based). +4. Wire it into [`../../src/risk-gate/server.ts`](../../src/risk-gate/server.ts), env-gated so a missing key falls back to the in-memory or no-op impl. +5. Add tests under `../../tests/.test.ts` — happy path + soft-failure + adversarial bodies. +6. Update [`../../README.md`](../../README.md) sponsor section + this index. + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Project root | [`../../README.md`](../../README.md) | +| Wiring conventions | [`../../.claude/skills/sponsor-wiring/SKILL.md`](../../.claude/skills/sponsor-wiring/SKILL.md) | diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c29c73d --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,35 @@ +# `scripts/` — operator helpers + +> Small bash scripts used during development. Both honour `.env.local` so credentials never live on the command line. + +| File | What it does | +|---|---| +| [`dev.sh`](./dev.sh) | Boots the API on `:8787` (Bun watch mode) and the Astro frontend on `:4321` in parallel. Kills both on Ctrl-C. Wired to `bun run dev` | +| [`kh.sh`](./kh.sh) | Bash wrapper for the KeeperHub REST API — reads `KEEPERHUB_API_KEY` and `KEEPERHUB_API_URL` from `.env.local`. Subcommands: `list`, `get `, `run `, `status `, `ping` | + +## `kh.sh` examples + +```sh +./scripts/kh.sh ping # auth check +./scripts/kh.sh list # workflows in the org +./scripts/kh.sh get 8c12ujo1ax7b93w21updd +./scripts/kh.sh run 8c12ujo1ax7b93w21updd '{"owner":"0xtreasury"}' +./scripts/kh.sh status +``` + +The wrapper exists so workflow ids can be discovered and exercised without rerunning the full server, and so the org-key vs user-key distinction is centralised in one place rather than repeated in shell history. + +## Conventions + +- Every script reads `.env.local` if it exists; never echoes secrets. +- Failures `exit 1` with a one-line reason; success is silent or prints structured output. +- No `set -e` shortcuts that silently mask real failures — explicit checks for empty env vars and HTTP non-2xx. + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| KeeperHub adapter | [`../src/playbooks/keeperhub.ts`](../src/playbooks/keeperhub.ts) | +| Sponsor research | [`../docs/sponsors/keeperhub.md`](../docs/sponsors/keeperhub.md) | +| Env template | [`../.env.example`](../.env.example) | diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..68fd1bf --- /dev/null +++ b/src/README.md @@ -0,0 +1,46 @@ +# `src/` — ChainShield server + +> Fastify HTTP API that evaluates transaction intents against a deterministic policy engine, anchors every decision on 0G Storage, fires KeeperHub remediation on `BLOCK`, and gossips the verdict over the Gensyn AXL mesh. + +The whole server is plain TypeScript on Bun. Five trait-shaped seams (`Store`, `Simulator`, `PlaybookRunner`, `NotificationChannel`, `GossipTransport`) keep every external dependency replaceable — tests run against in-memory fakes; production wires real adapters from env. + +| Folder | What it is | +|---|---| +| [`core/`](./core) | Types, Zod schemas, `PolicyService`, `DecisionEngine`, EVM selector helpers | +| [`memory/`](./memory) | `Store` interface, `InMemoryStore`, `ZeroGStore` (0G anchor adapter) | +| [`simulator/`](./simulator) | `Simulator` interface + `HeuristicSimulator` (ERC-20 calldata decode + balance projection) | +| [`playbooks/`](./playbooks) | `PlaybookRunner` interface, `KeeperHubRunner`, `WebhookChannel`, `CollectorChannel` | +| [`transport/`](./transport) | `GossipTransport` interface, `AxlGossipTransport`, `NoopGossip` | +| [`risk-gate/`](./risk-gate) | Fastify `app.ts` + `server.ts` composition root | +| [`cli/`](./cli) | `demo.ts` — four canonical scenes against the live API | + +## Request flow + +```mermaid +flowchart LR + Caller[Wallet / treasury client] -->|POST /evaluate| App + App[risk-gate/app.ts
Fastify route] --> Engine + Engine[core/engine.ts
5-rule ladder] --> Sim[simulator/
HeuristicSimulator] + Engine --> Store[memory/
ZeroGStore] + Engine -->|BLOCK| Run[playbooks/
KeeperHubRunner] + Engine -->|BLOCK| Gossip[transport/
AxlGossipTransport] + Engine -->|BLOCK| Notify[playbooks/
WebhookChannel] +``` + +## Hard rules + +1. Verdict ladder is monotonic: `BLOCK > REQUIRE_HUMAN_CONFIRMATION > ALLOW`. A rule may only escalate, never de-escalate. +2. `forbiddenSelectors` is checked before any other rule and short-circuits to `BLOCK` with risk 95. +3. Defensive guards (`invalidIntentValue`, `invalidApprovalCap`) escalate to `REQUIRE_HUMAN_CONFIRMATION` (risk 70) instead of throwing. +4. Every decision is persisted via `Store.appendDecision` exactly once. +5. `reasons[]` is human-readable English; `rulesMatched[]` is machine-readable rule keys. Keep them in sync. +6. Sponsor adapters fail soft — a Galileo hiccup or KeeperHub outage never returns 5xx. + +## Related + +| | | +|---|---| +| Tests | [`../tests/`](../tests) — 109 specs across 13 files | +| Composition root | [`risk-gate/server.ts`](./risk-gate/server.ts) | +| Frontend | [`../web/`](../web) — Astro 6, separate Bun workspace | +| Conventions | [`../AGENTS.md`](../AGENTS.md) | diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 0000000..b112841 --- /dev/null +++ b/src/cli/README.md @@ -0,0 +1,36 @@ +# `src/cli/` — demo runner + +> A four-scene CLI that drives the live API end-to-end. Used for the recording and as a smoke test before submission. + +| File | Role | +|---|---| +| [`demo.ts`](./demo.ts) | Boots a fresh policy via `POST /policies`, then runs four `POST /evaluate` calls covering every verdict path. Exits `0` if the verdict and reasons match the expected shape, `1` otherwise. | + +## Scenes + +| # | Intent | Expected verdict | Rule that fires | +|---|---|---|---| +| 1 | Small transfer to allowlisted vault | `ALLOW` | (all rules pass) | +| 2 | Transfer above the per-tx ETH cap | `BLOCK` | `maxTransferEth` | +| 3 | `approve(spender, MAX_UINT256)` | `BLOCK` | `forbiddenSelectors` (or `approvalCapByToken`) | +| 4 | Transfer to an off-allowlist destination | `REQUIRE_HUMAN_CONFIRMATION` | `allowedDestinations` | + +## How to run + +```sh +# 1. boot the API (in another terminal) +bun run dev + +# 2. drive the four scenes +bun run demo +``` + +The demo uses `INTERNAL_API_BASE` if set, otherwise defaults to `http://127.0.0.1:8787`. Output prints the verdict, risk score, matched rules, and the `anchor.rootHash` if 0G is wired. + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| API surface | [`../risk-gate/app.ts`](../risk-gate/app.ts) | +| Recording walkthrough | [`../../docs/demo-script.md`](../../docs/demo-script.md) | diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000..7149f76 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,61 @@ +# `src/core/` — types, schemas, engine + +> The heart of ChainShield. Pure TypeScript with no I/O — every external dependency is injected as a trait so the engine stays unit-testable in microseconds. + +| File | Role | +|---|---| +| [`types.ts`](./types.ts) | All exported types: `TxIntent`, `Policy`, `Decision`, `Verdict`, `SimulationResult`, `BalanceDelta`, `ApprovalDelta`, `PlaybookRun`, `LlmReasoning` | +| [`schemas.ts`](./schemas.ts) | Zod schemas at the API boundary — `policyInputSchema`, `evaluateRequestSchema`. Parsed once at the edge; plain TS types flow through internals | +| [`policyService.ts`](./policyService.ts) | Policy CRUD with version bumping. Zod-validates input, persists to `Store`, returns the canonical record | +| [`engine.ts`](./engine.ts) | `DecisionEngine` — the 5-rule ladder + simulator integration + remediation dispatch | +| [`selectors.ts`](./selectors.ts) | EVM 4-byte selector helpers (`selectorOf`, `decodeUint256`) + curated selector constants (`ERC20_APPROVE`, `ERC20_TRANSFER`, …) | + +## Decision ladder + +```mermaid +flowchart TD + A[Intent in] --> B{forbiddenSelectors?} + B -->|yes| Z[BLOCK risk 95] + B -->|no| C{maxTransferEth?} + C -->|over| Z + C -->|under| D{maxDailyOutflowEth?} + D -->|over| Z + D -->|under| E{allowedDestinations?} + E -->|off-list| F[REQUIRE_HUMAN_CONFIRMATION risk 60] + E -->|on-list| G{approvalCapByToken?} + G -->|over| Z + G -->|under| H{simulator revert?} + F --> H + H -->|revert| I[REQUIRE_HUMAN_CONFIRMATION risk 70] + H -->|success| J[ALLOW] + J --> K[persist + anchor] + I --> K + Z --> L[persist + anchor + remediation] +``` + +## Invariants (do not break) + +1. The verdict ladder is **monotonic** — rules can only escalate. +2. `forbiddenSelectors` short-circuits before any other rule. +3. `maxTransferEth` and `approvalCapByToken` produce `BLOCK` with risk ≥ 90. +4. `maxDailyOutflowEth` reads the timeline; only non-blocked rows count toward the rolling sum. +5. `allowedDestinations` downgrades `ALLOW` to `REQUIRE_HUMAN_CONFIRMATION` (risk 60). It cannot promote to `BLOCK` on its own. +6. Defensive guards escalate to `REQUIRE_HUMAN_CONFIRMATION` instead of throwing. +7. `reasons[]` and `rulesMatched[]` stay in sync. + +## Tests + +| | | +|---|---| +| 5-rule ladder + guards | [`../../tests/engine.test.ts`](../../tests/engine.test.ts) | +| Simulator integration + revert escalation | [`../../tests/engineSimulation.test.ts`](../../tests/engineSimulation.test.ts) | +| Playbook + notifications + AXL gossip on `BLOCK` | [`../../tests/engineRemediation.test.ts`](../../tests/engineRemediation.test.ts) | +| Policy CRUD + version bumping | [`../../tests/policyService.test.ts`](../../tests/policyService.test.ts) | + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Wired by | [`../risk-gate/server.ts`](../risk-gate/server.ts) | +| Selector reference | [`../../.claude/skills/selector-decode/SKILL.md`](../../.claude/skills/selector-decode/SKILL.md) | diff --git a/src/memory/README.md b/src/memory/README.md new file mode 100644 index 0000000..cd19698 --- /dev/null +++ b/src/memory/README.md @@ -0,0 +1,50 @@ +# `src/memory/` — Store layer + +> Pluggable persistence behind a single trait. Tests use the in-memory impl; production wires the 0G anchor adapter. + +| File | Role | +|---|---| +| [`store.ts`](./store.ts) | The `Store` interface (`putPolicy`, `getPolicy`, `listPolicies`, `appendDecision`, `listDecisions`, optional `getAnchor`) and the `AnchorRecord` type | +| [`memoryStore.ts`](./memoryStore.ts) | `InMemoryStore` — pure in-process maps + arrays. Used in tests and as the fallback when `ZERO_G_PRIVATE_KEY` is unset | +| [`zeroGStore.ts`](./zeroGStore.ts) | `ZeroGStore` — uploads each policy and decision JSON to 0G Galileo via `@0gfoundation/0g-storage-ts-sdk`, caches the local copy, exposes anchor `(rootHash, txHash)` per id | + +## Anchor flow + +```mermaid +flowchart LR + PS[PolicyService.put] --> ZG[ZeroGStore.putPolicy] + ZG -->|in-memory write returns ~50ms| Caller + ZG -.->|background| Sched[scheduleAnchor] + Sched --> Try[tryAnchor] + Try --> SDK["Indexer.upload(MemData(json))"] + SDK --> Galileo[(0G Galileo
storage indexer)] + Galileo -->|"{rootHash, txHash}"| Anchors[(anchors map)] + Anchors --> Get[getAnchor by id] +``` + +## Soft-failure contract + +- `Indexer.upload` returns either an error-tuple `[result, Error|null]` or throws — `tryAnchor` handles both paths. +- Two response shapes: single `{ rootHash, txHash, txSeq }` and fragmented `{ rootHashes[], txHashes[], txSeqs[] }` (>4 GB). `tryAnchor` normalises both. +- Failure is logged (`logger.warn`) and the anchor map simply lacks an entry — the API still serves the response with `anchor: null`. +- Background uploads never block the API hot path; tests use the optional `waitForAnchor(id)` helper to settle pending uploads deterministically. + +## Tests + +| | | +|---|---| +| Anchor on write, soft-failure, empty-result handling | [`../../tests/zeroGStore.test.ts`](../../tests/zeroGStore.test.ts) | +| Anchor surfacing on policy + decision API responses (real `ZeroGStore` + `buildApp` e2e) | [`../../tests/apiAnchor.test.ts`](../../tests/apiAnchor.test.ts) | +| Multi-tenant decision isolation by client id | [`../../tests/clientIsolation.test.ts`](../../tests/clientIsolation.test.ts) | + +## Live proof + +A real policy was anchored on Galileo testnet — see the rootHash + storage tx table at the top of [`../../README.md`](../../README.md). + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Wired by | [`../risk-gate/server.ts`](../risk-gate/server.ts) — picks `ZeroGStore` when `ZERO_G_PRIVATE_KEY` is set | +| Sponsor research | [`../../docs/sponsors/0g.md`](../../docs/sponsors/0g.md) | diff --git a/src/playbooks/README.md b/src/playbooks/README.md new file mode 100644 index 0000000..c970410 --- /dev/null +++ b/src/playbooks/README.md @@ -0,0 +1,51 @@ +# `src/playbooks/` — remediation + notifications + +> When a verdict is `BLOCK`, ChainShield does more than say "no" — it fires an automated remediation playbook and pages every configured notification channel. Both layers are pluggable behind small interfaces. + +| File | Role | +|---|---| +| [`runner.ts`](./runner.ts) | The `PlaybookRunner` and `NotificationChannel` interfaces, plus `MockRunner` for tests | +| [`keeperhub.ts`](./keeperhub.ts) | `KeeperHubRunner` — fires KeeperHub workflows via `POST /api/workflow/:id/execute` with bearer auth, scrubs HTML error pages | +| [`notifier.ts`](./notifier.ts) | `WebhookChannel` (Discord-shaped embed by default, custom shapes via `contentTemplate`) and `CollectorChannel` (test-only in-memory recorder) | + +## Remediation flow + +```mermaid +flowchart LR + Engine[DecisionEngine.handleRemediation] --> R{remediation.onBlock?} + R -->|empty| N + R -->|ids[]| RR[KeeperHubRunner.run id 0] + RR -->|ok| Decision[playbookTriggered set] + RR -->|throws| Next[try id 1, id 2, ...] + Next -->|all fail| Decision2[reasons append failure] + Decision --> N{notifyChannels?} + Decision2 --> N + N -->|empty| Done + N -->|name list| CH[for each registered channel
WebhookChannel.notify] + CH --> Done[return decision] +``` + +## Hard rules + +- A failing playbook **never** affects the verdict or persistence — failures are pushed into `decision.reasons` (truncated to 256 chars) and the engine continues. +- Notification failures are silently swallowed — paging cannot down-convert a `BLOCK` to anything else. +- `KeeperHubRunner` returns the first response field it finds in priority `runId → id → executionId` and falls back to `"unknown"` so the JSON shape stays stable. +- HTML error bodies (auth-failure pages, 404s) are detected by `summarizeErrorBody` and replaced with `(html error page)` before they reach `decision.reasons` or the UI. + +## Tests + +| | | +|---|---| +| `MockRunner` invocation tracking + forced failures | [`../../tests/playbooks.test.ts`](../../tests/playbooks.test.ts) | +| `KeeperHubRunner` happy path, response-shape fallback, HTML scrub, body truncation, URL encoding | same file | +| `WebhookChannel` Discord embed shape + custom template + non-2xx error | same file | +| Engine integration: trigger on `BLOCK`, fall-through on failure, notification fan-out | [`../../tests/engineRemediation.test.ts`](../../tests/engineRemediation.test.ts) | + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Wired by | [`../risk-gate/server.ts`](../risk-gate/server.ts) — picks `KeeperHubRunner` when `KEEPERHUB_API_KEY` is set | +| Helper script | [`../../scripts/kh.sh`](../../scripts/kh.sh) — `list / get / run / status / ping` against the live KeeperHub API | +| Sponsor research | [`../../docs/sponsors/keeperhub.md`](../../docs/sponsors/keeperhub.md) | diff --git a/src/risk-gate/README.md b/src/risk-gate/README.md new file mode 100644 index 0000000..c22c9b0 --- /dev/null +++ b/src/risk-gate/README.md @@ -0,0 +1,60 @@ +# `src/risk-gate/` — Fastify API + composition root + +> The HTTP surface and the wiring that decides which adapter to use for each trait. Reads env vars once at boot, swaps real adapters in place of the in-memory fallbacks, and starts listening. + +| File | Role | +|---|---| +| [`app.ts`](./app.ts) | `buildApp(deps)` — Fastify instance, CORS layer, Zod-validated routes for `/policies`, `/policies/:id`, `/evaluate`, `/timeline`, `/health`. Anchor metadata is injected at the serialization boundary by `withAnchorPolicy` / `withAnchorDecision` | +| [`server.ts`](./server.ts) | The composition root — reads env, picks `Store` / `PlaybookRunner` / `GossipTransport`, registers notification channels, calls `defaultEngine`, starts listening on `:8787` | + +## Adapter selection + +| Env var | When set | Real adapter | When unset | Fallback | +|---|---|---|---|---| +| `ZERO_G_PRIVATE_KEY` | non-empty | `ZeroGStore` (anchors on Galileo) | unset | `InMemoryStore` | +| `KEEPERHUB_API_KEY` | non-empty | `KeeperHubRunner` (real workflows) | unset | `MockRunner` | +| `NOTIFY_DISCORD_WEBHOOK` | set | adds `discord` channel via `WebhookChannel` | unset | only `collector` channel registered | +| `AXL_BASE_URL` | non-empty | `AxlGossipTransport` (publish over mesh) | unset | `NoopGossip` | +| `WEB_ORIGIN` | comma list | parsed via `parseOriginEntry` (literal or `/regex/`) | unset | defaults to `http://127.0.0.1:4321,http://localhost:4321` | + +## Routes + +```mermaid +flowchart LR + C[Caller] -->|GET /health| H[200 OK] + C -->|POST /policies| PP[PolicyService.put
+ anchor] + C -->|GET /policies/:id| GP[Store.getPolicy
+ anchor] + C -->|GET /policies| LP[Store.listPolicies
+ anchor each] + C -->|POST /evaluate| EV[DecisionEngine.evaluate
+ anchor] + C -->|GET /timeline| TL[Store.listDecisions
+ anchor each] +``` + +## Anchor injection + +`ZeroGStore.getAnchor(id)` is **optional** on the `Store` interface. `app.ts` calls it via optional chaining, so `InMemoryStore` (which has no anchor concept) needs no changes — the API simply omits the `anchor` field. + +```ts +// inside app.ts +function withAnchorDecision(d: Decision): WithAnchor { + const anchor = store.getAnchor?.(d.id); + return anchor ? { ...d, anchor } : d; +} +``` + +## Tests + +| | | +|---|---| +| End-to-end API flow against an in-memory store | [`../../tests/api.test.ts`](../../tests/api.test.ts) | +| Anchor surfacing on policy + decision responses (real `ZeroGStore` + `buildApp`) | [`../../tests/apiAnchor.test.ts`](../../tests/apiAnchor.test.ts) | +| CORS allowlist + Cloudflare Pages preview-domain regex | [`../../tests/cors.test.ts`](../../tests/cors.test.ts) | + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Engine | [`../core/engine.ts`](../core/engine.ts) | +| Frontend that calls these routes | [`../../web/`](../../web) | +| Containerised deploy | [`../../Dockerfile`](../../Dockerfile), [`../../docker-compose.yml`](../../docker-compose.yml) | +| $0 hosting walkthrough | [`../../docs/deploy.md`](../../docs/deploy.md) | diff --git a/src/simulator/README.md b/src/simulator/README.md new file mode 100644 index 0000000..d6f3bc6 --- /dev/null +++ b/src/simulator/README.md @@ -0,0 +1,52 @@ +# `src/simulator/` — pre-signature simulation + +> A heuristic ERC-20 simulator that decodes calldata locally and projects balance deltas without a network round trip. Returns a result in microseconds, so the engine can escalate borderline intents without a fork. + +| File | Role | +|---|---| +| [`simulator.ts`](./simulator.ts) | The `Simulator` interface — `simulate(intent)` returning a `SimulationResult` (`success`, `revertReason?`, `balanceDeltas[]`, `approvals[]?`, `gasUsed?`) | +| [`heuristic.ts`](./heuristic.ts) | `HeuristicSimulator` — decodes `transfer` / `transferFrom` / `approve` calldata and projects deltas; treats native ETH and unknown selectors as success | + +## What it covers + +```mermaid +flowchart LR + I[TxIntent] --> S{selector} + S -->|transfer 0xa9059cbb| T[debit from / credit to] + S -->|transferFrom 0x23b872dd| TF[debit from / credit to + spender] + S -->|approve 0x095ea7b3| AP[emit ApprovalDelta] + S -->|none / native ETH| ETH[debit from / credit to value] + S -->|unknown| OK[success: no deltas] + T --> R[SimulationResult] + TF --> R + AP --> R + ETH --> R + OK --> R +``` + +## Why heuristic, not fork + +| | Fork-based simulator | Heuristic simulator | +|---|---|---| +| Round trip | `eth_call` against fork (~hundreds of ms) | None — pure calldata decode | +| Coverage | Whole EVM | ERC-20 transfer / transferFrom / approve + native ETH | +| Edge cases | Fee-on-transfer, blacklists, pause | Caught by the `forbiddenSelectors` rule before the simulator runs | +| Cost | RPC fees + node maintenance | Free | +| Latency budget | Out of scope for hot-path API | Microseconds — fits inside the `~50 ms` API target | + +The simulator is one of six rules in the engine ladder; `forbiddenSelectors` already blocks the dangerous tokens, so the heuristic only needs to model the happy paths well. + +## Tests + +| | | +|---|---| +| Calldata decode + balance deltas + typed `approvals[]` | [`../../tests/simulator.test.ts`](../../tests/simulator.test.ts) | +| Engine integration + revert escalation | [`../../tests/engineSimulation.test.ts`](../../tests/engineSimulation.test.ts) | + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Consumed by | [`../core/engine.ts`](../core/engine.ts) — `runSimulator()` | +| Selector reference | [`../core/selectors.ts`](../core/selectors.ts) | diff --git a/src/transport/README.md b/src/transport/README.md new file mode 100644 index 0000000..0636000 --- /dev/null +++ b/src/transport/README.md @@ -0,0 +1,56 @@ +# `src/transport/` — peer-to-peer decision gossip + +> When ChainShield issues a `BLOCK`, the verdict needs to reach every co-operating gate without a centralised relay. This folder publishes each blocked decision over the Gensyn AXL agent mesh — soft-failure, no SDK lock-in, language-agnostic. + +| File | Role | +|---|---| +| [`axlGossip.ts`](./axlGossip.ts) | `AxlGossipTransport` — `POST ${AXL_BASE_URL}/api/v1/mcp/publish` with a `{ topic, payload: { decision, policy } }` body. Default topic `chainshield.decisions` | +| [`noopGossip.ts`](./noopGossip.ts) | `NoopGossip` — used when `AXL_BASE_URL` is unset, so the rest of the system behaves identically without an AXL node running | + +## Gossip flow + +```mermaid +flowchart LR + Engine[DecisionEngine
handleRemediation] -->|BLOCK only| GT[GossipTransport.broadcast] + GT --> Pub[POST /api/v1/mcp/publish
topic: chainshield.decisions] + Pub --> Local[(AXL local bridge
:9002)] + Local --> Mesh[(AXL mesh
MCP / A2A)] + Mesh --> P1[Co-operating gate 1] + Mesh --> P2[Co-operating gate 2] + Mesh --> P3[Co-operating gate N] +``` + +## Why AXL + +- **Local HTTP bridge at `:9002`** — language-agnostic, no SDK, drop-in for any agent stack. +- **Built-in MCP / A2A support** — structured agent-to-agent messages without re-implementing libp2p. +- **No central relay** — co-operating gates form a mesh; one offline node never breaks the rest. +- **NAT/firewall friendly + end-to-end encrypted** by default. + +## Soft-failure contract + +`GossipTransport.broadcast` is required to **never throw**. Implementations log internally on failure and return. The engine awaits the call so blocked decisions reach the mesh before the API response returns; because the contract guarantees no throws, this awaiting is safe. + +| Failure mode | Behaviour | +|---|---| +| 5xx response | `logger.warn` with status + summarised body; no throw | +| Network error (`ECONNREFUSED`, DNS, etc.) | `logger.warn` with the truncated message; no throw | +| HTML error page in the body | Collapsed to `(html error page)` so markup never reaches the logs | +| Oversized JSON error body | Truncated to 200 chars + `…` | + +## Tests + +| | | +|---|---| +| Happy path, default + custom topic, soft-failure on 5xx, soft-failure on network error, HTML scrubbing, `NoopGossip` no-op | [`../../tests/axlGossip.test.ts`](../../tests/axlGossip.test.ts) | +| Engine integration: gossip fires on `BLOCK`, never on `ALLOW` / `REQUIRE_HUMAN_CONFIRMATION` | [`../../tests/engineRemediation.test.ts`](../../tests/engineRemediation.test.ts) | + +## Pointers + +| | | +|---|---| +| Parent | [`../README.md`](../README.md) | +| Wired by | [`../risk-gate/server.ts`](../risk-gate/server.ts) — picks `AxlGossipTransport` when `AXL_BASE_URL` is set, else `NoopGossip` | +| Engine hook | [`../core/engine.ts`](../core/engine.ts) — broadcast invoked from `handleRemediation` after the playbook runner | +| Sponsor research | [`../../docs/sponsors/gensyn-axl.md`](../../docs/sponsors/gensyn-axl.md) | +| AXL docs | | diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c761011 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,51 @@ +# `tests/` — unit + integration coverage + +> 109 specs across 13 files, all green in `~340 ms`. Every external dependency is faked at the trait boundary; live anchor hashes are pinned as test constants so the renderer is exercised against real chain data. + +```sh +bun test # all 109 specs +bun test tests/engine.test.ts # one file +bun test --watch # watch mode +bun test --coverage # coverage report +``` + +## File index + +| File | Domain | +|---|---| +| [`api.test.ts`](./api.test.ts) | Risk-gate Fastify API end-to-end | +| [`apiAnchor.test.ts`](./apiAnchor.test.ts) | Anchor surfacing on policy + decision responses (real `ZeroGStore` + `buildApp`) | +| [`axlGossip.test.ts`](./axlGossip.test.ts) | `AxlGossipTransport` happy path, soft-failure (5xx, network error, HTML body), `NoopGossip` | +| [`clientIsolation.test.ts`](./clientIsolation.test.ts) | Multi-tenant decision isolation by client id | +| [`cors.test.ts`](./cors.test.ts) | CORS allowlist + Cloudflare Pages preview-domain regex + suffix-attack rejection | +| [`engine.test.ts`](./engine.test.ts) | 5-rule decision ladder + `invalidIntentValue` / `invalidApprovalCap` defensive guards | +| [`engineRemediation.test.ts`](./engineRemediation.test.ts) | Playbook trigger, fall-through on failure, notification fan-out, AXL gossip on `BLOCK` | +| [`engineSimulation.test.ts`](./engineSimulation.test.ts) | Simulator integration + revert escalation | +| [`playbooks.test.ts`](./playbooks.test.ts) | `MockRunner`, `KeeperHubRunner`, `WebhookChannel`, `CollectorChannel` | +| [`policyService.test.ts`](./policyService.test.ts) | Policy CRUD + version bumping + Zod schema rejection | +| [`simulator.test.ts`](./simulator.test.ts) | `HeuristicSimulator` calldata decode + balance deltas + typed `approvals[]` | +| [`webFormat.test.ts`](./webFormat.test.ts) | Astro renderer: `shortHash`, `anchorPillHtml`, `escapeHtml` adversarial XSS | +| [`zeroGStore.test.ts`](./zeroGStore.test.ts) | Anchor on write, soft-failure, empty-result handling, single + multi response shapes | +| [`helpers.ts`](./helpers.ts) | Shared fixtures: `TREASURY` / `COLD_VAULT` / `ATTACKER` / `TOKEN` addresses, `makePolicy`, `makeIntent`, `approveCalldata` | + +## Patterns to mirror + +| Pattern | Where it lives | +|---|---| +| Mock fetcher injected into HTTP-based adapters | `tests/playbooks.test.ts` (KeeperHub), `tests/axlGossip.test.ts` | +| Real adapter against a fake indexer | `tests/zeroGStore.test.ts` (`IndexerLike` injected) | +| Live production hashes pinned as test constants | `tests/webFormat.test.ts` (`FULL_ROOT`, `FULL_TX`) | +| Fresh `InMemoryStore` per spec | every `tests/engine*.ts` and `tests/api*.ts` | +| Deterministic `now()` + `idGen()` injected | `tests/engineRemediation.test.ts`, `tests/engineSimulation.test.ts` | + +## CI gates + +The same `bun test` runs in CI on every PR to `main` and every push, alongside `tsc --noEmit`, `astro check`, the Astro production build, and a byte-level emoji scan. A red test fails the pipeline; nothing merges without all checks green. + +## Pointers + +| | | +|---|---| +| Source under test | [`../src/`](../src) | +| Bun test docs | | +| Coding conventions | [`../AGENTS.md`](../AGENTS.md) |