FIP: Snapchain Signers #266
Replies: 2 comments 2 replies
-
|
This FIP directly addresses one of the most painful points in the current mini-app developer experience. The gas cost + wallet interaction requirement for key registration is a real conversion killer — I've seen users drop off at exactly that step during onboarding flows. What this unblocks for mini-app devs: The shift to EIP-712 offchain custody signatures processed by Snapchain means apps can register keys as part of normal sign-up flow without ever prompting a wallet gas transaction. For apps targeting users who are new to web3 (or just on mobile), this is significant. The Schedly/Supabase incident is also a good reference point here — self-revocation ( A few thoughts/questions from the dev side:
Overall this is a well-scoped, immediately valuable change. Separating it from the broader Functional Signers proposal and shipping it independently was the right call. |
Beta Was this translation helpful? Give feedback.
-
|
Updated the FIP to add scoping. This authorizes specific message types the signer can submit. We added this functionality to this FIP because we don't want to roll out offchain signers without this security feature. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Abstract
This proposal introduces key registration via custody-authenticated messages, eliminating the gas cost and friction of onchain Key Registry transactions. Users register and revoke keys by producing an EIP-712 signature from their custody address. Snapchain processes these as protocol messages, achieving the same end state as onchain registration at zero cost. This decouples key registration from onchain signer addition, removing it from the user onboarding critical path.
The same
KEY_ADDprimitive supports two first-class capabilities in addition to traditional full-authority signers:KEY_ADDmust explicitly enumerate theMessageTypevalues the key may sign. There is no "full authority" shortcut; a full-authority client must list every type explicitly. This is a fail-closed design, so adding newMessageTypevalues in the future does not implicitly grant them to existing keys.Motivation
Cost barrier
Each key currently requires an onchain transaction on Optimism. This costs gas, requires a wallet interaction, and demands the user wait for block confirmation. For miniapp developers trying to onboard users, this is a meaningful conversion barrier.
Custodial signer concentration
The cost barrier pushes miniapps toward a shared-signer model where a single app holds full signing authority for many users. This concentrates risk: a single server compromise exposes every user's signing capability.
Friction for developers
Onchain key registration requires developers to integrate wallet connection flows, manage gas sponsorship, and handle transaction failures. This complexity discourages experimentation and slows the pace of new app development.
All-or-nothing delegation
Today, every registered key has unrestricted, indefinite authority over every message type for the FID. There is no way to grant a miniapp permission to post casts but not change your profile, or to allow reactions but not verification messages. When a user authorizes a key for a miniapp, they grant the same authority they give to their primary client — an inappropriate trust model for lightweight integrations (frames, bots, tipping apps, mints).
Blast radius of compromise
If a miniapp's key is leaked, the attacker has full, indefinite signing authority over the user's FID. The Schedly incident demonstrated this precisely: the attacker had the same authority as the legitimate app. Scopes and sliding TTL together bound both the breadth and the lifetime of any compromise.
Why not MPC?
MPC/FROST threshold signing is a valid approach to key custody, but it introduces mandatory interactive signing rounds (2 round trips to a broker) for every signature. This conflicts with Farcaster's design goal of seamless programmatic signing — tips, mints, bots, and automated actions all depend on instant, local signing. Scopes plus sliding TTL deliver blast-radius reduction without adding per-signature friction. MPC remains complementary future work.
Specification
New message types
KeyAddBody
KeyRemoveBody
MessageData extension
EIP-712 domain and types
Domain: { name: "Farcaster KeyAdd", version: "1", chainId: <OP chain ID> } KeyAdd: [ { name: "fid", type: "uint256" }, { name: "key", type: "bytes" }, { name: "keyType", type: "uint32" }, { name: "scopes", type: "uint32[]" }, { name: "ttl", type: "uint32" }, { name: "nonce", type: "uint32" }, { name: "deadline", type: "uint256" } ] KeyRemove (custody, EIP-712): [ { name: "fid", type: "uint256" }, { name: "key", type: "bytes" }, { name: "nonce", type: "uint32" }, { name: "deadline", type: "uint256" } ] KeyRemove (self-revocation): Ed25519 signature over the same fields, signed by the key being removed.The
scopesandttlfields are part of the signed authorization payload so the custody signature binds to the exact permissions and lifetime being granted.FID resolution (KEY_ADD only)
KEY_ADDrequires a registered FID. The client is responsible for submitting the FID registration transaction on-chain before submitting theKEY_ADDmessage to Snapchain. Theregistration_tx_hashfield controls how the FID is resolved:If
registration_tx_hashis absent: The FID must already exist in Snapchain state (i.e., anIdRegisterEventhas been processed). If it does not exist, reject with a 400-level error.If
registration_tx_hashis present:IdRegisterEventfor this FID → FID is resolved, proceed to validationIdRegisterEvent→ reject (400)Mempool entries with a
registration_tx_hashare flushed when the corresponding on-chain event batch is ingested, or evicted after a TTL (e.g., 10 minutes) if the tx never settles.Transaction replacement detection
When a
KEY_ADDis accepted into the mempool with a pendingregistration_tx_hash, Snapchain records the sender address, account nonce, andrequestFid(app FID) from the pending transaction. Rather than polling on a timer, eviction is lazy: the replacement check runs the next time any request arrives for the same user FID or the same app FID (anotherKEY_ADD,KEY_REMOVE, self-revocation, or regular message). Snapchain checkseth_getTransactionCountfor the sender. If the sender's on-chain nonce has advanced past the recorded nonce without the expected tx hash settling, the original transaction was replaced — evict the mempool entry.Hooking into app FID activity is valuable because the app is typically more active than any individual user — an app registering keys for many users will naturally trigger replacement checks for all its pending entries. This makes the RPC cost zero in the common case (tx settles quickly, on-chain event flushes the entry) and provides frequent eviction opportunities via app activity. The TTL remains as a backstop for entries where neither the user nor app see further requests.
(Pending discussion with Arthur on whether this approach is sufficient or if active polling is also needed.)
Metadata validation (SignedKeyRequest)
For key registration via
KEY_ADD, themetadatafield is required withmetadata_type = 1(SignedKeyRequest). This contains an ABI-encodedSignedKeyRequestMetadatastruct — the same format used by the onchainSignedKeyRequestValidatorcontract:struct SignedKeyRequestMetadata { uint256 requestFid; // App FID that requested this key address requestSigner; // Custody address of the app FID bytes signature; // EIP-712 signature from requestSigner uint256 deadline; // Signature expiry }Onchain, the contract verifies this signature before emitting the event — Snapchain trusts the contract. For signers registered via
KEY_ADD, there is no contract in the loop, so Snapchain must verify theSignedKeyRequestsignature directly:metadata→ extractrequestFid,requestSigner,signature,deadlinedeadline >= current_block_timestampsignaturevia EIP-712 typed data recovery (same domain the contract uses)requestSignerrequestSigneris the custody address ofrequestFid(lookup fromIdRegisterEvent)Without this verification, an attacker could claim any
requestFid, enabling false app attribution and nonce consumption on another app's counter.The verified
requestFidis stored alongside the key registration and serves as theappFidfor downstream indexing (consistent with the on-chain path).Nonce scoping
Nonces are scoped by the entity authorizing the operation:
KEY_ADDand custodyKEY_REMOVE(signature_type = 1). The user's custody address is the authorizing entity.KEY_REMOVE(signature_type = 2). The app that holds the key is the authorizing entity. Scoped to therequestFidfromSignedKeyRequestMetadatastored at key registration time.This ensures an app's self-revocation activity does not consume the user's nonce counter. Spam protection for self-revocation is additionally provided by the active key state check — an app can only self-revoke keys that are currently active.
Scopes
The
scopesfield enumerates theMessageTypevalues a key is permitted to sign. It is required and must be non-empty for everyKEY_ADD. There is no "full authority" shortcut — callers that want a key with broad permissions must list everyMessageTypeexplicitly. This is a fail-closed design: if newMessageTypevalues are added to the protocol in the future, existing keys do not implicitly gain access to them. The custody signature binds to the exact set of scopes, so any change to a key's authority requires a newKEY_ADD.Any message whose type is not in the key's
scopesfails validation.Client libraries and SDKs should provide a convenience helper (e.g.,
allStandardTypes()) that expands to the current set of standard message types at call time. This gives callers a simple one-call UX equivalent to "full delegation" while keeping the on-wire representation explicit and the signed authorization unambiguous.scopes[CAST_ADD, CAST_REMOVE, REACTION_ADD, REACTION_REMOVE, LINK_ADD, LINK_REMOVE, VERIFICATION_ADD_ETH_ADDRESS, VERIFICATION_REMOVE, USER_DATA_ADD, FRAME_ACTION](all current standard types, explicitly listed)[CAST_ADD, CAST_REMOVE][REACTION_ADD, REACTION_REMOVE, LINK_ADD, LINK_REMOVE]Onchain keys registered via the existing Key Registry contract predate the
scopesfield. They are grandfathered as implicit full authority for backwards compatibility — their validation path is unchanged. This grandfather clause only applies to onchain keys; everyKEY_ADDmust carry explicit scopes.Regardless of
scopes, all keys are always blocked from signingKEY_ADDmessages at the mempool layer.KEY_ADDauthorization comes from the embeddedcustody_signature, not from the message signer — this keeps the mempool surface tight.KEY_REMOVEhas a separate carve-out described below.Sliding TTL
The
ttlfield enables OAuth2-style sliding expiration:ttl = 0→ the key never expires based on inactivity. This is the default and matches traditional signer behavior.ttl > 0→ the key is valid whilelast_used_at + ttl >= current_block_timestamp. Each successfully validated message signed by the key updateslast_used_attomessage.timestamp. There are no refresh tokens — continued use of the key is itself the renewal signal.last_used_atis initialized tomessage.timestampat the time ofKEY_ADD. A key registered with a TTL but never used will expirettlseconds after creation.Revocation (via
KEY_REMOVE, either custody or self-signature) is immediate and unaffected bylast_used_atorttl. A revoked key cannot be "reactivated" by any operation; it must be re-registered with a newKEY_ADD.The maximum allowed
ttlis 90 days (open question — see below). Keys withttl > 0andttl > MAX_TTLare rejected atKEY_ADDtime.Validation flow (KEY_ADD)
MessageDataand extractKeyAddBodymetadata_type == 1and validateSignedKeyRequestmetadata (see above)deadline >= current_block_timestampnonce > last_nonce_for_fid(per-FID monotonic counter, stored on shard 0)custody_signaturevia EIP-712 typed data recovery (over the full payload includingscopesandttl)IdRegisterEvent)key_type == 1(Ed25519)scopesis non-empty and every value is a validMessageTypeenum valuettl <= MAX_TTL(90 days)SIGNER_EVENT_TYPE_ADDttl > 0, initializelast_used_at = message.timestampValidation flow (KEY_REMOVE)
KEY_REMOVEsupports two authorization modes viasignature_type, reusing a single message type to minimize API surface:Custody revocation (
signature_type = 1): The FID owner revokes a key.MessageDataand extractKeyRemoveBodykeyis currently an active key for this FIDdeadline >= current_block_timestampnonce > last_nonce_for_fidsignaturevia EIP-712 typed data recoverySIGNER_EVENT_TYPE_REMOVESelf-revocation (
signature_type = 2): The app/entity that holds the key revokes it. This enables mass revocation when an app is compromised (e.g., the Schedly incident) without requiring each user to act individually. Self-revocation is always allowed regardless ofscopes— a scoped key whose scopes don't includeKEY_REMOVEmust still be able to revoke itself if compromised.MessageDataand extractKeyRemoveBodykeyis currently an active key for this FIDdeadline >= current_block_timestamprequestFid(app FID) stored at key registration timenonce > last_nonce_for_app_fid(app FID's nonce counter)signatureis a valid Ed25519 signature over the removal payload, signed bykeySIGNER_EVENT_TYPE_REMOVEValidation flow (messages signed by a key)
For every user message (cast, reaction, link, etc.), after the existing signature/hash checks, Snapchain performs:
(fid, message.signer)in the signer index. If absent, reject (MissingSigner).ttl > 0:last_used_atfor this key.last_used_at + ttl < current_block_timestamp, reject (expired).KEY_ADD): ifmessage_data.typeis not inscopes, reject.KEY_REMOVEsigned by the key being removed is permitted regardless ofscopes.ttl > 0, writelast_used_at = message.timestamp.Onchain keys perform a single read, identical to today. Signers registered via
KEY_ADDadd a scope containment check (microseconds). TTL'd keys additionally read and writelast_used_at.Conflict resolution
Signers registered via
KEY_ADDand onchain keys coexist:KEY_ADD, withKEY_ADDtaking precedence for ties) wins. Duplicate add is a no-op.Nonce management
There are two nonce counters, both monotonically increasing and stored on shard 0:
KEY_ADDand custodyKEY_REMOVE. Prevents replay and ensures ordering of user-authorized key operations.KEY_REMOVE. Scoped to therequestFidfromSignedKeyRequestMetadata. SincerequestFidis mandatory and verified (see metadata validation), every key has a valid app FID to scope against.If a message arrives with a nonce that has already been used or is less than the current nonce for its respective counter, it is rejected.
Storage and shard routing
Key operations via
KEY_ADDandKEY_REMOVEare routed to shard 0 and are never pruned. This ensures key state is always available for message validation across all shards.The primary record mirrors the existing onchain signer index and is extended to carry
scopesandttl:Signers registered via
KEY_ADDreuse the same secondary index (SignerByFid) so thatget_active_signer()works identically for both onchain andKEY_ADD-registered signers. Onchain keys predatescopesandttland are grandfathered: the scope check is bypassed for them, and they have no TTL. Signers registered viaKEY_ADDalways carry explicitscopes(required non-empty atKEY_ADDtime) and optionalttl.Nonce storage
Sliding-expiry storage
A new per-key index tracks
last_used_atfor keys withttl > 0:Only populated for keys where
ttl > 0. Keys withttl = 0skip this write on every validated message, keeping the hot path unchanged.Implementation in Snapchain
Key changes
MESSAGE_TYPE_KEY_ADD(16),MESSAGE_TYPE_KEY_REMOVE(17),KeyAddBody(withscopesandttl),KeyRemoveBodytomessage.proto.alloy_primitivesas a dependency and usesSignatureScheme::Eip712for address verification — this extends that capability to custody signature recovery over the extendedKeyAddpayload.src/core/validations/message.rs): Add validation forKeyAddBodyandKeyRemoveBody— check field presence, key length, deadline, scopes enum values,ttl <= MAX_TTL.src/mempool/mempool.rs,src/connectors/onchain_events/mod.rs): Implement theregistration_tx_hashflow — check on-chain event index, query L1/L2 RPC for pending txs, buffer in mempool, flush on event ingestion.src/storage/store/engine.rs,src/storage/store/block_engine.rs): HandleKEY_ADDandKEY_REMOVEin the message processing pipeline. These update the signer index (same secondary index used byget_active_signer()). For every validated user message, additionally checkscopes, check sliding expiry (ifttl > 0), and updatelast_used_aton success.KEY_ADDandKEY_REMOVEto shard 0.last_used_aton shard 0, populated only for keys withttl > 0.What doesn't change
get_active_signer()remains the entry point for the signer lookup — its return type grows to carryscopesandttl, but callers that don't care about those fields continue to work.Performance
KEY_ADD,ttl = 0KEY_ADD,ttl > 0last_used_at+ 1 write oflast_used_atKEY_ADDKEY_REMOVEAll operations are O(1) point reads/writes. The additional cost for scoped/TTL'd keys (~microseconds) is negligible compared to consensus and network latency, and is skipped entirely for traditional onchain signers.
Security Properties
Same trust model as onchain
Key registration via
KEY_ADDrequires the same custody address signature that the onchain Key Registry requires. The trust anchor is identical. The difference is that snapchain validates the signature directly instead of relying on an onchain contract.Replay protection
The per-FID nonce prevents replay attacks. A captured
KEY_ADDmessage cannot be replayed because the nonce will have already been consumed.Custody-signature deadline enforcement
The
deadlinefield in the custody signature prevents long-lived authorization payloads from being submitted later. If a user signs a key authorization but the app never submits it, the authorization becomes invalid after the deadline. This is separate from the key's ownttl— thedeadlinecontrols how long the authorization-to-register is valid, whilettlcontrols how long the registered key itself is valid.Blast radius containment
Scopes and sliding TTL together bound the impact of a key compromise:
last_used_at + ttl— and if the attacker stops using it, it expires on its own. If the attacker keeps using it, the legitimate app is likely to notice anomalous activity and revoke before further damage.Compare to a traditional onchain signer, where a compromise grants unrestricted, indefinite authority.
Transparent revocation
Custody can revoke any key immediately via
KEY_REMOVE(signature_type=1). A key can always revoke itself via self-signature (signature_type=2) regardless of its scopes — this remains true even for scoped keys whose scopes don't includeKEY_REMOVE, so that compromised keys can be terminated by the app that holds them.Standard signatures
Keys produce standard Ed25519 signatures. The
Messageenvelope,HashScheme, andSignatureSchemeare unchanged. Existing clients that verify signatures will still verify correctly. Theget_active_signer()lookup itself is also unchanged. What changes is (a) the set of keys in the signer index now includes signers registered viaKEY_ADD, and (b) validation applies additional scope and expiry checks after the lookup.Key operation rate limiting
KEY_ADDis subject to a dedicated rate limit, separate from regular message rate limits: 1 key add per FID per minute. This is enforced at the RPC ingestion layer.KEY_REMOVEis not rate limited — revocation of a compromised key should never be blocked.This rate limit is sufficient for legitimate usage (key registration happens once during onboarding, or during periodic rotation for TTL'd keys) while preventing spam. The rate limiter's per-FID check also serves as the hook for lazy eviction of stale mempool entries — when a
KEY_ADDarrives for a FID with a pendingregistration_tx_hash, the replacement detection check (see FID resolution) runs at the same time.(Rate limit value pending discussion with Arthur — 1/min is the starting proposal.)
Active key cap
The maximum number of active keys per FID is 1000, matching the on-chain KeyRegistry contract limit. This is a combined cap across on-chain and off-chain keys.
KEY_ADDis rejected if the FID already has 1000 active keys.No new key types
This proposal only supports Ed25519 keys (
key_type == 1), the same key type supported by on-chain registration.No interactive signing protocol
Keys — including scoped or TTL'd keys — require no network round trips to produce a signature. The holder has the complete private key. This preserves programmatic signing for tips, mints, bots, and automated workflows.
Comparison with MPC/FROST
These approaches are complementary. A future FIP could add MPC as an option for how key private material is custodied, while this proposal handles the delegation/scoping/lifetime layer. This proposal prioritizes the scope+TTL model because it delivers the most immediate ecosystem value with the least per-signature friction.
EIP-1271 (Smart Contract Wallets)
Smart contract wallet support via EIP-1271 is intentionally deferred. It requires snapchain to make onchain
isValidSignaturecalls during validation, which introduces external dependencies in the consensus-critical path. This deserves its own FIP with a detailed design for how validators handle these calls (caching, timeout, fallback behavior).Future Consideration: ZK-Gated Opaque Policies
The transparent ACL in this proposal (
scopes) is simple and auditable, but it requires policies to be visible on-chain. An alternative approach would use ZK proofs to enforce opaque (private) policies:KEY_ADD, the app commits to a policy as a hash (not plaintextscopes).scopes— enforcement is still at the protocol layer, but the policy itself stays private.This achieves non-transparent ACL enforcement without introducing a broker dependency. The app still holds the complete key and signs locally (no MPC latency), but cannot produce a message that passes validation without a valid proof of policy compliance.
This approach adds significant complexity (ZK circuit design for Snapchain state proofs, proof generation cost per message, verification cost at validators) and is deferred to a future FIP. It becomes more attractive if apps need richer or private policy semantics beyond per-
MessageTypescoping.Open Questions
KEY_ADDper FID per minute is the starting proposal. Pending discussion with Arthur.getSignersByFidshould return both on-chain and off-chain keys transparently, including theirscopes,ttl, and (if applicable)last_used_at. A user-facing UI listing active delegations is important for transparency.last_used_atindex. For high-throughput bots, this could be meaningful. Should we coarsen updates (e.g., only bumplast_used_atif the new value differs by more than N seconds from the previous)?MessageTypelevel. Future extensions could add target-level scoping (e.g., "only react to casts in this channel") but this adds significant complexity and is deferred.References
Beta Was this translation helpful? Give feedback.
All reactions