Appearance
Running the watcher
The watcher lives in watcher/ (TypeScript, viem). It observes the chain, reduces observations to desired flag words, and publishes diffs to the registry. It ships observe-only: DRY_RUN=true is the default, and nothing is written until you flip it.
Quick start
bash
# 1. deploy the registry (testnet first — chain id 46630)
UPDATER_ADDRESS=<watcher key address> forge script script/Deploy.s.sol \
--rpc-url https://rpc.testnet.chain.robinhood.com --broadcast \
--private-key $DEPLOYER_KEY --sender $DEPLOYER_ADDRESS
# 2. configure
cd watcher && cp .env.example .env # fill in registry address + updater key
# 3. observe-only soak (DRY_RUN=true is the default)
pnpm install
pnpm once # single cycle, then exit
pnpm dev # continuous loop
# 4. after the soak, set DRY_RUN=false so the watcher actually publishesEnvironment
| Variable | Default | Meaning |
|---|---|---|
CHAIN | testnet | mainnet (4663) or testnet (46630) |
RPC_URL | chain default RPC | Use a dedicated endpoint (e.g. Alchemy) in production — the public RPC is rate-limited, and a full cycle costs 100+ eth_calls across 25 tokens |
SENTINEL_REGISTRY_ADDRESS | — | deployed SentinelRegistry |
PRIVATE_KEY | — | allowlisted updater key (needs gas ETH when publishing) |
DRY_RUN | true | log intended transactions instead of sending |
WEBHOOK_URL | — | optional; POSTed on every flag change |
POLL_INTERVAL_MS | 60000 | cycle interval for pnpm dev |
FAIL_RAISE_AFTER | 3 | consecutive failed reads of a level flag before it is raised anyway (reads are tri-state: a failed read retains the on-chain bit, never clears it) |
REWIND_BLOCKS | 36000 | on a lost/corrupt state file, rescan this many blocks (~1h) instead of silently skipping to latest |
Observation semantics are fail-closed end to end: level-flag reads that error retain the current on-chain bit and escalate to a raise after FAIL_RAISE_AFTER blind cycles; latching raises observed during DRY_RUN persist in the state file until a live cycle lands them on-chain; reconciliation is per-bit (raiseTokenFlags/clearTokenFlags), so bits raised by an operator out-of-band are never stomped; and past the hardcoded calendar's coverage the watcher never asserts MARKET_CLOSED (staying on the tight staleness tier) while alerting every cycle.
The on-chain monitored set must match the watcher's token table: register tokens at deploy time (MONITOR_TOKENS env on Deploy.s.sol) or later via setMonitored(address[], true) (owner). Tokens left unregistered read as NOT_MONITORED and every integrating market correctly refuses to price them. The CLI refuses to clear NOT_MONITORED — it clears only by actually registering the token.
The operator loop
Latching flags (ADMIN_ACTION, IMPLEMENTATION_UPGRADED, BLOCKLIST_EVENT, ROLE_CHANGED) are raised by the watcher but only cleared by a human, with the reason recorded on-chain in the event log:
bash
pnpm cli status --token 0x322F0929c4625eD5bAd873c95208D54E1c003b2d
# status: ADMIN_ACTION|MARKET_CLOSED (0x1010)
# updatedAt: 2026-07-09T14:02:11.000Z
# heartbeat: 2026-07-09T14:03:05.000Z
pnpm cli clear-flag --token 0x322F… --flag ADMIN_ACTION --reason "reviewed: AP redemption"
pnpm cli clear-global --flag ROLE_CHANGED --reason "reviewed: expected ops rotation"Treat a latching flag like a page: something discrete happened (an upgrade, a seizure, a blocklist change) that a machine can detect but only a person can judge.
Calibration notes
- Feed staleness (26h default). Chainlink push feeds update on deviation or heartbeat, and the standard heartbeat is 24h. Low-volatility assets (SPY, SGOV) legitimately go many hours without updates mid-session — the first live mainnet run false-flagged them at a 1h threshold, which is why the default is
26hwith per-feedheartbeatSoverrides inconfig.ts. - Sequencer lag (120s). Robinhood Chain targets ~100ms blocks; two minutes of silence is far outside normal variance.
- Session calendar (2026 only). ⚠ The NYSE holiday table in
calendar.tsis hardcoded for 2026 and requires annual maintenance — add the following year's calendar before each new year. Early-close days are tracked for reference but treated as in-session under the 24/5 feed model. - Feeds not yet mapped. Tokens without a configured feed have staleness left unassessed (the reducer preserves the current bit rather than guessing). Resolve the remaining proxy-vs-aggregator pairs before relying on round IDs.
Production posture
- Reference deployment (AWS + KMS):
infra/aws/in the repo is a complete Terraform module — the updater key is a secp256k1 key inside AWS KMS (SIGNER=kms, no plaintext key anywhere), the watcher runs on an egress-only SSM-managed instance, and CloudWatch alarms fire if a cycle hasn't completed in 15 minutes. Seeinfra/aws/README.mdfor the deploy, go-live cutover, and key-rotation runbooks. Derive the KMS key's address withpnpm cli kms-address. - Otherwise: run
pnpm oncefrom cron (orpnpm devunder a supervisor); either way the heartbeat proves liveness on-chain, and consumers bound its age. - Fund the updater address modestly and monitor its balance — an empty key is a dead watcher, which consumers experience as a freeze (fail closed, by design).
- Point
WEBHOOK_URLat your alerting; every flag change posts a JSON payload with the human-readable reason. - Keep the operator CLI key separate from the watcher key if you want raise-and-clear separation of duties (both must be allowlisted updaters).