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

sh
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 test

Without 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.

json
// 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 * * *"    }
] }
LoopCronWorkerWhat one tick does
watcher10 min60 sre-screen due watches, push tier.changed, anchor the ledger when ANCHOR_EVERY_N is reached
trustmap5 minlooptail new blocks, grade ≤ 5 recipients ≥ 25 USDC, re-grade after 24 h
events5 min20 spoll USDC Blacklisted / UnBlacklisted logs, re-screen affected addresses, webhooks
treasury15 minloopscreen + decide + (dry-run or live) release pending invoices
operatorhourly :30own intervalsnapshot → candidates → model → execute → one OPERATOR entry
tips15 min5 minverify submitted tips, pay bounties, drop stale stakes
refunds10 minvia report workersend queued refunds up to the daily cap
usychourlyhourlyplan DEPOSIT / REDEEM / NONE into a Circle Earn vault; NOOP · YIELD_PLAN, or OPERATOR · YIELD_SWEEP / YIELD_REDEEM when it moved funds (live only)
report00:05 UTC00:05 UTCgenerate, hash, sign and ledger the previous day
sh
# 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 min

Hosting 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)

sh
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.