feat(Keystore): per-actor two-step revocation, replacing the account-wide lock [STRAWMAN] - #89
Draft
ilikesymmetry wants to merge 1 commit into
Draft
feat(Keystore): per-actor two-step revocation, replacing the account-wide lock [STRAWMAN]#89ilikesymmetry wants to merge 1 commit into
ilikesymmetry wants to merge 1 commit into
Conversation
…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
force-pushed
the
feat/per-actor-revoke-delay
branch
from
August 21, 2026 10:22
6ec5530 to
61535a5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
CanonicalHighRatePayerAccountAdding 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: addbool pendingRevoke; renameexpiry→revokeDelayOrExpiry(union).pendingRevoke == false: actor live; field is a revoke delay (seconds), bounded byMAX_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 withpendingRevoke = true.RevokeActor: two-step. FlipspendingRevokeand converts the stored delay into an absolute expiry (revokeDelayOrExpiry += block.timestamp); the actor stays live through the window.AlreadyRevokingguards re-initiation. EmitsActorRevokeInitiated.Lock/Unlockchange types,AccountState.lockUnion,FLAG_LOCKED,FLAG_UNLOCK_INITIATED,isLocked/getLockStatus,onlyUnlocked, and the lock events/errors.applySignedAccountChangeshas no lock gate or standalone-op rule anymore.ActorAuthorizedpacking 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 noKEYSTOREdependency. 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
MAX_REVOKE_DELAY(strawman: 7 days) caps a live actor's revoke delay so an admin can't mint an effectively-unremovable actor.IncrementLocalEpochpairing (same reduction axiom as today; documented + tested).actor_config/AccountStatelayouts 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
MAX_REVOKE_DELAYvalue.unlockbe admin-only (scope 0) rather than any authorized caller?Testing
forge test→ 385 passing;forge fmt --checkclean. New coverage: two-step revoke lifecycle,AlreadyRevoking,RevokeDelayTooLongceiling, pre-scheduled expiry beyond the ceiling, the end-to-end key-rotation-without-lock scenario, and the account's self-contained lock/unlock lifecycle.