Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
# ── Root (hardhat: deploy, wire, demo tasks) ────────────────────────────────
# Copy to .env (gitignored). This file is ONLY for the contract toolchain.
# The worker and the indexer have their own: worker/.env and indexer/.env.
#
# See DEPLOYMENT.md for the full ordered sequence.

# REQUIRED — the OWNER / deployer key (0x + 64 hex). This is what hardhat deploys from, and it is
# the key that later calls `approvePacket` to release held packets.
#
# NOTE: worker/.env also has a variable named PRIVATE_KEY, but there it means the OPERATOR key.
# Do not copy this file into worker/ — giving the worker the owner key would let it approve the
# very packets it chose to withhold, which is exactly what the split exists to prevent.
PRIVATE_KEY=

# REQUIRED for a real deployment — the worker key's ADDRESS (not its private key).
# Leave unset only for a throwaway demo: owner and operator then collapse onto the deployer and
# the worker gains approval rights. The deploy script and `dvn:preflight` both warn about it.
OPERATOR_ADDRESS=

# Public defaults work but rate-limit. Use your own endpoints for anything sustained.
RPC_URL_BASE_SEPOLIA=https://sepolia.base.org
RPC_URL_OPTIMISM_SEPOLIA=https://sepolia.optimism.io

# Populated after `lz:deploy` (Phase 8):
# Populated AFTER deploying ComplianceDVN on each chain, and required BEFORE wiring.
# `layerzero.config.ts` refuses to wire without them: wiring a placeholder succeeds silently and
# then every message on that pathway is permanently unverifiable.
DVN_BASE_SEPOLIA=
DVN_OPTIMISM_SEPOLIA=
# DVN operator / worker tuning:
DVN_CONFIRMATIONS=5
POLL_MS=15000
CHECKPOINT_PATH=.context/dvn-checkpoint.json
# Operator-controlled flagged address for the veto demo (address you hold the key for):

# Optional — only read by the `demo:*` tasks, to show a vetoed transfer alongside a clean one.
# An address you hold the key for. The worker has its own TEST_DENYLIST, which is the one that
# actually causes the veto; this copy just drives the demo output.
TEST_DENYLIST=

# Worker tuning (DVN_CONFIRMATIONS, POLL_MS, CHECKPOINT_PATH, …) is NOT read here — it lives in
# worker/.env. It used to be listed in this file, where setting it had no effect.
8 changes: 7 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,10 @@ deployments

# Standalone Node 20 worker package: governed by its own toolchain
# (worker/tsconfig.json typecheck + vitest), not the root Next.js eslint config.
worker
worker

# Demo dashboard: plain browser-global scripts served statically — no modules, no Node,
# so the root TS/Next config has nothing true to say about them.
demo/dashboard
# Checked-in compiled artifacts (see demo/README.md) — generated, not hand-authored.
demo/prebuilt
3 changes: 1 addition & 2 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ body:
- Contracts (contracts/ComplianceDVN.sol, on-chain veto)
- Worker — service (worker/service.ts, verify/commit/veto loop)
- Worker — assess / risk engine (worker/assess/)
- Worker — tracker (worker/tracker/)
- CLI (pnpm cli assess / trace)
- Demo dashboard (demo/dashboard/)
- Deploy / wiring (hardhat tasks, layerzero.config.ts)
- Build / tooling / CI
- Documentation
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ docs/superpowers/

# presentation deck — kept local, not uploaded
presentation/

# worker runtime state (scan checkpoint, deferred queue, approvals) — per-deployment, not source
worker/.context/
10 changes: 8 additions & 2 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@ package.json
# Generated deployment artifacts (ABIs, solcInputs) — not hand-authored source.
deployments/

# Standalone Node 20 worker package: formatted via its own toolchain, not the root.
worker/
# Standalone Node 20 packages: formatted via their own toolchains, not the root.
worker/
indexer/

# Demo dashboard: hand-formatted static pages (2-space, embedded scripts); reformatting
# them to the root style is churn with no reader. Prebuilt artifacts are generated JSON.
demo/dashboard/
demo/prebuilt/
383 changes: 383 additions & 0 deletions BRANCH_DIFF.md

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ need them for live deploy/wire/demo flows (see the README).
| `contracts/` | `ComplianceDVN.sol` (the thin on-chain DVN) and friends |
| `worker/service.ts` | Always-on worker: watches `JobAssigned`, verifies/commits or vetoes |
| `worker/assess/` | Chain-independent risk engine — `assess()` / `combine()` over the merged denylist |
| `worker/tracker/` | Tier 1 observation-only route tracker |
| `worker/cli.ts` | `pnpm cli assess <addr>` / `pnpm cli trace <txHash>` |
| `demo/` | Demo assets: dashboard, decoy contracts, mint script (see `demo/README.md`) |
| `deploy/`, `tasks/` | Hardhat deploy scripts and operator tasks |
| `test/` | Foundry (`*.t.sol`) and Hardhat/Vitest tests |
| `layerzero.config.ts` | DVN wiring (required DVN, per-chain ULN config) |
Expand Down
233 changes: 233 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
# Deployment — testnet

Ordered sequence for bringing up the contracts, the indexer, and the worker. The order matters:
each step produces a value the next one needs, and two of the steps are hard to undo.

For day-2 operations (alerts, key rotation, recovery) see [worker/RUNBOOK.md](worker/RUNBOOK.md).

## Keys

Three distinct keys. Keeping them separate is not hygiene, it is the design:

| Key | Held by | Purpose | Must be funded |
| -------------------- | ------------------- | ----------------------------------------------------------- | ---------------- |
| **owner / deployer** | a human | deploys, and calls `approvePacket` to release held packets | yes, both chains |
| **operator** | the worker process | `submitVerification`, `commitVerification`, `recordVerdict` | yes, both chains |
| **feed signer** | the indexer process | signs published feeds | no |

`approvePacket` is `onlyOwner` precisely so the worker cannot release the packets it chose to
withhold. If owner and operator are the same key, that protection is gone — the worker could
approve its own holds. The deploy script warns when they collapse.

The owner key belongs in the CLI environment as `OWNER_PRIVATE_KEY`, **never** in the worker's
environment.

## 1. Root environment

```bash
cp .env.example .env
```

Set in `.env`:

- `PRIVATE_KEY` — the **owner/deployer** key (this is what hardhat deploys from)
- `OPERATOR_ADDRESS` — the worker key's _address_ (not its private key)
- `RPC_URL_BASE_SEPOLIA`, `RPC_URL_OPTIMISM_SEPOLIA` — override the public defaults if you have
your own endpoints; public RPCs rate-limit and the indexer polls continuously

Leave `DVN_BASE_SEPOLIA` and `DVN_OPTIMISM_SEPOLIA` empty for now — they are outputs of step 3.

## 2. Preflight

```bash
npx hardhat dvn:preflight --network base-sepolia
```

```bash
npx hardhat dvn:preflight --network optimism-sepolia
```

Checks the signer resolves, the RPC answers, the deployer is funded, the ReceiveUln is known, and
whether an existing deployment is compatible. Sends no transactions. Fix every `ERROR` and read
every `WARN` before continuing.

## 3. Deploy the DVN

Both chains. `OPERATOR_ADDRESS` must be set or owner and operator collapse.

```bash
npx hardhat deploy --network base-sepolia --tags ComplianceDVN
```

```bash
npx hardhat deploy --network optimism-sepolia --tags ComplianceDVN
```

Then put the two addresses into `.env` as `DVN_BASE_SEPOLIA` and `DVN_OPTIMISM_SEPOLIA`.

> A previously deployed ComplianceDVN cannot be reused. `submitVerification` gained verdict
> parameters and the contract is not upgradeable, so the old address exposes a different ABI.
> `dvn:preflight` detects this and says so.

## 4. Wire the pathway

This is the step that tells the ULN which DVN each pathway **requires**.

```bash
npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts
```

`layerzero.config.ts` throws if either `DVN_*` is unset rather than defaulting to a placeholder —
wiring a zero address succeeds silently and then every message on that pathway is permanently
unverifiable, because the required DVN has no code to verify with.

Confirm both sides:

```bash
npx hardhat dvn:status --network base-sepolia
```

Check `operator` is the worker address and `owner` is yours.

## 5. Indexer

```bash
cd indexer
cp .env.example .env
```

Set:

- `FEED_SIGNING_KEY` — a fresh key, used only for signing. Note its **address**; the worker needs it.
- `DVN_BASE_SEPOLIA`, `DVN_OPTIMISM_SEPOLIA` — from step 3
- `TRACKED_TOKENS` — the ERC-20s whose transfers build the graph. **Empty means no edges**, so the
feed will be valid, signed, and empty.
- `TOKEN_MINIMUMS` — `chain:token:minValue` per token, in the token's smallest unit. **Without an
entry a token never produces an inbound label**, so leaving this empty turns
`sanctions_1hop_inbound` off entirely. Outbound labels are unaffected.
- `POLICY_VERSION` — must equal the worker's `POLICY_VERSION` in `worker/assess/policy.ts` (currently `1`)

```bash
docker compose up -d
```

Migrations run at boot. Verify:

```bash
curl -s localhost:9091/feed/latest.json | head -c 400
```

A `503 no feed published yet` is expected until the first build (`FEED_REBUILD_MS`, default 10 min).
Check the logs for the boot warnings — they name any tracked token missing a threshold.

Get the signer address for the next step:

```bash
docker compose logs indexer | grep -i "indexer feed\|signer"
```

## 6. Worker

```bash
cd worker
cp .env.example .env
```

Set:

- `OPERATOR_PRIVATE_KEY` — the **operator** key (funded on both chains). The name differs from
the root `.env` deliberately: the worker rejects a bare `PRIVATE_KEY` and explains why, so
copying the root file here fails loudly instead of granting owner rights.
- `DVN_BASE_SEPOLIA`, `DVN_OPTIMISM_SEPOLIA` — from step 3
- `INDEXER_FEED_URL` — e.g. `http://<indexer-host>:9091/feed/latest.json`
- `INDEXER_SIGNERS` — the indexer's signer **address**. The worker refuses to boot with a feed URL
and no allowlist: ingesting unverified labels is worse than having none.
- `EMIT_VERDICT_EVENTS` — `block` by default, which means each veto costs a `recordVerdict`
transaction. Set it empty to record only `allow` (which rides along on `submitVerification` for
free).
- `TEST_DENYLIST` — an address you hold a key for, if you want to demo a veto

Do **not** set `OWNER_PRIVATE_KEY` here.

```bash
pnpm start
```

```bash
curl -s localhost:9090/readyz
```

The worker refuses to verify anything until it has a fresh risk store, so `readyz` failing at
first is the fail-closed design working, not a fault.

## 7. Smoke test

Send a clean transfer and watch it settle. `demo:send` is the convenience wrapper:

```bash
npx hardhat demo:send --network base-sepolia --dst opt --to 0x<recipient> --amount 1
```

Or the full LayerZero task, which takes explicit eids rather than a network:

```bash
npx hardhat lz:oft:send --src-eid 40245 --dst-eid 40232 --to 0x<recipient> --amount 1
```

The worker log should show `VERIFY submitted` then `COMMIT driven`. Or screen a specific
transaction without sending:

```bash
pnpm cli verify baseSepolia 0x<txhash> --dry-run
```

Then exercise a veto by putting a held address in `TEST_DENYLIST` and sending from it — expect
`VETO — withholding verification` and no delivery.

To exercise the manual-review path, set `OWNER_PRIVATE_KEY` in your **shell** (not the worker's
env) and:

```bash
pnpm cli pending
```

```bash
pnpm cli approve optimismSepolia 0x<payloadHash>
```

## Cross-checks that bite later

- **Operator gas on both chains.** `submitVerification` and `commitVerification` run on the
_destination_ chain, so a bidirectional pathway needs the operator funded on both.
- **`POLICY_VERSION` must match** between worker code and indexer env. A mismatched feed is
rejected whole, not reconciled — scores computed under different weights are not comparable.
- **Feed TTL vs rebuild interval.** `FEED_TTL_SEC` must exceed `FEED_REBUILD_MS` or a document can
expire before its replacement exists and screening flaps. Config enforces this.
- **Staleness vs refresh.** `MAX_DENYLIST_STALENESS_MS >= DENYLIST_REFRESH_MS`, likewise enforced.
- **`DEGRADED_MODE`.** Default `degrade` keeps verifying on OFAC/OpenSanctions alone when the feed
is unavailable. `halt` withholds everything instead. Decide deliberately.
- **Sourcify v1 is in a brownout.** The indexer targets v2; if you point `VERIFIER_URL` at a
self-hosted instance, make sure it serves `/v2/contract/{chainId}/{address}`.

## Dashboards

For a local stack, the indexer's compose file brings up Prometheus and Grafana with both dashboards
and both datasources already provisioned:

```bash
docker compose --profile observability up -d
```

Grafana on <http://localhost:3000> (loopback only, anonymous viewer). See
[indexer/README.md](indexer/README.md) for what the panels mean.

To wire them into existing monitoring instead, import:

- `worker/deploy/grafana-dashboard.json` — Prometheus only
- `indexer/deploy/grafana-dashboard.json` — needs both Prometheus and the indexer's Postgres;
the audit trail lives in Postgres and is deliberately not exported as metrics

Both pin their datasource variables to the uids `dvn-prometheus` / `dvn-postgres`; if yours are
named differently, repoint the variable once at the top of the dashboard.

Alerts: `worker/deploy/k8s/prometheusrule.yaml`.
26 changes: 12 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ direct-hit lookup over a denylist merged from four sources:
veto: blocked if any party is flagged. The live denylist built to 101 entries during
the demo.

`worker/tracker/` adds the Tier 1 observation-only tracker. `cli trace <txHash>`
reconstructs a route via the LayerZero Scan API and colors each endpoint with `assess()`.
Screening results, held packets, and owner actions are all surfaced in the demo dashboard
(`demo/dashboard/`), which links each verdict to its LayerZero Scan route.

## Deployed contracts

Expand Down Expand Up @@ -94,14 +94,11 @@ npx hardhat lz:deploy --ci --networks base-sepolia,optimism-sepolia --tags Comp
npx hardhat lz:oapp:wire --oapp-config layerzero.config.ts --ci
npx hardhat dvn:status --network base-sepolia # sanity

# Run the worker (always-on) + send demos
# Run the worker (always-on)
pnpm worker # screens both chains, verifies/commits or vetoes
npx hardhat demo:send --network optimism-sepolia --to <clean_addr> --dst base # delivers
npx hardhat demo:send --network optimism-sepolia --to $TEST_DENYLIST --dst base # vetoed

# One-shot CLI
pnpm cli assess <address>
pnpm cli trace <txHash> # Tier 1 route + risk coloring
# Demo dashboard (MetaMask sends, owner review, sanctions/graph views)
cd demo/dashboard && python serve.py # http://localhost:8080 — see demo/README.md
```

## Tests
Expand All @@ -111,8 +108,9 @@ Foundry (31): `ComplianceDVN` unit tests (fee, job, operator-gating, admin) and
one reverts `commitVerification` with `LZ_ULN_Verifying`, so the recipient balance
stays 0.

Vitest (16): `assess()`/`combine()`, the 81-byte header and OFT-message decoders, the
`JobAssigned` assignment filter, the durable checkpoint, and the tracker transform.
Vitest (worker + indexer): `assess()`/`combine()`, the 81-byte header and OFT-message decoders,
the `JobAssigned` assignment filter, the durable checkpoint, the deferred queue, the signed feed,
and the 3-hop proximity graph.

## CI / CD

Expand Down Expand Up @@ -160,11 +158,11 @@ verifying other OApps' packets on the shared endpoint.
contracts/ComplianceDVN.sol ComplianceDVN.t.sol + ComplianceDvnVeto.t.sol (veto proof)
contracts/ToyOFT.sol demo OFT
deploy/ hardhat-deploy scripts
demo/ demo assets: dashboard, decoy contracts, mint script (demo/README.md)
layerzero.config.ts requiredDVNs = our DVN, both directions
tasks/ dvn:status, demo:send
tasks/ dvn:status, dvn:preflight, dvn:verify-wiring
worker/assess/ Tier 0 risk engine (OFAC + OpenSanctions + mixers + test)
worker/chain/ header/message decoders, PacketSent scanner, verify/commit
worker/tracker/ Tier 1 LayerZero-Scan tracker
worker/service.ts always-on watcher (fail-closed) worker/cli.ts one-shot
worker/chain/ header/message decoders, PacketSent scanner
worker/service.ts always-on watcher (fail-closed)
docs/superpowers/ design spec + implementation plan
```
Loading
Loading