Skip to content

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:

ObserverReadsProduces
tokenWatcherpaused(), oraclePaused(), newUIMultiplier()/effectiveAt(), token events, burn transactionsper-token pause/multiplier/admin observations
registryWatcherAccessControlsRegistry events + paused() + implementation()blocklist/role/upgrade/registry-pause observations
feedWatcherlatestRoundData() per configured feedstaleness vs session-aware threshold
sequencerWatcherlatest block timestamp vs wall clocklag > 120s
calendar(pure) ET weekday + 2026 NYSE holiday tablein-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

FailureWhat happens
Watcher process diesHeartbeat ages out → every bounded consumer reverts (fail closed)
RPC flakes for one observerCycle errors are logged; words are only written from complete observations
Watcher key compromisedOwner revokes the updater; flags can be force-corrected by a new key
Registry owner compromisedGame over for v1 — see the trust model roadmap

Unaudited, day-one software. MIT licensed.