From 587258a8fb3dd39ab4fccb21af7d9f39960f6234 Mon Sep 17 00:00:00 2001 From: zyrick Date: Fri, 25 Jul 2025 23:11:46 +0100 Subject: [PATCH 1/2] feat: implement nonce tracker and rate lock contracts Closes #18 Closes #17 --- src/lib.rs | 4 + src/nonce.rs | 32 +++++ src/rate_lock.rs | 35 +++++ .../test_lock_and_validate_rate.1.json | 133 ++++++++++++++++++ test_snapshots/test_nonce_tracker.1.json | 122 ++++++++++++++++ tests/nonce_test.rs | 26 ++++ tests/rate_lock_test.rs | 34 +++++ 7 files changed, 386 insertions(+) create mode 100644 src/nonce.rs create mode 100644 src/rate_lock.rs create mode 100644 test_snapshots/test_lock_and_validate_rate.1.json create mode 100644 test_snapshots/test_nonce_tracker.1.json create mode 100644 tests/nonce_test.rs create mode 100644 tests/rate_lock_test.rs diff --git a/src/lib.rs b/src/lib.rs index 2bad7ad..634d7d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,9 +9,11 @@ pub mod events; pub mod fees; pub mod mint; pub mod multisig; +pub mod nonce; pub mod schema; pub mod token; pub mod utils; +pub mod rate_lock; pub use crate::email_to_wallet::EmailToWalletContract; pub use conversion::ConversionContract; @@ -21,3 +23,5 @@ pub use event::*; pub use multisig::MultiSigContract; pub use token::TokenContract; pub use utils::*; +pub use crate::nonce::NonceTracker; +pub use crate::nonce::ContractError; diff --git a/src/nonce.rs b/src/nonce.rs new file mode 100644 index 0000000..0de8f9d --- /dev/null +++ b/src/nonce.rs @@ -0,0 +1,32 @@ +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracterror, Env, Address, Symbol, symbol_short}; + +#[contracterror] +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum ContractError { + InvalidNonce = 1, +} + +#[contract] +pub struct NonceTracker; + +#[contractimpl] +impl NonceTracker { + pub fn get_nonce(env: Env, user: Address) -> u64 { + let key = (user.clone(), symbol_short!("NONCE")); + env.storage().persistent().get(&key).unwrap_or(0) + } + + pub fn check_and_update_nonce(env: Env, user: Address, incoming: u64) -> Result { + let key = (user.clone(), symbol_short!("NONCE")); + let stored: u64 = env.storage().persistent().get(&key).unwrap_or(0); + + if incoming <= stored { + return Err(ContractError::InvalidNonce); + } + + env.storage().persistent().set(&key, &incoming); + Ok(incoming) + } +} diff --git a/src/rate_lock.rs b/src/rate_lock.rs new file mode 100644 index 0000000..25d9455 --- /dev/null +++ b/src/rate_lock.rs @@ -0,0 +1,35 @@ +#![no_std] + +use soroban_sdk::{contract, contracterror, contractimpl, Env, Address, Symbol, symbol_short}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum RateLockError { + NoRateLocked = 1, + RateExpired = 2, +} + +#[contract] +pub struct RateLockContract; + +#[contractimpl] +impl RateLockContract { + pub fn lock_rate(env: Env, user: Address, rate: i128, duration_seconds: u64) { + let expiry = env.ledger().timestamp() + duration_seconds; + let key = (user.clone(), symbol_short!("RATELOCK")); + env.storage().persistent().set(&key, &(rate, expiry)); + } + + pub fn validate_conversion(env: Env, user: Address) -> Result { + let key = (user.clone(), symbol_short!("RATELOCK")); + let stored: Option<(i128, u64)> = env.storage().persistent().get(&key); + + let (rate, expiry) = stored.ok_or(RateLockError::NoRateLocked)?; + + if env.ledger().timestamp() > expiry { + return Err(RateLockError::RateExpired); + } + + Ok(rate) + } +} diff --git a/test_snapshots/test_lock_and_validate_rate.1.json b/test_snapshots/test_lock_and_validate_rate.1.json new file mode 100644 index 0000000..489e0dd --- /dev/null +++ b/test_snapshots/test_lock_and_validate_rate.1.json @@ -0,0 +1,133 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 61, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "symbol": "RATELOCK" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + }, + { + "symbol": "RATELOCK" + } + ] + }, + "durability": "persistent", + "val": { + "vec": [ + { + "i128": { + "hi": 0, + "lo": 100 + } + }, + { + "u64": 60 + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/test_snapshots/test_nonce_tracker.1.json b/test_snapshots/test_nonce_tracker.1.json new file mode 100644 index 0000000..84e2e0b --- /dev/null +++ b/test_snapshots/test_nonce_tracker.1.json @@ -0,0 +1,122 @@ +{ + "generators": { + "address": 2, + "nonce": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "symbol": "NONCE" + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "symbol": "NONCE" + } + ] + }, + "durability": "persistent", + "val": { + "u64": 2 + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": null + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/tests/nonce_test.rs b/tests/nonce_test.rs new file mode 100644 index 0000000..9c7d840 --- /dev/null +++ b/tests/nonce_test.rs @@ -0,0 +1,26 @@ +#![cfg(test)] + +use soroban_sdk::{Env, Address}; +use soroban_sdk::testutils::Address as _; +use stellar_multisig_contract::nonce::NonceTracker; +use stellar_multisig_contract::nonce::ContractError; + +#[test] +fn test_nonce_tracker() { + let env = Env::default(); + let contract_id = env.register(NonceTracker, ()); + let user = Address::generate(&env); + + env.as_contract(&contract_id, || { + assert_eq!(NonceTracker::get_nonce(env.clone(), user.clone()), 0); + NonceTracker::check_and_update_nonce(env.clone(), user.clone(), 1).unwrap(); + assert_eq!(NonceTracker::get_nonce(env.clone(), user.clone()), 1); + }); + + env.as_contract(&contract_id, || { + let err = NonceTracker::check_and_update_nonce(env.clone(), user.clone(), 1).unwrap_err(); + assert_eq!(err, ContractError::InvalidNonce); + NonceTracker::check_and_update_nonce(env.clone(), user.clone(), 2).unwrap(); + assert_eq!(NonceTracker::get_nonce(env.clone(), user.clone()), 2); + }) +} \ No newline at end of file diff --git a/tests/rate_lock_test.rs b/tests/rate_lock_test.rs new file mode 100644 index 0000000..238cc53 --- /dev/null +++ b/tests/rate_lock_test.rs @@ -0,0 +1,34 @@ +#![cfg(test)] + +use soroban_sdk::{Env, Address}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::Ledger; +use stellar_multisig_contract::rate_lock::{RateLockContract, RateLockError}; +use stellar_multisig_contract::rate_lock::RateLockContractClient as RateLockClient; + + +#[test] +fn test_lock_and_validate_rate() { + let env = Env::default(); + let user = Address::generate(&env); + let contract_id = env.register(RateLockContract, ()); + + // Lock rate + env.as_contract(&contract_id, || { + RateLockContract::lock_rate(env.clone(), user.clone(), 100, 60); + }); + + // Validate inside contract context + let rate = env.as_contract(&contract_id, || { + RateLockContract::validate_conversion(env.clone(), user.clone()).unwrap() + }); + assert_eq!(rate, 100); + + // Advance time + env.ledger().set_timestamp(env.ledger().timestamp() + 61); + + let err = env.as_contract(&contract_id, || { + RateLockContract::validate_conversion(env.clone(), user.clone()).unwrap_err() + }); + assert_eq!(err, RateLockError::RateExpired); +} From e5b2053e07ccd5f8b73df44e83c0ca9dfce869a3 Mon Sep 17 00:00:00 2001 From: zyrick Date: Sat, 26 Jul 2025 00:09:23 +0100 Subject: [PATCH 2/2] style: format code for consistency --- src/lib.rs | 6 +++--- src/nonce.rs | 8 ++++++-- src/rate_lock.rs | 2 +- tests/nonce_test.rs | 18 +++++++++--------- tests/rate_lock_test.rs | 5 ++--- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 634d7d2..d5e52d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,12 +10,14 @@ pub mod fees; pub mod mint; pub mod multisig; pub mod nonce; +pub mod rate_lock; pub mod schema; pub mod token; pub mod utils; -pub mod rate_lock; pub use crate::email_to_wallet::EmailToWalletContract; +pub use crate::nonce::ContractError; +pub use crate::nonce::NonceTracker; pub use conversion::ConversionContract; pub use conversion::Currency; pub use escrow::EscrowContract; @@ -23,5 +25,3 @@ pub use event::*; pub use multisig::MultiSigContract; pub use token::TokenContract; pub use utils::*; -pub use crate::nonce::NonceTracker; -pub use crate::nonce::ContractError; diff --git a/src/nonce.rs b/src/nonce.rs index 0de8f9d..f6b52aa 100644 --- a/src/nonce.rs +++ b/src/nonce.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracterror, Env, Address, Symbol, symbol_short}; +use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, Address, Env, Symbol}; #[contracterror] #[derive(Copy, Clone, Eq, PartialEq, Debug)] @@ -18,7 +18,11 @@ impl NonceTracker { env.storage().persistent().get(&key).unwrap_or(0) } - pub fn check_and_update_nonce(env: Env, user: Address, incoming: u64) -> Result { + pub fn check_and_update_nonce( + env: Env, + user: Address, + incoming: u64, + ) -> Result { let key = (user.clone(), symbol_short!("NONCE")); let stored: u64 = env.storage().persistent().get(&key).unwrap_or(0); diff --git a/src/rate_lock.rs b/src/rate_lock.rs index 25d9455..a7c2fca 100644 --- a/src/rate_lock.rs +++ b/src/rate_lock.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, Env, Address, Symbol, symbol_short}; +use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, Address, Env, Symbol}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] diff --git a/tests/nonce_test.rs b/tests/nonce_test.rs index 9c7d840..331a3cb 100644 --- a/tests/nonce_test.rs +++ b/tests/nonce_test.rs @@ -1,26 +1,26 @@ #![cfg(test)] -use soroban_sdk::{Env, Address}; use soroban_sdk::testutils::Address as _; -use stellar_multisig_contract::nonce::NonceTracker; +use soroban_sdk::{Address, Env}; use stellar_multisig_contract::nonce::ContractError; +use stellar_multisig_contract::nonce::NonceTracker; #[test] fn test_nonce_tracker() { - let env = Env::default(); - let contract_id = env.register(NonceTracker, ()); - let user = Address::generate(&env); + let env = Env::default(); + let contract_id = env.register(NonceTracker, ()); + let user = Address::generate(&env); - env.as_contract(&contract_id, || { + env.as_contract(&contract_id, || { assert_eq!(NonceTracker::get_nonce(env.clone(), user.clone()), 0); NonceTracker::check_and_update_nonce(env.clone(), user.clone(), 1).unwrap(); assert_eq!(NonceTracker::get_nonce(env.clone(), user.clone()), 1); - }); + }); - env.as_contract(&contract_id, || { + env.as_contract(&contract_id, || { let err = NonceTracker::check_and_update_nonce(env.clone(), user.clone(), 1).unwrap_err(); assert_eq!(err, ContractError::InvalidNonce); NonceTracker::check_and_update_nonce(env.clone(), user.clone(), 2).unwrap(); assert_eq!(NonceTracker::get_nonce(env.clone(), user.clone()), 2); }) -} \ No newline at end of file +} diff --git a/tests/rate_lock_test.rs b/tests/rate_lock_test.rs index 238cc53..eee379e 100644 --- a/tests/rate_lock_test.rs +++ b/tests/rate_lock_test.rs @@ -1,11 +1,10 @@ #![cfg(test)] -use soroban_sdk::{Env, Address}; use soroban_sdk::testutils::Address as _; use soroban_sdk::testutils::Ledger; -use stellar_multisig_contract::rate_lock::{RateLockContract, RateLockError}; +use soroban_sdk::{Address, Env}; use stellar_multisig_contract::rate_lock::RateLockContractClient as RateLockClient; - +use stellar_multisig_contract::rate_lock::{RateLockContract, RateLockError}; #[test] fn test_lock_and_validate_rate() {