Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
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]
Expand Down
14 changes: 14 additions & 0 deletions nonce-registry/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
119 changes: 119 additions & 0 deletions nonce-registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<u64>) -> 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
}
}
214 changes: 214 additions & 0 deletions nonce-registry/src/test.rs
Original file line number Diff line number Diff line change
@@ -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<u64> {
let mut out = Vec::new(env);
for value in values {
out.push_back(*value);
}
out
}

fn nonce_consumed_events(env: &Env) -> Vec<NonceConsumed> {
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));
}
Loading