@@ -19,6 +19,7 @@ use super::{
1919 increment_gasless_key_count, init_last_used_at, put_gasless_key_owner, put_gasless_key_record,
2020 GaslessKeyRecord , OnchainEventStore ,
2121} ;
22+ use crate :: core:: error:: HubError ;
2223use crate :: core:: message:: HubEventExt ;
2324use crate :: core:: validations:: error:: ValidationError ;
2425use crate :: core:: validations:: key:: {
@@ -47,11 +48,28 @@ use crate::storage::store::engine::MessageValidationError;
4748/// which has to stage a write even on "success", but that stage is rolled back if any later
4849/// step fails (the whole txn batch is discarded together on error).
4950///
50- /// Per-FID gasless-key cap (1000) is enforced here (NEYN-10579): a KEY_ADD that would push the
51- /// per-FID gasless count to or past `MAX_GASLESS_KEYS_PER_FID` is rejected with
52- /// `ActiveKeyCapExceeded`. The count is tracked by a per-FID counter entry mutated alongside the
53- /// primary record. On-chain signers are explicitly not counted — they have their own cap at the
54- /// L2 KeyRegistry contract and are tracked under a separate storage namespace.
51+ /// ## Resubmission (NEYN-10624)
52+ ///
53+ /// A KEY_ADD for a key that this FID already owns in the gasless store is treated as a
54+ /// scope/TTL update rather than a collision. The update is authorized only when the new
55+ /// message's verified `requestFid` matches the stored `request_fid` on the existing record —
56+ /// i.e. only the originating requesting fid can modify its own key's scopes or sliding-TTL
57+ /// window. Mismatched owner FID surfaces as `KeyClaimedByDifferentFid` (cross-user take-over
58+ /// attempt) and mismatched `request_fid` as `KeyRegisteredByDifferentRequestingFid` (cross-app
59+ /// take-over attempt). The nonce CAS continues to enforce strict monotonicity, so a
60+ /// resubmission must carry a higher nonce than any prior KEY_ADD / KEY_REMOVE on the user
61+ /// namespace for this FID. On upsert, the previous KEY_ADD message rides along in the emitted
62+ /// `MergeMessageBody.deleted_messages`, mirroring the shape of KEY_REMOVE events.
63+ ///
64+ /// ## Per-FID gasless-key cap (NEYN-10579)
65+ ///
66+ /// A KEY_ADD that would push the per-FID gasless count to or past `MAX_GASLESS_KEYS_PER_FID`
67+ /// (1000) is rejected with `ActiveKeyCapExceeded`. The count is tracked by a per-FID counter
68+ /// entry mutated alongside the primary record. On-chain signers are explicitly not counted —
69+ /// they have their own cap at the L2 KeyRegistry contract and are tracked under a separate
70+ /// storage namespace. The cap is not enforced on the resubmission (upsert) branch: replacing
71+ /// an existing record is net-zero for the counter, so an FID already at the cap can still
72+ /// modify a key it already owns.
5573///
5674/// Pending-FID resolution via `registration_tx_hash` is still out of scope — owned by
5775/// NEYN-10577.
@@ -130,43 +148,83 @@ pub fn merge_key_add(
130148 return Err ( ValidationError :: InvalidSignature . into ( ) ) ;
131149 }
132150
133- // Step 8 (conflict resolution). The gasless by-public-key index is authoritative for
134- // "is this key already registered as a gasless signer?" — it's written and deleted in
135- // lock-step with the primary by-FID record (NEYN-10623), so any hit here — same FID
136- // (replay of a prior KEY_ADD) or different FID (cross-user collision) — is a reason
137- // to reject. Both cases surface as `KeyAlreadyRegistered`; the caller-actionable signal
138- // is just "you can't add this key again."
139- //
140- // Scope is gasless-only: this check does not consider on-chain signers, so a key that
141- // is an on-chain signer for a different FID does not block a gasless KEY_ADD here. The
142- // same-FID on-chain collision is caught by the `get_active_signer` check below.
143- if get_gasless_key_owner_fid ( db, txn_batch, & key_add_body. key ) ?. is_some ( ) {
144- return Err ( ValidationError :: KeyAlreadyRegistered . into ( ) ) ;
145- }
151+ // Step 8 (conflict resolution). On-chain first so a signer that was minted via L2 doesn't
152+ // silently get shadowed by a gasless registration; this branch also short-circuits before
153+ // the gasless reads below on that path. The same-FID on-chain collision stays a reject —
154+ // resubmission applies only to the gasless store (NEYN-10624).
146155 let existing_onchain = onchain_event_store
147156 . get_active_signer ( fid, key_add_body. key . clone ( ) , Some ( txn_batch) )
148157 . map_err ( |_| MessageValidationError :: MissingSigner ) ?;
149158 if existing_onchain. is_some ( ) {
150- return Err ( ValidationError :: KeyAlreadyRegistered . into ( ) ) ;
159+ return Err ( ValidationError :: KeyAlreadyRegisteredOnchain . into ( ) ) ;
151160 }
152161
153- // Per-FID gasless-key cap (NEYN-10579). Read through the txn batch so staged same-batch
154- // increments are visible — two KEY_ADDs in one commit see each other's counter bumps and the
155- // cap is enforced precisely at 1000. Ordered after conflict resolution so a KEY_ADD that
156- // would collide fails for the more specific reason instead of being mis-attributed.
157- let gasless_count = get_gasless_key_count ( db, txn_batch, fid) ?;
158- if gasless_count >= MAX_GASLESS_KEYS_PER_FID {
159- return Err ( ValidationError :: ActiveKeyCapExceeded ( MAX_GASLESS_KEYS_PER_FID ) . into ( ) ) ;
162+ // Gasless by-public-key index is authoritative for "which FID (if any) currently claims
163+ // this key as a gasless signer?" — written and deleted in lock-step with the primary
164+ // by-FID record. Three outcomes:
165+ // * None → first-time add for this FID; proceed.
166+ // * Some(other_fid) ≠ fid → cross-user collision; reject with
167+ // `KeyClaimedByDifferentFid`.
168+ // * Some(fid) → same-FID resubmission; authorize as an upsert iff the
169+ // new message's verified `requestFid` matches the stored
170+ // `request_fid` on the prior record (NEYN-10624).
171+ // Mismatch rejects with
172+ // `KeyRegisteredByDifferentRequestingFid`.
173+ //
174+ // A `Some(fid)` with no primary record is an internal inconsistency (owner index and
175+ // by-fid record are supposed to move in lockstep) — surfaced as an `internal_error`
176+ // rather than a user-facing rejection, so it's loud in logs.
177+ //
178+ // On the upsert branch the prior `GaslessKeyRecord` is carried forward so its embedded
179+ // KEY_ADD message can ride in the emitted event's `deleted_messages`.
180+ let prior_record = match get_gasless_key_owner_fid ( db, txn_batch, & key_add_body. key ) ? {
181+ None => None ,
182+ Some ( owner_fid) if owner_fid != fid => {
183+ return Err ( ValidationError :: KeyClaimedByDifferentFid . into ( ) ) ;
184+ }
185+ Some ( _) => {
186+ let record = get_gasless_key_record ( db, txn_batch, fid, & key_add_body. key ) ?
187+ . ok_or_else ( || HubError {
188+ code : "internal_error" . to_string ( ) ,
189+ message : format ! (
190+ "gasless owner index has fid={} for key but primary record missing" ,
191+ fid
192+ ) ,
193+ } ) ?;
194+ if record. request_fid != verified. request_fid {
195+ return Err ( ValidationError :: KeyRegisteredByDifferentRequestingFid . into ( ) ) ;
196+ }
197+ Some ( record)
198+ }
199+ } ;
200+
201+ // Per-FID gasless-key cap (NEYN-10579). Skipped entirely on the upsert path — replacing an
202+ // existing record doesn't grow the counter, so the cap check would be a false reject for
203+ // an FID already at 1000 that's legitimately modifying scope/TTL on a key it already owns.
204+ // For first-time adds: read through the txn batch so staged same-batch increments are
205+ // visible — two KEY_ADDs in one commit see each other's counter bumps and the cap is
206+ // enforced precisely at 1000.
207+ if prior_record. is_none ( ) {
208+ let gasless_count = get_gasless_key_count ( db, txn_batch, fid) ?;
209+ if gasless_count >= MAX_GASLESS_KEYS_PER_FID {
210+ return Err ( ValidationError :: ActiveKeyCapExceeded ( MAX_GASLESS_KEYS_PER_FID ) . into ( ) ) ;
211+ }
160212 }
161213
162214 // Step 4 (nonce CAS). Stages the bump on txn_batch; rolls back with everything else
163215 // if any later step fails. The store rejects `new_nonce <= stored`, which implicitly
164- // covers `nonce == 0` (stored defaults to 0).
216+ // covers `nonce == 0` (stored defaults to 0). This also makes resubmission safe against
217+ // replay: a second KEY_ADD carrying the same or lower nonce than the first is rejected
218+ // here, so the upsert path can't be driven by a replayed original message.
165219 check_and_set_user_nonce ( db, txn_batch, fid, key_add_body. nonce ) ?;
166220
167221 // State writes. Record embeds the full message (source of truth) plus the cached
168- // request_fid. last_used_at is initialized to the message's own timestamp so the
169- // sliding-TTL window begins at creation.
222+ // request_fid. `put_gasless_key_record` overwrites unconditionally, so an upsert simply
223+ // replaces the prior embedded message with the new one — scopes/TTL/etc. on that message
224+ // are re-read on every scope evaluation (see `active_key.rs`). The owner-index write is
225+ // a no-op re-put on resubmission (same fid → same value) but issued unconditionally for
226+ // symmetry with the first-time path. `last_used_at` is reset to the new message's
227+ // timestamp: the sliding-TTL window restarts on each accepted KEY_ADD.
170228 let record = GaslessKeyRecord {
171229 message : Some ( msg. clone ( ) ) ,
172230 request_fid : verified. request_fid ,
@@ -180,16 +238,23 @@ pub fn merge_key_add(
180238 & key_add_body. key ,
181239 message_data. timestamp ,
182240 ) ?;
183- // Increment the per-FID gasless-key counter in lock-step with the record write. The cap
184- // check above read the old value; this staged put makes any subsequent KEY_ADD in the same
185- // txn batch see the new count.
186- increment_gasless_key_count ( db, txn_batch, fid) ?;
241+ // Increment the per-FID gasless-key counter in lock-step with the record write, but only
242+ // on the first-time-add branch. On upsert (resubmission) the net count is unchanged — we're
243+ // replacing a record, not adding one — and bumping the counter would cause
244+ // `decrement_gasless_key_count` on a future KEY_REMOVE to underflow or run short.
245+ let is_first_time_add = prior_record. is_none ( ) ;
246+ if is_first_time_add {
247+ increment_gasless_key_count ( db, txn_batch, fid) ?;
248+ }
249+
250+ let deleted_messages: Vec < proto:: Message > =
251+ prior_record. and_then ( |r| r. message ) . into_iter ( ) . collect ( ) ;
187252
188253 Ok ( HubEvent :: new_event (
189254 HubEventType :: MergeMessage ,
190255 hub_event:: Body :: MergeMessageBody ( proto:: MergeMessageBody {
191256 message : Some ( msg. clone ( ) ) ,
192- deleted_messages : vec ! [ ] ,
257+ deleted_messages,
193258 } ) ,
194259 ) )
195260}
0 commit comments