From 96b91f0083f45a13d21a47edb260c9780bcde173 Mon Sep 17 00:00:00 2001 From: shinorh247 <304154597+shinorh247@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:39:24 +0000 Subject: [PATCH 1/2] Add nonce registry contract for gasless replay protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off-chain signed meta-transactions had no on-chain record of which nonces were already submitted, so a relayer could submit the same signed message twice. Track consumed nonces per wallet in a dedicated contract: consume_nonce burns a nonce and reverts with NonceAlreadyUsed on a replay, is_nonce_used exposes the consumed state, and bulk_invalidate lets a wallet pre-emptively invalidate its own pending nonces. Nonces are independent uint64 values with no ordering, and NonceConsumed carries wallet and nonce so relayers can index them. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- Cargo.lock | 7 ++ Cargo.toml | 2 +- nonce-registry/Cargo.toml | 14 +++ nonce-registry/src/lib.rs | 119 +++++++++++++++++++++ nonce-registry/src/test.rs | 214 +++++++++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 nonce-registry/Cargo.toml create mode 100644 nonce-registry/src/lib.rs create mode 100644 nonce-registry/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index 8cc2a9f9..77522e2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,6 +806,13 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "nonce-registry" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "num-bigint" version = "0.4.6" diff --git a/Cargo.toml b/Cargo.toml index 5a23aa5b..66994d4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract"] +members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "nonce-registry"] resolver = "2" [workspace.package] diff --git a/nonce-registry/Cargo.toml b/nonce-registry/Cargo.toml new file mode 100644 index 00000000..09822ae9 --- /dev/null +++ b/nonce-registry/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nonce-registry" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/nonce-registry/src/lib.rs b/nonce-registry/src/lib.rs new file mode 100644 index 00000000..f4e34031 --- /dev/null +++ b/nonce-registry/src/lib.rs @@ -0,0 +1,119 @@ +#![no_std] + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Vec}; + +#[cfg(test)] +mod test; + +/// Name of the event emitted the first time a nonce is consumed for a wallet. +pub const NONCE_CONSUMED_EVENT_NAME: &str = "NonceConsumed"; + +/// Ledger threshold at which a consumed nonce is refreshed towards +/// [`NONCE_TTL_EXTEND_TO`]. Archival of a persistent entry keeps the stored +/// value, so replay protection survives; the refresh only avoids the cost of +/// restoring an archived entry on the hot path. +pub const NONCE_TTL_THRESHOLD: u32 = 100_000; + +/// Ledger count to extend a consumed nonce to (~30 days at 5s ledgers). +pub const NONCE_TTL_EXTEND_TO: u32 = 518_400; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum NonceRegistryError { + /// The nonce has already been consumed for this wallet. + NonceAlreadyUsed = 1, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + /// Marks `(wallet, nonce)` as consumed. Nonces carry no ordering, so each + /// pair is tracked independently. + UsedNonce(Address, u64), +} + +/// Payload of the `NonceConsumed` event. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NonceConsumed { + pub wallet: Address, + pub nonce: u64, +} + +#[contract] +pub struct NonceRegistryContract; + +fn used_nonce_key(wallet: &Address, nonce: u64) -> DataKey { + DataKey::UsedNonce(wallet.clone(), nonce) +} + +fn is_used(env: &Env, wallet: &Address, nonce: u64) -> bool { + env.storage() + .persistent() + .has(&used_nonce_key(wallet, nonce)) +} + +fn store_used(env: &Env, wallet: &Address, nonce: u64) { + let key = used_nonce_key(wallet, nonce); + env.storage().persistent().set(&key, &true); + env.storage() + .persistent() + .extend_ttl(&key, NONCE_TTL_THRESHOLD, NONCE_TTL_EXTEND_TO); +} + +fn emit_consumed(env: &Env, wallet: &Address, nonce: u64) { + env.events().publish( + (Symbol::new(env, NONCE_CONSUMED_EVENT_NAME), wallet.clone()), + NonceConsumed { + wallet: wallet.clone(), + nonce, + }, + ); +} + +#[contractimpl] +impl NonceRegistryContract { + /// Marks `nonce` as consumed for `wallet`. + /// + /// Returns [`NonceRegistryError::NonceAlreadyUsed`] if the nonce was already + /// consumed, and emits `NonceConsumed` otherwise. Deliberately does not + /// require authorization: submission is performed by a relayer, and the + /// signed message being verified is what authorizes the action. As a + /// result a third party can burn a nonce, which only invalidates a + /// meta-transaction that has not been submitted yet. + pub fn consume_nonce(env: Env, wallet: Address, nonce: u64) -> Result<(), NonceRegistryError> { + if is_used(&env, &wallet, nonce) { + return Err(NonceRegistryError::NonceAlreadyUsed); + } + store_used(&env, &wallet, nonce); + emit_consumed(&env, &wallet, nonce); + Ok(()) + } + + /// Returns whether `nonce` has been consumed for `wallet`. + pub fn is_nonce_used(env: Env, wallet: Address, nonce: u64) -> bool { + is_used(&env, &wallet, nonce) + } + + /// Pre-emptively burns every nonce in `nonces` for `wallet`. + /// + /// Only the wallet itself may invalidate its nonces. Nonces that are + /// already consumed are skipped rather than rejected, so the call is + /// idempotent; the returned value is the number of newly consumed nonces. + /// `NonceConsumed` is emitted once per newly consumed nonce. + pub fn bulk_invalidate(env: Env, wallet: Address, nonces: Vec) -> u32 { + wallet.require_auth(); + + let mut newly_consumed = 0u32; + for nonce in nonces.iter() { + if is_used(&env, &wallet, nonce) { + continue; + } + store_used(&env, &wallet, nonce); + emit_consumed(&env, &wallet, nonce); + newly_consumed += 1; + } + newly_consumed + } +} diff --git a/nonce-registry/src/test.rs b/nonce-registry/src/test.rs new file mode 100644 index 00000000..1844d6b6 --- /dev/null +++ b/nonce-registry/src/test.rs @@ -0,0 +1,214 @@ +#![cfg(test)] + +use crate::{ + NonceConsumed, NonceRegistryContract, NonceRegistryContractClient, NonceRegistryError, + NONCE_CONSUMED_EVENT_NAME, +}; +use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, IntoVal, Symbol, TryFromVal, Vec, +}; + +fn setup() -> (Env, NonceRegistryContractClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(NonceRegistryContract, ()); + let client = NonceRegistryContractClient::new(&env, &contract_id); + let wallet = Address::generate(&env); + (env, client, wallet) +} + +fn to_vec(env: &Env, values: &[u64]) -> Vec { + let mut out = Vec::new(env); + for value in values { + out.push_back(*value); + } + out +} + +fn nonce_consumed_events(env: &Env) -> Vec { + let topic = Symbol::new(env, NONCE_CONSUMED_EVENT_NAME); + let mut matching = Vec::new(env); + for (_, topics, data) in env.events().all().iter() { + if let Some(first) = topics.get(0) { + if Symbol::try_from_val(env, &first) == Ok(topic.clone()) { + if let Ok(event) = NonceConsumed::try_from_val(env, &data) { + matching.push_back(event); + } + } + } + } + matching +} + +#[test] +fn nonce_is_unused_until_consumed() { + let (_env, client, wallet) = setup(); + + assert!(!client.is_nonce_used(&wallet, &7)); + + client.consume_nonce(&wallet, &7); + + assert!(client.is_nonce_used(&wallet, &7)); + assert!(!client.is_nonce_used(&wallet, &8)); +} + +#[test] +fn consuming_a_nonce_twice_reverts_with_nonce_already_used() { + let (_env, client, wallet) = setup(); + + client.consume_nonce(&wallet, &1); + + assert_eq!( + client.try_consume_nonce(&wallet, &1), + Err(Ok(NonceRegistryError::NonceAlreadyUsed)) + ); +} + +#[test] +fn nonce_state_is_tracked_per_wallet() { + let (env, client, wallet) = setup(); + let other = Address::generate(&env); + + client.consume_nonce(&wallet, &42); + + assert!(client.is_nonce_used(&wallet, &42)); + assert!(!client.is_nonce_used(&other, &42)); +} + +#[test] +fn nonces_have_no_ordering() { + let (_env, client, wallet) = setup(); + + client.consume_nonce(&wallet, &u64::MAX); + client.consume_nonce(&wallet, &0); + client.consume_nonce(&wallet, &5); + + assert!(client.is_nonce_used(&wallet, &u64::MAX)); + assert!(client.is_nonce_used(&wallet, &0)); + assert!(client.is_nonce_used(&wallet, &5)); + assert!(!client.is_nonce_used(&wallet, &1)); +} + +#[test] +fn nonce_consumed_event_is_emitted_on_first_consumption() { + let (env, client, wallet) = setup(); + + client.consume_nonce(&wallet, &9); + + let events = nonce_consumed_events(&env); + assert_eq!(events.len(), 1); + let event = events.get(0).unwrap(); + assert_eq!(event.wallet, wallet); + assert_eq!(event.nonce, 9); + + // The wallet is indexed as the second topic so relayers can filter per account. + let (_, topics, _) = env.events().all().get(0).unwrap(); + assert_eq!(topics.len(), 2); + assert_eq!( + Address::try_from_val(&env, &topics.get(1).unwrap()), + Ok(wallet.clone()) + ); +} + +#[test] +fn failed_consumption_emits_no_event() { + let (env, client, wallet) = setup(); + + client.consume_nonce(&wallet, &3); + + assert!(client.try_consume_nonce(&wallet, &3).is_err()); + + // Only the rejected invocation is in scope: it emitted nothing. + assert_eq!(nonce_consumed_events(&env).len(), 0); +} + +#[test] +fn bulk_invalidate_marks_every_provided_nonce_consumed() { + let (env, client, wallet) = setup(); + + let burned = client.bulk_invalidate(&wallet, &to_vec(&env, &[10, 20, 30])); + + assert_eq!(burned, 3); + assert_eq!(nonce_consumed_events(&env).len(), 3); + for nonce in [10u64, 20, 30] { + assert!(client.is_nonce_used(&wallet, &nonce)); + } + assert!(!client.is_nonce_used(&wallet, &40)); +} + +#[test] +fn bulk_invalidate_skips_nonces_that_are_already_consumed() { + let (env, client, wallet) = setup(); + + client.consume_nonce(&wallet, &2); + let burned = client.bulk_invalidate(&wallet, &to_vec(&env, &[1, 2, 3])); + + assert_eq!(burned, 2); + // One event per newly burned nonce; the already consumed one is skipped. + assert_eq!(nonce_consumed_events(&env).len(), 2); + for nonce in [1u64, 2, 3] { + assert!(client.is_nonce_used(&wallet, &nonce)); + } + + // Re-running the same list is a no-op instead of a failure. + assert_eq!( + client.bulk_invalidate(&wallet, &to_vec(&env, &[1, 2, 3])), + 0 + ); + assert_eq!(nonce_consumed_events(&env).len(), 0); +} + +#[test] +fn bulk_invalidate_requires_wallet_authorization() { + let env = Env::default(); + let contract_id = env.register(NonceRegistryContract, ()); + let client = NonceRegistryContractClient::new(&env, &contract_id); + let wallet = Address::generate(&env); + let attacker = Address::generate(&env); + + // No auth mocked at all: the wallet's authorization cannot be satisfied. + assert!(client + .try_bulk_invalidate(&wallet, &to_vec(&env, &[1, 2])) + .is_err()); + assert!(!client.is_nonce_used(&wallet, &1)); + + // Auth for a different address does not grant wallet access either. + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &attacker, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "bulk_invalidate", + args: (wallet.clone(), to_vec(&env, &[1, 2])).into_val(&env), + sub_invokes: &[], + }, + }]); + assert!(client + .try_bulk_invalidate(&wallet, &to_vec(&env, &[1, 2])) + .is_err()); + assert!(!client.is_nonce_used(&wallet, &1)); +} + +#[test] +fn bulk_invalidate_only_affects_the_calling_wallet() { + let env = Env::default(); + let contract_id = env.register(NonceRegistryContract, ()); + let client = NonceRegistryContractClient::new(&env, &contract_id); + let wallet = Address::generate(&env); + let other = Address::generate(&env); + + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &wallet, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "bulk_invalidate", + args: (wallet.clone(), to_vec(&env, &[4, 5])).into_val(&env), + sub_invokes: &[], + }, + }]); + + assert_eq!(client.bulk_invalidate(&wallet, &to_vec(&env, &[4, 5])), 2); + assert!(client.is_nonce_used(&wallet, &4)); + // The same numeric nonce for another wallet stays unconsumed. + assert!(!client.is_nonce_used(&other, &4)); +} From 4555ef1adddffe4250a9af056e34ed27848a38f1 Mon Sep 17 00:00:00 2001 From: shinorh247 <304154597+shinorh247@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:54:23 +0000 Subject: [PATCH 2/2] Fix workspace manifest broken by the merge from main The merge kept both sides of the Cargo.toml members list and folded the multisig-admin and nonce-registry package blocks in Cargo.lock into a single block with two name keys. cargo metadata then aborted with a duplicate key, so CI failed at the format check before clippy or the tests could run. Unify the members list and restore one valid lock block per crate. --- Cargo.lock | 8 +++++++- Cargo.toml | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45cc591d..3e64e4d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,13 +808,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "nonce-registry" name = "multisig-admin" version = "0.1.0" dependencies = [ "soroban-sdk", ] +[[package]] +name = "nonce-registry" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "num-bigint" version = "0.4.6" diff --git a/Cargo.toml b/Cargo.toml index aacea374..fe795e92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ [workspace] -members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "nonce-registry"] -members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "multisig-admin"] +members = ["creator-keys", "creator-keys-factory", "referral-contract", "lp-reward-contract", "multisig-admin", "nonce-registry"] resolver = "2" [workspace.package]