Appearance
Architecture
Sentinel is deliberately small: five read-only observers, one pure decision function, one diff-and-batch submitter, one registry contract. Everything that can be pure, is — the decision reducer and the session calendar have no I/O and are unit-tested exhaustively.
Stock tokenspause · oraclePause · multiplier
AccessControlsRegistryblocklist · roles · upgradeTo (beacon)
Chainlink feedsRH* / USD · 24/5
Block productionsequencer lag
Session calendarweekends · US holidays
▼ observations (typed, side-effect-free)
decision.tspure reducer: observations + current words → desired words
▼ desired flag words
submitterdiff vs on-chain · batch · DRY_RUN default
alertswebhook on every change
▼ setTokenFlags / setGlobalFlags / heartbeat
SentinelRegistry.solper-token + global words · updater allowlist · heartbeat
▼ statusOf(token) · isOperational(token, tolerated, maxAge)
Integratorslending markets · v4 hooks · vaults — revert on non-tolerated flags
The registry
SentinelRegistry stores two kinds of words:
- Per-token flags (bits 0–7), keyed by stock-token address.
- Global flags (bits 8–15), applied to every token:
statusOf(token)returns the union.
Writes are gated by an owner-managed updater allowlist (the owner itself is deliberately not an updater — publishing requires an explicit grant). Every mutating call refreshes the heartbeat; a no-op write (same word) skips the storage write and event but still counts as proof of liveness. Words are validated against the defined bit masks, so a buggy watcher can't set undefined bits.
The watcher
Each observer returns typed observations and performs no writes:
| Observer | Reads | Produces |
|---|---|---|
tokenWatcher | paused(), oraclePaused(), newUIMultiplier()/effectiveAt(), token events, burn transactions | per-token pause/multiplier/admin observations |
registryWatcher | AccessControlsRegistry events + paused() + implementation() | blocklist/role/upgrade/registry-pause observations |
feedWatcher | latestRoundData() per configured feed | staleness vs session-aware threshold |
sequencerWatcher | latest block timestamp vs wall clock | lag > 120s |
calendar | (pure) ET weekday + 2026 NYSE holiday table | in-session / closed |
The decision reducer folds observations and the current on-chain words into desired words. The submitter diffs desired against current, batches the changes (batchSetTokenFlags), and either logs them (DRY_RUN=true, the default) or publishes.
Level vs latching
Two flag classes, chosen per failure mode:
Level flags mirror live state and are recomputed every cycle: raised while the condition holds, cleared automatically when it stops. A token pause ends → the flag drops next cycle. (
TOKEN_PAUSED,ORACLE_PAUSED,FEED_STALE,MULTIPLIER_PENDING,REGISTRY_PAUSED,MARKET_CLOSED,SEQUENCER_DOWN)Latching flags mark discrete events whose risk outlives the event. An implementation upgrade is instantaneous on-chain, but the new code is unreviewed until a human reads it. The watcher only ever raises these; an operator clears them through the CLI after review, with the reason recorded in the event log. (
ADMIN_ACTION,IMPLEMENTATION_UPGRADED,BLOCKLIST_EVENT,ROLE_CHANGED)
One subtlety: a token with no configured feed can't have its staleness assessed, so the reducer preserves the current FEED_STALE bit rather than clearing what it cannot see.
Failure behavior
| Failure | What happens |
|---|---|
| Watcher process dies | Heartbeat ages out → every bounded consumer reverts (fail closed) |
| RPC flakes for one observer | Cycle errors are logged; words are only written from complete observations |
| Watcher key compromised | Owner revokes the updater; flags can be force-corrected by a new key |
| Registry owner compromised | Game over for v1 — see the trust model roadmap |