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 ofTenantCreatedevents (total signups).first_draws— count of DISTINCT tenant ids that emitted at least onePeriodDrawnin the range (i.e. activated: funded and billed at least once) — not the total number ofPeriodDrawnevents, since a tenant renewing monthly emits one every period and must not inflate this.agent_registrations/zk_registrations— counts ofAgentKeyRegistered/ZkAgentRegistered, registry-wide.topup_total_usdc_6dp— sum of everyToppedUp.amountin the range, in the payment token's raw base units (USDC, 6 decimal places — the same scaletenants().balance/tierConfigs().feealready use elsewhere in this tool).withdrawals— count ofBalanceWithdrawn, registry-wide (contrast the per-tenantTenantSnapshot::withdrawal_countabove).by_tier—tenants_createdbucketed 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 asadmin-added(Alert) /admin-removed(Notice).IssuerKeyRegistered/IssuerKeyDeregistered— folded intoissuer_keys(this registry has no enumerable view; the event log is the only source of truth); diffed asissuer-key-registered(Alert) /issuer-key-deregistered(Notice).AgentKeyRegistered/AgentKeyRevoked— folded intoagent_keys(also no enumerable view); diffed asagent-key-registered(Alert) /agent-key-revoked(Notice).ZkAgentRegistered/ZkAgentRevoked— folded intozk_agents(also no enumerable view); diffed aszk-agent-registered(Alert) /zk-agent-revoked(Notice).OriginEpochBumped— diffed asorigin-epoch-bumped(Alert, any direction — see below).BalanceWithdrawn— counted (withdrawal_count); a count increase is diffed asbalance-withdrawn(Alert). Deliberately NOT a raw balance-band comparison:drawPeriod(a routine renewal) legitimately shrinksbalancetoo, so comparing the balance itself would conflate a renewal with an attacker's withdrawal.balancestays in the snapshot as anInfo-only convenience field (balance-changed).TierChanged,ToppedUp,PeriodDrawn— folded intotier/balance; diffed asInfo-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.
createTenantseeds the tenant's owner as an admin with noAdminAddedevent — an events-only watcher under-reports the privileged set from block zero.grantor-watchrecovers it by probingisAdminover the union of the current NFT owner and every address that ever appears in anAdminAddedevent. - The
ownerOffallback. The current NFT holder carries full admin power with noisAdminbit and no event to mark it.grantor-watchalways unionsownerOfinto the privileged set, unconditionally — AND compares it baseline-vs-current directly, diffed astenant-owner-changed(Alert): a tenant-pass transfer hands over full admin power with noAdminAddedevent, 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 itsmaxAdminsteam-seat cap, with no event.grantor-watchpollstierConfigs(tenant's current tier)every snapshot; diffed astier-disabled(Alert, enabled→disabled only — the reverse is an ordinary owner lever, not a security event) andmax-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#
- 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). - If it isn't expected: rotate the admin key.
grantor-watchcannot do this — it never sends a transaction — so this is a normaladdAdmin/removeAdmincall from a still-trusted admin or the tenant owner. - 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 asub, at the RP. - Re-run
baselineonce 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.