Skip to content

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 publishes

Environment

VariableDefaultMeaning
CHAINtestnetmainnet (4663) or testnet (46630)
RPC_URLchain default RPCUse 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_ADDRESSdeployed SentinelRegistry
PRIVATE_KEYallowlisted updater key (needs gas ETH when publishing)
DRY_RUNtruelog intended transactions instead of sending
WEBHOOK_URLoptional; POSTed on every flag change
POLL_INTERVAL_MS60000cycle interval for pnpm dev
FAIL_RAISE_AFTER3consecutive 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_BLOCKS36000on 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 26h with per-feed heartbeatS overrides in config.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.ts is 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. See infra/aws/README.md for the deploy, go-live cutover, and key-rotation runbooks. Derive the KMS key's address with pnpm cli kms-address.
  • Otherwise: run pnpm once from cron (or pnpm dev under 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_URL at 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).

Unaudited, day-one software. MIT licensed.