Skip to content

Commit 6a65efd

Browse files
authored
feat: allow KEY_ADD resubmissions to modify allowed scope (#820)
These changes allow the registered requesting fid to submit a KEY_ADD message for a non-deleted key for the same fid the key is registered to. This will reset the TTL and is intended for scope modification as well. If the requester is attempting to re-assign the key to a different fid, they must submit at KEY_REMOVE message first. Also Updated `ValidationError` enum to include more specific error messages for key registration conflicts.
1 parent a3494be commit 6a65efd

8 files changed

Lines changed: 542 additions & 43 deletions

File tree

‎.github/workflows/verify-impl.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jobs:
9090
- working-directory: ./snapchain
9191
env:
9292
RUSTFLAGS: "-Dwarnings -A mismatched_lifetime_syntaxes"
93-
run: cargo build --bins
93+
run: cargo check --bins
9494

9595
- working-directory: ./snapchain
9696
env:

‎src/core/validations/error.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,12 @@ pub enum ValidationError {
180180
MissingMetadata,
181181
#[error("invalid signature type")]
182182
InvalidSignatureType,
183-
#[error("key is already registered for this fid")]
184-
KeyAlreadyRegistered,
183+
#[error("key is already registered onchain for this fid")]
184+
KeyAlreadyRegisteredOnchain,
185+
#[error("key is already claimed by a different fid")]
186+
KeyClaimedByDifferentFid,
187+
#[error("key is already registered for this fid by a different requesting fid")]
188+
KeyRegisteredByDifferentRequestingFid,
185189
#[error("key is not registered for this fid")]
186190
KeyNotRegistered,
187191
#[error("active-key cap exceeded: {0} keys per FID")]

‎src/core/validations/key.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ fn signed_key_request_types() -> Value {
265265
})
266266
}
267267

268-
fn signed_key_request_typed_data(
268+
pub(crate) fn signed_key_request_typed_data(
269269
request_fid: u64,
270270
key: &[u8],
271271
deadline: u64,

‎src/storage/store/account/active_key.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! list of allowed `MessageType` values ("scopes"), and every user message signed by that key
55
//! must fall within that list. This module centralizes the lookup that answers
66
//!
7-
//! "Is `signer` an active key for `fid`, and if so, what (if any) constraints apply?"
7+
//! > Is `signer` an active key for `fid`, and if so, what (if any) constraints apply?
88
//!
99
//! so validation sites in both `ShardEngine` and `BlockEngine` can call one function and
1010
//! pattern-match on the result.

‎src/storage/store/account/gasless_key_merge.rs‎

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
2223
use crate::core::message::HubEventExt;
2324
use crate::core::validations::error::ValidationError;
2425
use 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

Comments
 (0)