Skip to content

feat(Keystore): per-actor two-step revocation, replacing the account-wide lock [STRAWMAN] - #89

Draft
ilikesymmetry wants to merge 1 commit into
mainfrom
feat/per-actor-revoke-delay
Draft

feat(Keystore): per-actor two-step revocation, replacing the account-wide lock [STRAWMAN]#89
ilikesymmetry wants to merge 1 commit into
mainfrom
feat/per-actor-revoke-delay

Conversation

@ilikesymmetry

Copy link
Copy Markdown
Collaborator

STRAWMAN / pre-PPS — do not merge. This is a concrete strawman to make the proposal tangible for discussion and a PPS. Every changed symbol carries a STRAWMAN (pre-PPS) marker. Full test suite passes (385) and forge fmt is clean, but the design surface (spec, node behavior, storage layout) is deliberately up for debate.

Problem

A high-throughput account (canonically an x402 facilitator with many load-balanced signing keys) cannot add or rotate a key without leaving high-throughput mode. The account-wide lock freezes all actor changes, so key rotation means unlock → wait out the delay → rotate → re-lock, dropping the tier for the whole window. For a permissioned high-throughput account (Shopify Operator, the Cloudflare equivalent) the "run two accounts and reroute traffic" workaround doesn't apply.

Insight

The lock was doing two unrelated jobs. Splitting them removes the coupling:

Job Before After
Sender-tier front-run/DOS safety (an in-flight signature can't be revoke-front-run) account-wide config freeze per-actor revoke delay in Keystore
Payer-tier balance predictability (ETH-out blocked so balance is mempool-predictable) lock-gated ETH block, lock state read from Keystore self-contained lock inside the allowlisted CanonicalHighRatePayerAccount

Adding a key never invalidates a peer's in-flight tx, so adds are immediate. Removing a key only needs to protect that key's in-flight signatures, so revokes are two-step per-actor. Neither needs an account-wide freeze.

Keystore changes

  • ActorConfig: add bool pendingRevoke; rename expiryrevokeDelayOrExpiry (union).
    • pendingRevoke == false: actor live; field is a revoke delay (seconds), bounded by MAX_REVOKE_DELAY. This value is the per-actor guarantee a node reads for the sender tier ("this signer can't be pulled faster than N seconds").
    • pendingRevoke == true: field is an absolute expiry. Pre-scheduled session keys / policy leases authorize directly with pendingRevoke = true.
  • RevokeActor: two-step. Flips pendingRevoke and converts the stored delay into an absolute expiry (revokeDelayOrExpiry += block.timestamp); the actor stays live through the window. AlreadyRevoking guards re-initiation. Emits ActorRevokeInitiated.
  • Removed: Lock/Unlock change types, AccountState.lockUnion, FLAG_LOCKED, FLAG_UNLOCK_INITIATED, isLocked/getLockStatus, onlyUnlocked, and the lock events/errors. applySignedAccountChanges has no lock gate or standalone-op rule anymore.
  • Typehashes / import digest / ActorAuthorized packing updated for the new field. Inline k1 self revocation stays immediate (flag flip) — delayed revoke for the self key is a noted follow-up.

Account change (CanonicalHighRatePayerAccount)

Absorbs the payer freeze in its own storage (slot 0, packed): lock(unlockDelay), initiateUnlock(), isLocked(), getPayerLockStatus(). Outbound ETH is blocked while locked, exactly as before — but with no KEYSTORE dependency. Since nodes already allowlist this bytecode to grant the payer tier, they read this contract's slot directly. This keeps Keystore agnostic to any one account variant's balance-predictability needs.

Safeguards & known trade-offs

  • Self-brick bound: MAX_REVOKE_DELAY (strawman: 7 days) caps a live actor's revoke delay so an admin can't mint an effectively-unremovable actor.
  • Durability: a bare revoke is still not durable while a replayable JIT authorize is outstanding — durable teardown requires an IncrementLocalEpoch pairing (same reduction axiom as today; documented + tested).
  • Normative surface: this changes the actor_config / AccountState layouts and the node's rate-limit tiering read (from an account slot to the signing actor's config slot). That's the real reason it would push Cobalt, and why it wants a PPS + a call.

Open questions for the PPS

  1. MAX_REVOKE_DELAY value.
  2. Should the payer account's unlock be admin-only (scope 0) rather than any authorized caller?
  3. Delayed revocation for the inline k1 self key (kept immediate here).
  4. Node tiering: confirm reading the per-actor slot for the sender tier is acceptable to the EL/node team.

Testing

forge test → 385 passing; forge fmt --check clean. New coverage: two-step revoke lifecycle, AlreadyRevoking, RevokeDelayTooLong ceiling, pre-scheduled expiry beyond the ceiling, the end-to-end key-rotation-without-lock scenario, and the account's self-contained lock/unlock lifecycle.

…ocation [STRAWMAN]

Pre-PPS strawman for discussion — do not merge.

Motivation: the account-wide lock forces high-throughput accounts (e.g. an
x402 facilitator) to drop out of high-throughput mode to add or rotate a
signing key, since actor changes are frozen while locked. This decouples the
two jobs the lock was doing:

- Sender-tier front-run/DOS safety becomes a per-actor property. ActorConfig
  gains `pendingRevoke` and renames `expiry` to the `revokeDelayOrExpiry`
  union: while live (pendingRevoke=false) the field is a revoke delay bounded
  by MAX_REVOKE_DELAY; RevokeActor is now two-step (flips pendingRevoke and
  converts the delay to an absolute expiry), so an in-flight signature settles
  inside its window. Adding a key is immediate and never invalidates peers, so
  accounts rotate keys without losing throughput.

- Payer-tier balance predictability moves entirely into the allowlisted
  CanonicalHighRatePayerAccount (self-contained lock + unlock delay + ETH-out
  block + a node-readable status view). Keystore no longer knows about it.

Keystore removals: Lock/Unlock change types, lockUnion, FLAG_LOCKED,
FLAG_UNLOCK_INITIATED, isLocked/getLockStatus, onlyUnlocked, and the lock
events/errors. Pre-scheduled session keys authorize directly with
pendingRevoke=true. Durable revoke still requires an IncrementLocalEpoch
pairing (documented). Inline k1 self revocation stays immediate (follow-up).

Open questions for the PPS: MAX_REVOKE_DELAY value; whether unlock should be
admin-only on the payer account; delayed revoke for the inline self.

All 385 tests pass; forge fmt clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@ilikesymmetry
ilikesymmetry force-pushed the feat/per-actor-revoke-delay branch from 6ec5530 to 61535a5 Compare August 21, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant