docs / guide

view as .md

Chain watcher — detect a compromised admin key#

grantor-watch (crates/grantor-watch) is a read-only operator binary: it polls one tenant's on-chain state on GrantorRegistry and alerts on drift against a baseline you recorded yourself — every call is eth_getLogs/eth_call, it never constructs a signer or sends a transaction. It watches; it cannot act. See below for the risk it targets and exactly what is watchable, poll-only, or invisible to it.

Workflow#

All four subcommands read the same three required env vars, plus one optional one:

export RPC=http://127.0.0.1:8545
export CONTRACT_ADDR=0x...
export TENANT=1
export START_BLOCK=0   # optional, default 0 (genesis)

START_BLOCK exists because every poll re-scans eth_getLogs from there to head — on a long-lived deployment, genesis→head every 30s is thousands of calls forever. Set it to your deployment/tenant's block to bound the scan.

⚠️ Correctness constraint, not a mere performance knob: START_BLOCK MUST be at or before the tenant's createTenant block. The three event-only registries (issuer_keys/agent_keys/zk_agents) and the admin set are reconstructed by folding history from START_BLOCK forward — starting later silently drops any registration made before it (a still-live key that predates START_BLOCK never appears; if that key is later revoked, the revoke would even underflow a set that never saw the register). Picking a value later than createTenant trades correctness for a smaller scan — don't.

grantor-watch diff/watch also refuse to run against a baseline recorded for a different (CONTRACT_ADDR, TENANT) pair than the current config — a stale or copy-pasted baseline would otherwise "diff" as plausible-looking drift instead of the operator error it actually is.

snapshot — poll once, print#

grantor-watch snapshot

Polls once and prints the full TenantSnapshot as pretty JSON. For a first look or debugging — not part of the baseline/diff loop.

baseline — record the known-good state#

grantor-watch baseline [--out PATH]

Polls once and writes the snapshot to PATH (default grantor-watch.baseline.json). Run this once, right after you've confirmed the admin set, origin epoch, and status are what you expect — every later alert is measured against this file, so record it deliberately, not as a leftover from testing a snapshot run.

diff — one-shot check against the baseline#

grantor-watch diff [--baseline PATH]

Polls once, diffs against the baseline, prints every drift as a JSON line. Exit code contract: non-zero only if an Alert-severity finding is present — a diff with only Notice/Info findings exits 0. Wire this into cron/CI and treat a non-zero exit as "page someone," not merely "there was output."

watch — poll loop#

grantor-watch watch [--interval SECS] [--baseline PATH]

Loads the baseline once, then polls every SECS (default 30) and prints alert JSON lines each tick. A transient RPC failure prints a poll-error alert line and the loop keeps running — it exits only on a fatal startup error (bad env, unreadable baseline), never on a chain hiccup.

summary — registry-wide funnel counts, no TENANT needed#

grantor-watch summary [--since-block N] [--out PATH]

Every subcommand above is pinned to one tenant (TENANT is required). summary is the one exception: it scans the WHOLE registry — every tenant — and prints one FunnelSummary JSON line, the GTM adoption funnel from public logs alone. It reads RPC/CONTRACT_ADDR/ START_BLOCK the same way the other subcommands do, but never TENANT — there is no single tenant to scope to, so it uses its own smaller config (SummaryConfig, not WatchConfig) rather than making TENANT optional on the struct the security subcommands share. --since-block (default 0/genesis) is the same escape hatch as START_BLOCK, just spelled as a flag since summary is normally a one-shot report, not a long-lived polling loop. --out PATH, if given, ALSO writes the same JSON line to a file (in addition to printing it) — for appending to a history file or feeding a dashboard.

{"tenants_created":2,"first_draws":1,"agent_registrations":1,"zk_registrations":0,"topup_total_usdc_6dp":5000000,"withdrawals":0,"by_tier":{"0":1,"1":1}}
  • tenants_created — count of TenantCreated events (total signups).
  • first_draws — count of DISTINCT tenant ids that emitted at least one PeriodDrawn in the range (i.e. activated: funded and billed at least once) — not the total number of PeriodDrawn events, since a tenant renewing monthly emits one every period and must not inflate this.
  • agent_registrations / zk_registrations — counts of AgentKeyRegistered / ZkAgentRegistered, registry-wide.
  • topup_total_usdc_6dp — sum of every ToppedUp.amount in the range, in the payment token's raw base units (USDC, 6 decimal places — the same scale tenants().balance/ tierConfigs().fee already use elsewhere in this tool).
  • withdrawals — count of BalanceWithdrawn, registry-wide (contrast the per-tenant TenantSnapshot::withdrawal_count above).
  • by_tiertenants_created bucketed by tier: which tiers are actually converting, not just the total.

⚠️ Partial-range undercount, not overcount. first_draws in particular: a tenant whose ACTUAL first draw happened before --since-block still counts here if any LATER draw (e.g. a renewal) falls inside the range — so over a partial range this is a lower bound on true first-draws, not an exact one, and a tenant that only ever drew before --since-block is invisible to this run entirely. Pick --since-block at or before every tenant's createTenant block for an exact count; a later value trades precision for a smaller scan, same tradeoff START_BLOCK makes for the security subcommands above — just here it under-counts a funnel metric instead of under-reporting a security-relevant set.

Why a separate signature list, not an extension of what watch/diff already fetch: summary reads TenantCreated plus a handful of the same events the security path already watches (PeriodDrawn, AgentKeyRegistered, ZkAgentRegistered, ToppedUp, BalanceWithdrawn) — but from a DIFFERENT signature list than the per-tenant subcommands use, not the same list widened. Folding TenantCreated into the per-tenant list would technically still filter correctly (its first indexed argument is also the tenant id, same as every other watched event), but every fold in this crate that walks WatchEvents written for the per-tenant path treats an unrecognized variant as inert — so nothing would break, it would just start silently carrying an event none of that code was written to reason about. Two lists keep that honest: an event only ever shows up where its consumer actually expects it. This is also why summary isn't part of the drift model the rest of this doc describes — see the module doc on summary.rs — it's an adoption funnel, not a security posture, and nothing in diff_snapshots depends on it.

Why read-only#

Never sending a transaction is not a convenience, it is the point: this is the same "no server, nothing trusted with power it doesn't need" thesis applied to operations. Running grantor-watch adds no infrastructure to the product — it's a binary you run on a cron, against a public RPC, for your own tenant.

The risk it targets#

GrantorRegistry has no per-tenant ban, suspend, or freeze. The same absence of a lever applies to a compromised tenant admin key: nothing on chain can revoke an admin except that tenant's own remaining admins or its owner. If an admin key is stolen, the chain will not stop it — it will only ever tell you, after the fact, that something happened.

Detection is the response. grantor-watch exists to make that "after the fact" arrive fast, not to prevent the thing happening. What you do with an alert — rotate the key, tell relying parties to stop trusting a sub — is yours to do; the tool has no reach past reading the chain.

What is watchable, poll-only, and unwatchable#

Not all tenant state is equally visible on chain, and pretending otherwise would make this tool a false promise.

Watchable — emitted events, folded into the snapshot and diffed. Fetched with eth_getLogs from START_BLOCK→head (see above), decoded, and folded into either the reconstructed privileged set, one of the three event-only security registries, or a raw counter — every one of them compared baseline-vs-current by diff::diff_snapshots and surfaced as an Alert (grant) or Notice (revoke) finding:

  • AdminAdded/AdminRemoved — folded into the privileged admin set; diffed as admin-added (Alert) / admin-removed (Notice).
  • IssuerKeyRegistered/IssuerKeyDeregistered — folded into issuer_keys (this registry has no enumerable view; the event log is the only source of truth); diffed as issuer-key-registered (Alert) / issuer-key-deregistered (Notice).
  • AgentKeyRegistered/AgentKeyRevoked — folded into agent_keys (also no enumerable view); diffed as agent-key-registered (Alert) / agent-key-revoked (Notice).
  • ZkAgentRegistered/ZkAgentRevoked — folded into zk_agents (also no enumerable view); diffed as zk-agent-registered (Alert) / zk-agent-revoked (Notice).
  • OriginEpochBumped — diffed as origin-epoch-bumped (Alert, any direction — see below).
  • BalanceWithdrawn — counted (withdrawal_count); a count increase is diffed as balance-withdrawn (Alert). Deliberately NOT a raw balance-band comparison: drawPeriod (a routine renewal) legitimately shrinks balance too, so comparing the balance itself would conflate a renewal with an attacker's withdrawal. balance stays in the snapshot as an Info-only convenience field (balance-changed).
  • TierChanged, ToppedUp, PeriodDrawn — folded into tier/balance; diffed as Info-severity (tier-changed, balance-changed) — routine billing activity an operator already expects.

Poll-only — views with no event, read (and diffed) on every snapshot. The contract has real blind spots events alone cannot cover:

  • The founding admin. createTenant seeds the tenant's owner as an admin with no AdminAdded event — an events-only watcher under-reports the privileged set from block zero. grantor-watch recovers it by probing isAdmin over the union of the current NFT owner and every address that ever appears in an AdminAdded event.
  • The ownerOf fallback. The current NFT holder carries full admin power with no isAdmin bit and no event to mark it. grantor-watch always unions ownerOf into the privileged set, unconditionally — AND compares it baseline-vs-current directly, diffed as tenant-owner-changed (Alert): a tenant-pass transfer hands over full admin power with no AdminAdded event, so this is the only place that grant is visible at all.
  • Silent disableTier/setTierConfigFull. These can flip an EXISTING tenant's tier to disabled, or shrink its maxAdmins team-seat cap, with no event. grantor-watch polls tierConfigs(tenant's current tier) every snapshot; diffed as tier-disabled (Alert, enabled→disabled only — the reverse is an ordinary owner lever, not a security event) and max-admins-changed (Alert, either direction).

Also polled every snapshot, because none of it is event-backed: status() (time-derived from periodEnd/graceWindow — it can change with no transaction at all, just the passage of time; diffed as status-inactive or status-grace, both Notice — payment has lapsed either way), graceWindow() (diffed as grace-window-changed, Alert), paused() (diffed as registry-paused/registry-unpaused, Alert either direction), and the registry contract owner() (diffed as registry-owner-changed, Alert — note this is the registry contract's Ownable owner, a DIFFERENT field from the tenant NFT's ownerOf above).

origin_epoch is diffed with !=, not >: a decrease is never a legitimate on-chain transition (bumpOriginEpoch only increments), so it means the diff is running against the wrong registry/RPC or hit a reorg — either of which deserves to scream exactly as loud as a real bump.

Unwatchable — origin vouches emit no event at all. An origin vouch (see Sovereign tier) is an off-chain-signed object; the only on-chain surface it ever touches is a read, isOriginVoucher(id, signer, epoch) — a pure view over isAdmin and originEpoch. Signing one is not a transaction, so there is no log to subscribe to and nothing grantor-watch can watch directly. A compromised admin key can sign a vouch for a hostile origin and this tool has no way to observe that signature come into existence.

What is visible is the fingerprint left by the way an operator actually recovers from it: bumpOriginEpoch invalidates every vouch signed before it and emits OriginEpochBumped (watched directly, Alert severity), and any admin-set change the compromise caused shows up as an admin-added or admin-removed finding. Treat an unexpected OriginEpochBumped or an admin-set delta as the alarm for a compromised admin key — do not expect to ever see a hostile vouch itself.

On alert#

  1. Confirm the finding is real, not a change you made and forgot to re-baseline (e.g. you added an admin on purpose — re-run baseline).
  2. If it isn't expected: rotate the admin key. grantor-watch cannot do this — it never sends a transaction — so this is a normal addAdmin/ removeAdmin call from a still-trusted admin or the tenant owner.
  3. Tell relying parties to stop trusting the compromised admin's sub — the tool has no reach into any RP's deployment; that step is yours, the same way you'd handle it for ordinary API access: an RP blocks a sub, at the RP.
  4. Re-run baseline once the tenant is back in a known-good state, so future diffs measure from there, not from the compromise.

See also#

  • Sovereign tier — the origin-vouch mechanism this tool cannot watch directly.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.