Operate19
Self-hosting & deployment
CompliR is one Next.js app, one SQLite/libSQL database, nine background loops and an optional sanctions container. Everything is env-driven; nothing is hard-coded. Without keys every external loop reports dry-run and the app still answers verdicts.
Run locally
cp .env.example .env.local # Arc mainnet 5042 values are active; X402_DISABLED=1 for dev
docker compose -f docker/yente/docker-compose.yml up -d # OpenSanctions matcher (optional; degrades gracefully)
npm install && npm run dev # http://localhost:3000
npm test · npm run typecheck · npm run lint · npm run build · cd contracts && forge testWithout ATTESTER_PRIVATE_KEY the process generates an ephemeral attester key at start (signatures still verify, but change on restart). Without an LLM provider (LLM_PROVIDER=0g + ZEROG_API_KEY, or ANTHROPIC_API_KEY) the analyst and operator run their deterministic fallbacks. Without yente, sanctions lookups contribute their neutral value.
Environment reference
Network
- ARC_CHAIN_ID
- 5042 (mainnet) · 5042002 (testnet)
- ARC_RPC_URL
- https://rpc.mainnet.arc.io (public, no key; eth_getLogs ≤ 10,000 blocks / 2,000 results per query)
- ARC_EXPLORER_URL
- https://explorer.arc.io
- ARC_BLOCKSCOUT_API
- optional; the explorer API sits behind a Cloudflare challenge for non-browser clients, leave empty → RPC fallback
- USDC_ADDRESS
- 0x3600000000000000000000000000000000000000 (both networks)
- REGISTRY_ADDRESS
- AttestationRegistry; empty until deployed
- PROTOCOL_BLOCKLIST_ADDRESS
- empty; no protocol-level blocklist contract is documented
Evidence tunables (optional)
- ARC_USDC_EMITTER / _DECIMALS
- system emitter 0xffff…fffE at 18 dp (default); tracing the ERC-20 address would miss native sends
- EVIDENCE_LOOKBACK_BLOCKS
- 50000 (≈ 7 h of history per traced wallet at ~2 blocks/s)
- EVIDENCE_GETLOGS_CHUNK
- 9999 (public RPC hard cap is 10,000)
- EVIDENCE_SOURCE_TIMEOUT_MS
- 4000 per source
- YENTE_URL / YENTE_DATASET / YENTE_THRESHOLD
- http://localhost:8000 · default · 0.7
- OVERRIDES_PATH / OVERRIDES_JSON
- .data/overrides.json - optional evidence overrides merged over chain facts, for tests only; empty on the hosted instance (no synthetic evidence)
Keys and data
- ATTESTER_PRIVATE_KEY
- signs EIP-712 attestations and daily reports; sends registry writes in local signer mode
- LLM_PROVIDER / ZEROG_API_KEY / LLM_MODEL (or ANTHROPIC_API_KEY)
- Risk Analyst + operator; default model claude-sonnet-5-5
- DATABASE_URL / DATABASE_AUTH_TOKEN
- file:./.data/complir.db (libSQL; a Turso URL + token for hosted)
Payments (x402)
- X402_DISABLED
- 1 = free; 0 = charge
- X402_NETWORK / X402_PAY_TO
- CAIP-2 (defaults to eip155:$ARC_CHAIN_ID) · revenue address
- X402_FACILITATOR_URL
- https://api.circle.com/v1/facilitator/x402
- X402_FACILITATOR_API_KEY | X402_SELLER_PRIVATE_KEY
- exactly one: Circle Console key, or a seller proof signed by the payTo key
- X402_ASSET_NAME / _VERSION / X402_PENDING_POLLS
- USDC / 2 / 3
- PRICE_SCREEN_USDC / PRICE_WATCH_USDC
- 0.01 / 0.05
Ops
- COMPLIR_PAUSED
- 1 = kill switch: screens answer 503 SERVICE_PAUSED, operator holds
- ANCHOR_EVERY_N
- 50 ledger entries per Merkle anchor
- PUBLIC_BASE_URL
- absolute URL used in x402 resources, EDD links, badges and ERC-8004 metadata
- OWN_WALLETS
- comma-separated; excluded from /numbers and listed in every report
- CRON_SECRET
- bearer for every /api/internal/* route
Loops
- TREASURY_*
- MODE (live), PRIVATE_KEY, GUARDED_TREASURY_ADDRESS, MAX_TIER (MEDIUM), PER_PAYOUT_CAP_USDC (25), DAILY_CAP_USDC (50), REQUIRE_ONCHAIN
- TRUSTMAP_*
- MIN_USDC (25), REGRADE_HOURS (24), MAX_PER_TICK (5), SEED_DEMO (1)
- YIELD_*
- BACKEND (earn | usyc), MODE (dry), FLOAT_USDC (5), MIN_SWEEP_USDC (1), MAX_SWEEP_USDC (25), MIN_TVL_USDC (100000), REQUIRE_SENTINEL (1), LIQUIDITY_MULTIPLE (2), VAULT (pin); docs/YIELD.md
- USYC_*
- (YIELD_BACKEND=usyc only) MODE (dry), FLOAT_USDC (5), MIN_SWEEP_USDC (1); REVENUE_WALLET / REVENUE_PRIVATE_KEY; USYC_ADDRESS / TELLER / ENTITLEMENTS default to Arc mainnet
- EVENTS_*
- TICK_MS (20000), MAX_BLOCKS (500), MAX_LAG (5000), RPC_BUDGET (12), SCREENED_DAYS (30)
- NETWORK_*
- WINDOW_DAYS (7), MIN_CONCENTRATION (3)
- OPERATOR_*
- RAISE_CALLS_24H (50), MAX_P95_MS (8000)
- REFUND_*
- PRIVATE_KEY, DAILY_CAP_USDC (5), MAX_ATTEMPTS (3), AUTO_PROCESS (1)
- TIPS_* / TIP_BOUNTY_USDC
- STAKE_USDC (0.05), BOUNTY 0.25, DAILY_CAP_USDC (5), PAYOUT_PRIVATE_KEY, STAKE_GRACE_SECONDS (600)
- REPORT_NOTES_PATH / REPORT_INTEGRATIONS_DIR
- .data/report-notes.json · docs/integrations
Circle and identity
- SIGNER_MODE
- local | circle (circle relays attest() through the ops wallet; falls back to local)
- CIRCLE_API_KEY / CIRCLE_ENTITY_SECRET
- both required for anything but dry-run
- CIRCLE_WALLET_SET_ID / _NAME / ACCOUNT_TYPE / FEE_LEVEL / TX_TIMEOUT_MS
- complir · SCA (Gas Station-sponsorable) · MEDIUM · 120000
- CIRCLE_OPS_POLICY_* / CIRCLE_REVENUE_POLICY_*
- app-enforced per-tx / daily caps and allowlists: ops 1 / 10 USDC, revenue 25 / 100 USDC
- ERC8004_IDENTITY_REGISTRY / _OWNER_PRIVATE_KEY / _AGENT_ID
- 0x8004A169… on Arc mainnet · falls back to the attester key · set after registration
The full file with comments is .env.example; src/lib/config.ts validates the core set with zod and treats blank values as unset.
Vercel and crons
The app deploys as a standard Next.js project. Background work runs as Vercel crons that hit the internal routes with CRON_SECRET; the Vercel cron schedule is the floor, and each route runs exactly one tick.
// vercel.json - each path is POST|GET /api/internal/<worker> with Authorization: Bearer $CRON_SECRET
{ "crons": [
{ "path": "/api/internal/watcher", "schedule": "*/10 * * * *" },
{ "path": "/api/internal/trustmap", "schedule": "*/5 * * * *" },
{ "path": "/api/internal/treasury", "schedule": "*/15 * * * *" },
{ "path": "/api/internal/usyc", "schedule": "0 * * * *" },
{ "path": "/api/internal/operator", "schedule": "30 * * * *" },
{ "path": "/api/internal/events", "schedule": "*/5 * * * *" },
{ "path": "/api/internal/refunds", "schedule": "*/10 * * * *" },
{ "path": "/api/internal/tips", "schedule": "*/15 * * * *" },
{ "path": "/api/internal/report", "schedule": "5 0 * * *" }
] }| Loop | Cron | Worker | What one tick does |
|---|---|---|---|
| watcher | 10 min | 60 s | re-screen due watches, push tier.changed, anchor the ledger when ANCHOR_EVERY_N is reached |
| trustmap | 5 min | loop | tail new blocks, grade ≤ 5 recipients ≥ 25 USDC, re-grade after 24 h |
| events | 5 min | 20 s | poll USDC Blacklisted / UnBlacklisted logs, re-screen affected addresses, webhooks |
| treasury | 15 min | loop | screen + decide + (dry-run or live) release pending invoices |
| operator | hourly :30 | own interval | snapshot → candidates → model → execute → one OPERATOR entry |
| tips | 15 min | 5 min | verify submitted tips, pay bounties, drop stale stakes |
| refunds | 10 min | via report worker | send queued refunds up to the daily cap |
| usyc | hourly | hourly | plan DEPOSIT / REDEEM / NONE into a Circle Earn vault; NOOP · YIELD_PLAN, or OPERATOR · YIELD_SWEEP / YIELD_REDEEM when it moved funds (live only) |
| report | 00:05 UTC | 00:05 UTC | generate, hash, sign and ledger the previous day |
# Long-running alternative to crons (Railway / Fly / a VM). Each loop calls the same function as its route.
npx tsx --env-file=.env.local worker/watcher.ts # every 60 s: due re-screens, webhooks, anchor-if-due
npx tsx --env-file=.env.local worker/trustmap.ts # tail Arc, grade recipients
npx tsx --env-file=.env.local worker/events.ts # blocklist reactor, EVENTS_TICK_MS = 20 s
npx tsx --env-file=.env.local worker/treasury.ts # guarded payouts
npx tsx --env-file=.env.local worker/operator.ts # operator ticks on its own nextCheck interval
npx tsx --env-file=.env.local worker/tips.ts # verify + pay tips, 5 min
npx tsx --env-file=.env.local worker/usyc.ts # hourly yield tick (Circle Earn)
npx tsx --env-file=.env.local worker/report.ts # 00:05 UTC report, back-fills on start, drains refunds every 5 minHosting is pending; the local database is a file, so a hosted deployment needs a libSQL URL (DATABASE_URL + DATABASE_AUTH_TOKEN) that every cron invocation can reach.
yente (OpenSanctions)
docker compose -f docker/yente/docker-compose.yml up -d # yente 5.5.0 + single-node Elasticsearch 9.4.2, 2 GB heap
curl -s http://localhost:8000/healthz # first start downloads + indexes the sanctions collection (~5–10 min)
curl -s http://localhost:8000/catalog | head -c 600
# refresh: curl -X POST "http://localhost:8000/updatez?token=$YENTE_UPDATE_TOKEN&sync=true" (yente also polls hourly)docker/yente/manifest.yml indexes only the sanctions collection so the first build is small; switch to scope: default and a 4 GB heap for PEPs and the rest. CompliR queries /match/{dataset} with CryptoWallet.publicKey for every subject and funder and a name query when a name was supplied; a result counts when match is true or the score reaches YENTE_THRESHOLD. The public catalog is CC BY-NC 4.0; commercial use needs an OpenSanctions delivery token. The mainnet checklist covers what to flip after the app is up.