# 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.

```json
{"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_tier` — `tenants_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 `WatchEvent`s 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](../sovereign-tier.md)) 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](../sovereign-tier.md) — the origin-vouch mechanism this
  tool cannot watch directly.
