diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..556f1ae6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.3.0] + +### Added +- 4 bytes salts added to state for security purposes +- SaltManager role to manage salts rotation functionality +- GarbageCollector role to cleanup expired/invalid nonces +- Expirable nonces: contain expiration deadline in nanoseconds +- Salted nonces: contain salt part as validity identifier +- Cleanup functionality for nonces to preserve storage: all expired nonces or nonces with invalid salt can be cleared. Legacy nonces can't be cleared before a complete prohibition on its usage. + +### Changed +- Contract storage versioning +- State versioning +- Nonce versioning +- Updated nonce format: The new version must contain the salt part + expiration date to determine if it is valid, but legacy nonces are still supported +- Optimized nonce storage by adding hashers into maps +- Intent simulation output returns all nonces committed by transaction + diff --git a/Cargo.lock b/Cargo.lock index 9f2f7ea7..d2385ea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -639,7 +639,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "defuse" -version = "0.2.9" +version = "0.3.0" dependencies = [ "arbitrary_with", "bitflags 2.9.1", diff --git a/bitmap/src/lib.rs b/bitmap/src/lib.rs index f5d33594..1716b24a 100644 --- a/bitmap/src/lib.rs +++ b/bitmap/src/lib.rs @@ -42,7 +42,7 @@ where } #[inline] - pub fn clear_by_prefix(&mut self, prefix: [u8; 31]) -> bool { + pub fn cleanup_by_prefix(&mut self, prefix: U248) -> bool { self.0.remove(&prefix).is_some() } diff --git a/core/src/accounts.rs b/core/src/accounts.rs index 49653294..4cf78e17 100644 --- a/core/src/accounts.rs +++ b/core/src/accounts.rs @@ -2,9 +2,9 @@ use defuse_crypto::PublicKey; use defuse_serde_utils::base64::Base64; use near_sdk::{AccountIdRef, near}; use serde_with::serde_as; -use std::borrow::Cow; +use std::{borrow::Cow, collections::BTreeSet}; -use crate::Nonce; +use crate::{Nonce, Salt}; #[must_use = "make sure to `.emit()` this event"] #[near(serializers = [json])] @@ -63,3 +63,11 @@ impl NonceEvent { Self { nonce } } } + +#[must_use = "make sure to `.emit()` this event"] +#[near(serializers = [json])] +#[derive(Debug, Clone)] +pub struct SaltRotationEvent { + pub current: Salt, + pub invalidated: BTreeSet, +} diff --git a/core/src/engine/mod.rs b/core/src/engine/mod.rs index ac172ca1..c059a54f 100644 --- a/core/src/engine/mod.rs +++ b/core/src/engine/mod.rs @@ -6,7 +6,7 @@ pub use self::{inspector::*, state::*}; use defuse_crypto::{Payload, SignedPayload}; use crate::{ - DefuseError, ExpirableNonce, Result, + Deadline, DefuseError, ExpirableNonce, Nonce, Result, SaltedNonce, VersionedNonce, intents::{DefuseIntents, ExecutableIntent}, payload::{DefusePayload, ExtractDefusePayload, multi::MultiPayload}, }; @@ -64,10 +64,6 @@ where self.inspector.on_deadline(deadline); - if ExpirableNonce::maybe_from(nonce).is_some_and(|n| deadline > n.deadline) { - return Err(DefuseError::DeadlineGreaterThanNonce); - } - // make sure message is still valid if deadline.has_expired() { return Err(DefuseError::DeadlineExpired); @@ -79,6 +75,7 @@ where } // commit nonce + self.verify_intent_nonce(nonce, deadline)?; self.state.commit_nonce(signer_id.clone(), nonce)?; intents.execute_intent(&signer_id, self, hash)?; @@ -87,6 +84,34 @@ where Ok(()) } + #[inline] + fn verify_intent_nonce(&self, nonce: Nonce, intent_deadline: Deadline) -> Result<()> { + let Some(nonce) = VersionedNonce::maybe_from(nonce) else { + return Ok(()); + }; + + match nonce { + VersionedNonce::V1(SaltedNonce { + salt, + nonce: ExpirableNonce { deadline, .. }, + }) => { + if !self.state.is_valid_salt(salt) { + return Err(DefuseError::InvalidSalt); + } + + if intent_deadline > deadline { + return Err(DefuseError::DeadlineGreaterThanNonce); + } + + if deadline.has_expired() { + return Err(DefuseError::NonceExpired); + } + } + } + + Ok(()) + } + #[inline] fn finalize(self) -> Result { self.state diff --git a/core/src/engine/state/cached.rs b/core/src/engine/state/cached.rs index 4650efc2..81254ed4 100644 --- a/core/src/engine/state/cached.rs +++ b/core/src/engine/state/cached.rs @@ -1,5 +1,5 @@ use crate::{ - DefuseError, Nonce, Nonces, Result, + DefuseError, Nonce, NoncePrefix, Nonces, Result, Salt, amounts::Amounts, fees::Pips, intents::{ @@ -119,6 +119,10 @@ where .is_some_and(|a| a.auth_by_predecessor_id_toggled); was_enabled ^ toggled } + + fn is_valid_salt(&self, salt: Salt) -> bool { + self.view.is_valid_salt(salt) + } } impl State for CachedState @@ -175,22 +179,18 @@ where .commit_nonce(nonce) } - fn cleanup_expired_nonces( + fn cleanup_nonce_by_prefix( &mut self, - account_id: &AccountId, - nonces: impl IntoIterator, - ) -> Result<()> { + account_id: &AccountIdRef, + prefix: NoncePrefix, + ) -> Result { let account = self .accounts .get_mut(account_id) - .ok_or_else(|| DefuseError::AccountNotFound(account_id.clone()))? + .ok_or_else(|| DefuseError::AccountNotFound(account_id.to_owned()))? .as_inner_unchecked_mut(); - for n in nonces { - account.clear_expired_nonce(n); - } - - Ok(()) + Ok(account.cleanup_nonce_by_prefix(prefix)) } fn internal_add_balance( @@ -417,7 +417,7 @@ impl CachedAccount { } #[inline] - pub fn clear_expired_nonce(&mut self, n: U256) -> bool { - self.nonces.clear_expired(n) + pub fn cleanup_nonce_by_prefix(&mut self, prefix: NoncePrefix) -> bool { + self.nonces.cleanup_by_prefix(prefix) } } diff --git a/core/src/engine/state/deltas.rs b/core/src/engine/state/deltas.rs index 0efecc65..f0730b72 100644 --- a/core/src/engine/state/deltas.rs +++ b/core/src/engine/state/deltas.rs @@ -1,5 +1,5 @@ use crate::{ - DefuseError, Nonce, Result, + DefuseError, Nonce, NoncePrefix, Result, Salt, amounts::Amounts, fees::Pips, intents::{ @@ -96,6 +96,11 @@ where fn is_auth_by_predecessor_id_enabled(&self, account_id: &AccountIdRef) -> bool { self.state.is_auth_by_predecessor_id_enabled(account_id) } + + #[inline] + fn is_valid_salt(&self, salt: Salt) -> bool { + self.state.is_valid_salt(salt) + } } impl State for Deltas @@ -118,12 +123,12 @@ where } #[inline] - fn cleanup_expired_nonces( + fn cleanup_nonce_by_prefix( &mut self, - account_id: &AccountId, - nonces: impl IntoIterator, - ) -> Result<()> { - self.state.cleanup_expired_nonces(account_id, nonces) + account_id: &AccountIdRef, + prefix: NoncePrefix, + ) -> Result { + self.state.cleanup_nonce_by_prefix(account_id, prefix) } fn internal_add_balance( diff --git a/core/src/engine/state/mod.rs b/core/src/engine/state/mod.rs index 3f6f35a1..d2e773c0 100644 --- a/core/src/engine/state/mod.rs +++ b/core/src/engine/state/mod.rs @@ -2,7 +2,7 @@ pub mod cached; pub mod deltas; use crate::{ - Nonce, Result, + Nonce, NoncePrefix, Result, Salt, fees::Pips, intents::{ auth::AuthCall, @@ -42,6 +42,9 @@ pub trait StateView { /// Returns whether authentication by `PREDECESSOR_ID` is enabled. fn is_auth_by_predecessor_id_enabled(&self, account_id: &AccountIdRef) -> bool; + /// Returns whether salt in nonce is valid + fn is_valid_salt(&self, salt: Salt) -> bool; + #[inline] fn cached(self) -> CachedState where @@ -59,11 +62,11 @@ pub trait State: StateView { fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> Result<()>; - fn cleanup_expired_nonces( + fn cleanup_nonce_by_prefix( &mut self, - account_id: &AccountId, - nonces: impl IntoIterator, - ) -> Result<()>; + account_id: &AccountIdRef, + prefix: NoncePrefix, + ) -> Result; fn internal_add_balance( &mut self, diff --git a/core/src/error.rs b/core/src/error.rs index 0bb2c99e..a686309c 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -55,6 +55,9 @@ pub enum DefuseError { #[error("nonce was already expired")] NonceExpired, + #[error("invalid nonce")] + InvalidNonce, + #[error("public key '{1}' already exists for account '{0}'")] PublicKeyExists(AccountId, PublicKey), @@ -66,4 +69,10 @@ pub enum DefuseError { #[error("wrong verifying_contract")] WrongVerifyingContract, + + #[error("invalid salt")] + InvalidSalt, + + #[error("maximum attempts to generate a new salt reached")] + SaltGenerationFailed, } diff --git a/core/src/events.rs b/core/src/events.rs index 9d3dc7c3..ca2e3fe2 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -4,7 +4,7 @@ use derive_more::derive::From; use near_sdk::{near, serde::Deserialize}; use crate::{ - accounts::{AccountEvent, NonceEvent, PublicKeyEvent}, + accounts::{AccountEvent, NonceEvent, PublicKeyEvent, SaltRotationEvent}, fees::{FeeChangedEvent, FeeCollectorChangedEvent}, intents::{ IntentEvent, @@ -63,6 +63,9 @@ pub enum DefuseEvent<'a> { #[event_version("0.3.0")] SetAuthByPredecessorId(AccountEvent<'a, SetAuthByPredecessorId>), + + #[event_version("0.4.0")] + SaltRotation(SaltRotationEvent), } pub trait DefuseIntentEmit<'a>: Into> { diff --git a/core/src/nonce.rs b/core/src/nonce.rs deleted file mode 100644 index 0969a908..00000000 --- a/core/src/nonce.rs +++ /dev/null @@ -1,166 +0,0 @@ -use defuse_bitmap::{BitMap256, U248, U256}; -use defuse_borsh_utils::adapters::{As, TimestampNanoSeconds}; -use defuse_map_utils::{IterableMap, Map}; -use hex_literal::hex; -use near_sdk::{ - borsh::{self, BorshDeserialize, BorshSerialize}, - near, -}; - -use crate::{Deadline, DefuseError, Result}; - -pub type Nonce = U256; - -/// See [permit2 nonce schema](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#nonce-schema) -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[near(serializers = [borsh, json])] -#[derive(Debug, Clone, Default)] -pub struct Nonces>(BitMap256); - -impl Nonces -where - T: Map, -{ - #[inline] - pub const fn new(bitmap: T) -> Self { - Self(BitMap256::new(bitmap)) - } - - #[inline] - pub fn is_used(&self, n: Nonce) -> bool { - self.0.get_bit(n) - } - - #[inline] - pub fn commit(&mut self, n: Nonce) -> Result<()> { - if ExpirableNonce::maybe_from(n).is_some_and(|expirable| expirable.has_expired()) { - return Err(DefuseError::NonceExpired); - } - - if self.0.set_bit(n) { - return Err(DefuseError::NonceUsed); - } - - Ok(()) - } - - #[inline] - pub fn clear_expired(&mut self, n: Nonce) -> bool { - if ExpirableNonce::maybe_from(n).is_some_and(|n| n.has_expired()) { - let [prefix @ .., _] = n; - return self.0.clear_by_prefix(prefix); - } - - false - } - - #[inline] - pub fn iter(&self) -> impl Iterator + '_ - where - T: IterableMap, - { - self.0.as_iter() - } -} - -/// To distinguish between legacy nonces and expirable nonces -/// we use a specific prefix `EXPIRABLE_NONCE_PREFIX`. Expirable nonces -/// have the following structure: [`word_position`, `bit_position`]. -/// Where `word_position` = [ `EXPIRABLE_NONCE_PREFIX` , <8 bytes timestamp in nanoseconds>, <19 random bytes> ] -/// and `bit_position` is the last (lowest) byte -#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -#[borsh(crate = "::near_sdk::borsh")] -pub struct ExpirableNonce { - #[borsh( - serialize_with = "As::::serialize", - deserialize_with = "As::::deserialize" - )] - pub deadline: Deadline, - pub nonce: [u8; 20], -} - -impl From for Nonce { - fn from(n: ExpirableNonce) -> Self { - let mut result = [0u8; 32]; - - borsh::to_writer( - &mut result[..], - &(ExpirableNonce::EXPIRABLE_NONCE_PREFIX, n), - ) - .unwrap_or_else(|_| unreachable!()); - result - } -} - -impl ExpirableNonce { - /// Prefix to identify expirable nonces: - /// (first 4 bytes of `sha256("expirable_nonce"))` - pub const EXPIRABLE_NONCE_PREFIX: [u8; 4] = hex!("dd50bc7c"); - - pub const fn new(deadline: Deadline, nonce: [u8; 20]) -> Self { - Self { deadline, nonce } - } - - /// Checks prefix and parses the rest as expirable nonce - /// If prefix doesn't match or nonce has invalid timestamp, returns None - pub fn maybe_from(n: Nonce) -> Option { - let mut bytes = n.strip_prefix(&Self::EXPIRABLE_NONCE_PREFIX)?; - Self::deserialize_reader(&mut bytes).ok() - } - - #[inline] - pub fn has_expired(&self) -> bool { - self.deadline.has_expired() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use arbitrary::Unstructured; - use chrono::{Days, Utc}; - use defuse_test_utils::random::random_bytes; - use rstest::rstest; - - #[rstest] - fn roundtrip_layout(random_bytes: Vec) { - let mut u = Unstructured::new(&random_bytes); - let nonce_bytes: [u8; 20] = u.arbitrary().unwrap(); - let now = Deadline::new(Utc::now()); - - let exp = ExpirableNonce::new(now, nonce_bytes); - let packed: Nonce = exp.clone().into(); - - let unpacked = ExpirableNonce::maybe_from(packed).expect("prefix must match"); - assert_eq!(unpacked, exp); - } - - #[rstest] - fn nonexpirable_test(random_bytes: Vec) { - let mut u = Unstructured::new(&random_bytes); - let nonce: U256 = u.arbitrary().unwrap(); - let nonexpirable = ExpirableNonce::maybe_from(nonce); - - assert!(nonexpirable.is_none()); - } - - #[rstest] - fn expirable_test(random_bytes: Vec) { - let current_timestamp = Utc::now(); - let mut u = arbitrary::Unstructured::new(&random_bytes); - let nonce: [u8; 20] = u.arbitrary().unwrap(); - - let expired = ExpirableNonce::new( - Deadline::new(current_timestamp.checked_sub_days(Days::new(1)).unwrap()), - nonce, - ); - assert!(expired.has_expired()); - - let not_expired = ExpirableNonce::new( - Deadline::new(current_timestamp.checked_add_days(Days::new(1)).unwrap()), - nonce, - ); - assert!(!not_expired.has_expired()); - } -} diff --git a/core/src/nonce/expirable.rs b/core/src/nonce/expirable.rs new file mode 100644 index 00000000..cd7637fb --- /dev/null +++ b/core/src/nonce/expirable.rs @@ -0,0 +1,61 @@ +use defuse_borsh_utils::adapters::{As, TimestampNanoSeconds}; +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; + +use crate::Deadline; + +/// Expirable nonces contain deadline which is 8 bytes of timestamp in nanoseconds +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(crate = "::near_sdk::borsh")] +pub struct ExpirableNonce +where + T: BorshSerialize + BorshDeserialize, +{ + #[borsh( + serialize_with = "As::::serialize", + deserialize_with = "As::::deserialize" + )] + pub deadline: Deadline, + pub nonce: T, +} + +impl ExpirableNonce +where + T: BorshSerialize + BorshDeserialize, +{ + pub const fn new(deadline: Deadline, nonce: T) -> Self { + Self { deadline, nonce } + } + + #[inline] + pub fn has_expired(&self) -> bool { + self.deadline.has_expired() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use chrono::{Days, Utc}; + use defuse_test_utils::random::random_bytes; + use rstest::rstest; + + #[rstest] + fn expirable_test(random_bytes: Vec) { + let current_timestamp = Utc::now(); + let mut u = arbitrary::Unstructured::new(&random_bytes); + let nonce: [u8; 24] = u.arbitrary().unwrap(); + + let expired = ExpirableNonce::new( + Deadline::new(current_timestamp.checked_sub_days(Days::new(1)).unwrap()), + nonce, + ); + assert!(expired.has_expired()); + + let not_expired = ExpirableNonce::new( + Deadline::new(current_timestamp.checked_add_days(Days::new(1)).unwrap()), + nonce, + ); + assert!(!not_expired.has_expired()); + } +} diff --git a/core/src/nonce/mod.rs b/core/src/nonce/mod.rs new file mode 100644 index 00000000..14c0708c --- /dev/null +++ b/core/src/nonce/mod.rs @@ -0,0 +1,62 @@ +mod expirable; +mod salted; +mod versioned; + +pub use { + expirable::ExpirableNonce, + salted::SaltedNonce, + salted::{Salt, SaltRegistry}, + versioned::VersionedNonce, +}; + +use defuse_bitmap::{BitMap256, U248, U256}; +use defuse_map_utils::{IterableMap, Map}; +use near_sdk::near; + +use crate::{DefuseError, Result}; + +pub type Nonce = U256; +pub type NoncePrefix = U248; + +/// See [permit2 nonce schema](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#nonce-schema) +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[near(serializers = [borsh, json])] +#[derive(Debug, Clone, Default)] +pub struct Nonces>(BitMap256); + +impl Nonces +where + T: Map, +{ + #[inline] + pub const fn new(bitmap: T) -> Self { + Self(BitMap256::new(bitmap)) + } + + #[inline] + pub fn is_used(&self, n: Nonce) -> bool { + self.0.get_bit(n) + } + + #[inline] + pub fn commit(&mut self, n: Nonce) -> Result<()> { + if self.0.set_bit(n) { + return Err(DefuseError::NonceUsed); + } + + Ok(()) + } + + #[inline] + pub fn cleanup_by_prefix(&mut self, prefix: NoncePrefix) -> bool { + self.0.cleanup_by_prefix(prefix) + } + + #[inline] + pub fn iter(&self) -> impl Iterator + '_ + where + T: IterableMap, + { + self.0.as_iter() + } +} diff --git a/core/src/nonce/salted.rs b/core/src/nonce/salted.rs new file mode 100644 index 00000000..36e2955b --- /dev/null +++ b/core/src/nonce/salted.rs @@ -0,0 +1,259 @@ +use core::mem; +use hex::FromHex; +use near_sdk::{ + IntoStorageKey, + borsh::{BorshDeserialize, BorshSerialize}, + env::{self, sha256_array}, + near, + store::{IterableMap, key::Identity}, +}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use std::{ + fmt::{self, Debug}, + str::FromStr, +}; + +use crate::{DefuseError, Result}; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(PartialEq, PartialOrd, Ord, Eq, Copy, Clone, SerializeDisplay, DeserializeFromStr)] +#[near(serializers = [borsh])] +pub struct Salt([u8; 4]); + +impl Salt { + pub fn derive(num: u8) -> Self { + const SIZE: usize = size_of::(); + + let seed = env::random_seed_array(); + let mut input = [0u8; 33]; + input[..32].copy_from_slice(&seed); + input[32] = num; + + Self( + sha256_array(&input)[..SIZE] + .try_into() + .unwrap_or_else(|_| unreachable!()), + ) + } +} + +impl fmt::Debug for Salt { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +impl fmt::Display for Salt { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + Debug::fmt(self, f) + } +} + +impl FromStr for Salt { + type Err = hex::FromHexError; + + fn from_str(s: &str) -> Result { + FromHex::from_hex(s).map(Self) + } +} + +#[cfg(all(feature = "abi", not(target_arch = "wasm32")))] +mod abi { + use super::*; + + use near_sdk::{ + schemars::{ + JsonSchema, + r#gen::SchemaGenerator, + schema::{InstanceType, Metadata, Schema, SchemaObject}, + }, + serde_json, + }; + + impl JsonSchema for Salt { + fn schema_name() -> String { + String::schema_name() + } + + fn is_referenceable() -> bool { + false + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + extensions: [("contentEncoding", "hex".into())] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ..Default::default() + } + .into() + } + } +} + +/// Contains current valid salt and set of previous +/// salts that can be valid or invalid. +#[near(serializers = [borsh])] +#[derive(Debug)] +pub struct SaltRegistry { + previous: IterableMap, + current: Salt, +} + +impl SaltRegistry { + /// There can be only one valid salt at the beginning + #[inline] + pub fn new(prefix: S) -> Self + where + S: IntoStorageKey, + { + Self { + previous: IterableMap::with_hasher(prefix), + current: Salt::derive(0), + } + } + + fn derive_next_salt(&self) -> Result { + (0..=u8::MAX) + .map(Salt::derive) + .find(|s| !self.is_valid(*s)) + .ok_or(DefuseError::SaltGenerationFailed) + } + + /// Rotates the current salt, making it previous and keeping it valid. + #[inline] + pub fn set_new(&mut self) -> Result { + let salt = self.derive_next_salt()?; + + let previous = mem::replace(&mut self.current, salt); + self.previous.insert(previous, true); + + Ok(previous) + } + + /// Deactivates the previous salt, making it invalid. + #[inline] + pub fn invalidate(&mut self, salt: Salt) -> Result<()> { + if salt == self.current { + self.set_new()?; + } + + self.previous + .get_mut(&salt) + .map(|v| *v = false) + .ok_or(DefuseError::InvalidSalt) + } + + #[inline] + pub fn is_valid(&self, salt: Salt) -> bool { + salt == self.current || self.previous.get(&salt).is_some_and(|v| *v) + } + + #[inline] + pub const fn current(&self) -> Salt { + self.current + } +} + +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(crate = "::near_sdk::borsh")] +pub struct SaltedNonce +where + T: BorshSerialize + BorshDeserialize, +{ + pub salt: Salt, + pub nonce: T, +} + +impl SaltedNonce +where + T: BorshSerialize + BorshDeserialize, +{ + pub const fn new(salt: Salt, nonce: T) -> Self { + Self { salt, nonce } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use arbitrary::Unstructured; + use defuse_test_utils::random::{Rng, random_bytes, rng}; + use near_sdk::{test_utils::VMContextBuilder, testing_env}; + + use rstest::rstest; + + impl From<&[u8]> for Salt { + fn from(value: &[u8]) -> Self { + let mut result = [0u8; 4]; + result.copy_from_slice(&value[..4]); + Self(result) + } + } + + fn seed_to_salt(seed: &[u8; 32], attempts: u8) -> Salt { + let seed = [seed, attempts.to_be_bytes().as_ref()].concat(); + let hash = sha256_array(&seed); + + hash[..4].into() + } + + fn set_random_seed(rng: &mut impl Rng) -> [u8; 32] { + let seed = rng.random(); + let context = VMContextBuilder::new().random_seed(seed).build(); + testing_env!(context); + + seed + } + + #[rstest] + fn contains_salt_test(random_bytes: Vec) { + let random_salt: Salt = Unstructured::new(&random_bytes).arbitrary().unwrap(); + let salts = SaltRegistry::new(random_bytes); + + assert!(salts.is_valid(salts.current)); + assert!(!salts.is_valid(random_salt)); + } + + #[rstest] + fn update_current_salt_test(random_bytes: Vec, mut rng: impl Rng) { + let mut salts = SaltRegistry::new(random_bytes); + + let seed = set_random_seed(&mut rng); + let previous_salt = salts.set_new().expect("should set new salt"); + + assert!(salts.is_valid(seed_to_salt(&seed, 0))); + assert!(salts.is_valid(previous_salt)); + + let previous_salt = salts.set_new().expect("should set new salt"); + assert!(salts.is_valid(seed_to_salt(&seed, 1))); + assert!(salts.is_valid(previous_salt)); + } + + #[rstest] + fn reset_salt_test(random_bytes: Vec, mut rng: impl Rng) { + let mut salts = SaltRegistry::new(random_bytes); + let random_salt = rng.random::<[u8; 4]>().as_slice().into(); + + let seed = set_random_seed(&mut rng); + let current = seed_to_salt(&seed, 0); + let previous_salt = salts.set_new().expect("should set new salt"); + + assert!(salts.invalidate(previous_salt).is_ok()); + assert!(!salts.is_valid(previous_salt)); + assert!(matches!( + salts.invalidate(random_salt).unwrap_err(), + DefuseError::InvalidSalt + )); + + let seed = set_random_seed(&mut rng); + let new_salt = seed_to_salt(&seed, 0); + + assert!(salts.invalidate(current).is_ok()); + assert!(!salts.is_valid(current)); + assert_eq!(salts.current(), new_salt); + } +} diff --git a/core/src/nonce/versioned.rs b/core/src/nonce/versioned.rs new file mode 100644 index 00000000..8f825d4a --- /dev/null +++ b/core/src/nonce/versioned.rs @@ -0,0 +1,73 @@ +use hex_literal::hex; +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; + +use crate::{ + Nonce, + nonce::{expirable::ExpirableNonce, salted::SaltedNonce}, +}; + +/// To distinguish between legacy nonces and versioned nonces +/// we use a specific prefix individual for each version. +/// Serialized versioned nonce contains: +/// `VERSIONED_MAGIC_PREFIX (4 bytes) || VERSION (1 byte) || NONCE_BYTES (27 bytes)` +/// Currently supported versions: +/// - V1: `SALT (4 bytes) || DEADLINE (8 bytes) || NONCE (15 random bytes)` +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[borsh(crate = "::near_sdk::borsh")] +pub enum VersionedNonce { + V1(SaltedNonce>), +} + +impl VersionedNonce { + /// Magic prefixes (first 4 bytes of `sha256()`) used to mark versioned nonces: + pub const VERSIONED_MAGIC_PREFIX: [u8; 4] = hex!("5628f6c6"); + + pub fn maybe_from(n: Nonce) -> Option { + let mut versioned = n.strip_prefix(&Self::VERSIONED_MAGIC_PREFIX)?; + Self::deserialize_reader(&mut versioned).ok() + } +} + +impl From for Nonce { + fn from(value: VersionedNonce) -> Self { + const SIZE: usize = size_of::(); + let mut result = [0u8; SIZE]; + + (VersionedNonce::VERSIONED_MAGIC_PREFIX, value) + .serialize(&mut result.as_mut_slice()) + .unwrap_or_else(|_| unreachable!()); + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::{Deadline, nonce::salted::Salt}; + use arbitrary::Unstructured; + use chrono::Utc; + use defuse_test_utils::random::random_bytes; + use rstest::rstest; + + #[rstest] + fn maybe_from_test(random_bytes: Vec) { + let mut u = Unstructured::new(&random_bytes); + let legacy_nonce: Nonce = u.arbitrary().unwrap(); + + let expected = VersionedNonce::maybe_from(legacy_nonce); + assert!(expected.is_none()); + + let mut u = Unstructured::new(&random_bytes); + let nonce_bytes: [u8; 15] = u.arbitrary().unwrap(); + let now = Deadline::new(Utc::now()); + let salt: Salt = u.arbitrary().unwrap(); + + let salted = SaltedNonce::new(salt, ExpirableNonce::new(now, nonce_bytes)); + let nonce: Nonce = VersionedNonce::V1(salted.clone()).into(); + + let exp = VersionedNonce::maybe_from(nonce); + assert_eq!(exp, Some(VersionedNonce::V1(salted))); + } +} diff --git a/defuse/Cargo.toml b/defuse/Cargo.toml index 941397a0..a861c5af 100644 --- a/defuse/Cargo.toml +++ b/defuse/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defuse" -version = "0.2.9" +version = "0.3.0" edition.workspace = true rust-version.workspace = true repository.workspace = true diff --git a/defuse/src/accounts.rs b/defuse/src/accounts.rs index b65c316d..e4110e32 100644 --- a/defuse/src/accounts.rs +++ b/defuse/src/accounts.rs @@ -29,10 +29,10 @@ pub trait AccountManager { /// [permit2 nonce schema](https://docs.uniswap.org/contracts/permit2/reference/signature-transfer#nonce-schema). fn is_nonce_used(&self, account_id: &AccountId, nonce: AsBase64) -> bool; - /// Clears all expired nonces for given accounts. + /// Clears all expired nonces for given accounts by its prefix. /// Omitting any errors, e.g. if account doesn't exist or nonces are not expired. /// NOTE: MUST attach 1 yⓃ for security purposes. - fn cleanup_expired_nonces(&mut self, nonces: Vec<(AccountId, Vec>)>); + fn cleanup_nonces(&mut self, nonces: Vec<(AccountId, Vec>)>); /// Returns whether authentication by PREDECESSOR_ID is enabled /// for given `account_id`. diff --git a/defuse/src/contract/accounts/account/mod.rs b/defuse/src/contract/accounts/account/mod.rs index 03b9cc5e..de06e0ae 100644 --- a/defuse/src/contract/accounts/account/mod.rs +++ b/defuse/src/contract/accounts/account/mod.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use bitflags::bitflags; use defuse_bitmap::U256; use defuse_core::{ - Result, + NoncePrefix, Result, accounts::{AccountEvent, PublicKeyEvent}, crypto::PublicKey, events::DefuseEvent, @@ -149,12 +149,12 @@ impl Account { self.nonces.commit(nonce) } - /// Clears the nonce if it was expired. - /// Returns whether the nonces was cleared. If the nonce has not expired yet, then returns `false`, + /// Clears the all nonces with corresponding prefix if it was expired/invalidated. + /// Returns whether the nonces was cleared, /// regardless of whether it was previously committed or not. #[inline] - pub fn clear_expired_nonce(&mut self, nonce: U256) -> bool { - self.nonces.clear_expired(nonce) + pub fn cleanup_nonce_by_prefix(&mut self, prefix: NoncePrefix) -> bool { + self.nonces.cleanup_by_prefix(prefix) } #[inline] diff --git a/defuse/src/contract/accounts/account/nonces.rs b/defuse/src/contract/accounts/account/nonces.rs index 5b9cbb57..d518dd58 100644 --- a/defuse/src/contract/accounts/account/nonces.rs +++ b/defuse/src/contract/accounts/account/nonces.rs @@ -6,7 +6,7 @@ use near_sdk::{ store::{LookupMap, key::Sha256}, }; -use defuse_core::{DefuseError, Nonce, Nonces, Result}; +use defuse_core::{DefuseError, Nonce, NoncePrefix, Nonces, Result}; pub type MaybeLegacyAccountNonces = MaybeLegacyNonces, LookupMap>; @@ -61,10 +61,6 @@ where #[inline] pub fn is_used(&self, nonce: Nonce) -> bool { - // Check legacy map only if the nonce is not expirable - // otherwise check both maps - - // TODO: legacy nonces which have expirable prefix can be committed twice, check probability! self.nonces.is_used(nonce) || self .legacy @@ -73,9 +69,8 @@ where } #[inline] - pub fn clear_expired(&mut self, nonce: Nonce) -> bool { - // Expirable nonces can not be in the legacy map - self.nonces.clear_expired(nonce) + pub fn cleanup_by_prefix(&mut self, prefix: NoncePrefix) -> bool { + self.nonces.cleanup_by_prefix(prefix) } } @@ -84,9 +79,7 @@ pub(super) mod tests { use super::*; - use chrono::{Days, Utc}; use defuse_bitmap::U256; - use defuse_core::{Deadline, ExpirableNonce}; use defuse_test_utils::random::{Rng, range_to_random_size, rng}; use rstest::fixture; @@ -95,22 +88,13 @@ pub(super) mod tests { use defuse_test_utils::random::{make_arbitrary, random_bytes}; use rstest::rstest; - fn generate_nonce(expirable: bool, mut rng: impl Rng) -> U256 { - if expirable { - let future_deadline = Deadline::new(Utc::now().checked_add_days(Days::new(1)).unwrap()); - ExpirableNonce::new(future_deadline, rng.random()).into() - } else { - rng.random() - } - } - #[fixture] pub(crate) fn random_nonces( mut rng: impl Rng, #[default(10..100)] size: impl RangeBounds, ) -> Vec { (0..range_to_random_size(&mut rng, size)) - .map(|_| generate_nonce(rng.random(), &mut rng)) + .map(|_| rng.random()) .collect() } @@ -145,18 +129,18 @@ pub(super) mod tests { #[rstest] #[allow(clippy::used_underscore_binding)] fn commit_new_nonce(random_bytes: Vec, mut rng: impl Rng) { - let expirable_nonce = generate_nonce(true, &mut rng); - let legacy_nonce = generate_nonce(false, &mut rng); + let new_nonce = rng.random(); + let legacy_nonce = rng.random(); let mut new = MaybeLegacyAccountNonces::new(LookupMap::with_hasher(random_bytes)); - new.commit(expirable_nonce) - .expect("should be able to commit new expirable nonce"); + new.commit(new_nonce) + .expect("should be able to commit new nonce"); new.commit(legacy_nonce) .expect("should be able to commit new legacy nonce"); assert!(new.legacy.is_none()); - for n in [expirable_nonce, legacy_nonce] { + for n in [new_nonce, legacy_nonce] { assert!(new.nonces.is_used(n)); assert!(new.is_used(n)); } @@ -180,7 +164,7 @@ pub(super) mod tests { #[rstest] fn commit_duplicate_nonce(random_bytes: Vec, mut rng: impl Rng) { let mut new = MaybeLegacyAccountNonces::new(LookupMap::with_hasher(random_bytes)); - let nonce = generate_nonce(false, &mut rng); + let nonce = rng.random(); new.commit(nonce).expect("First commit should succeed"); @@ -190,19 +174,6 @@ pub(super) mod tests { )); } - #[rstest] - fn commit_expired_nonce(random_bytes: Vec, mut rng: impl Rng) { - let expired_deadline = Deadline::new(Utc::now().checked_sub_days(Days::new(1)).unwrap()); - let expired_nonce = ExpirableNonce::new(expired_deadline, rng.random()).into(); - - let mut new = MaybeLegacyAccountNonces::new(LookupMap::with_hasher(random_bytes)); - - assert!(matches!( - new.commit(expired_nonce).unwrap_err(), - DefuseError::NonceExpired - )); - } - #[rstest] #[allow(clippy::used_underscore_binding)] fn check_used_nonces( @@ -225,33 +196,16 @@ pub(super) mod tests { #[rstest] #[allow(clippy::used_underscore_binding)] - fn legacy_nonces_cant_be_cleared( - #[values(true, false)] expirable: bool, - random_bytes: Vec, - mut rng: impl Rng, - ) { - let random_nonce = generate_nonce(expirable, &mut rng); + fn legacy_nonces_cant_be_cleared(random_bytes: Vec, mut rng: impl Rng) { + let random_nonce = rng.random(); let legacy_nonces = get_legacy_map(&[random_nonce], random_bytes.clone()); let mut new = MaybeLegacyAccountNonces::with_legacy( legacy_nonces, LookupMap::with_hasher(random_bytes), ); - assert!(!new.clear_expired(random_nonce)); + let [prefix @ .., _] = random_nonce; + assert!(!new.cleanup_by_prefix(prefix)); assert!(new.is_used(random_nonce)); } - - #[rstest] - fn clear_active_nonce_fails(random_bytes: Vec, mut rng: impl Rng) { - let future_deadline = Deadline::new(Utc::now().checked_add_days(Days::new(1)).unwrap()); - let valid_nonce = ExpirableNonce::new(future_deadline, rng.random()).into(); - - let mut new = MaybeLegacyAccountNonces::new(LookupMap::with_hasher(random_bytes)); - - new.commit(valid_nonce) - .expect("should be able to commit new expirable nonce"); - - assert!(!new.clear_expired(valid_nonce)); - assert!(new.is_used(valid_nonce)); - } } diff --git a/defuse/src/contract/accounts/mod.rs b/defuse/src/contract/accounts/mod.rs index d6104b4b..2ec72446 100644 --- a/defuse/src/contract/accounts/mod.rs +++ b/defuse/src/contract/accounts/mod.rs @@ -7,13 +7,14 @@ pub use self::{account::*, state::*}; use std::collections::HashSet; use defuse_core::{ - DefuseError, Nonce, + DefuseError, ExpirableNonce, Nonce, SaltedNonce, VersionedNonce, crypto::PublicKey, engine::{State, StateView}, }; use defuse_near_utils::{Lock, NestPrefix, PREDECESSOR_ACCOUNT_ID, UnwrapOrPanic}; use defuse_serde_utils::base64::AsBase64; +use near_plugins::{AccessControllable, access_control_any}; use near_sdk::{ AccountId, AccountIdRef, BorshStorageKey, FunctionError, IntoStorageKey, assert_one_yocto, borsh::BorshSerialize, near, store::IterableMap, @@ -21,7 +22,7 @@ use near_sdk::{ use crate::{ accounts::AccountManager, - contract::{Contract, ContractExt, accounts::AccountEntry}, + contract::{Contract, ContractExt, Role, accounts::AccountEntry}, }; #[near] @@ -52,15 +53,21 @@ impl AccountManager for Contract { StateView::is_nonce_used(self, account_id, nonce.into_inner()) } - fn cleanup_expired_nonces(&mut self, nonces: Vec<(AccountId, Vec>)>) { + #[access_control_any(roles(Role::DAO, Role::GarbageCollector))] + #[payable] + fn cleanup_nonces(&mut self, nonces: Vec<(AccountId, Vec>)>) { + assert_one_yocto(); + for (account_id, nonces) in nonces { - // NOTE: all errors are omitted - State::cleanup_expired_nonces( - self, - &account_id, - nonces.into_iter().map(AsBase64::into_inner), - ) - .ok(); + for nonce in nonces.into_iter().map(AsBase64::into_inner) { + if !self.is_nonce_cleanable(nonce) { + continue; + } + + // NOTE: all errors are omitted + let [prefix @ .., _] = nonce; + let _ = State::cleanup_nonce_by_prefix(self, &account_id, prefix); + } } } @@ -77,6 +84,20 @@ impl AccountManager for Contract { } impl Contract { + #[inline] + fn is_nonce_cleanable(&self, nonce: Nonce) -> bool { + let Some(versioned_nonce) = VersionedNonce::maybe_from(nonce) else { + return false; + }; + + match versioned_nonce { + VersionedNonce::V1(SaltedNonce { + salt, + nonce: ExpirableNonce { deadline, .. }, + }) => deadline.has_expired() || !self.is_valid_salt(salt), + } + } + #[inline] pub fn ensure_auth_predecessor_id(&self) -> &'static AccountId { if !StateView::is_auth_by_predecessor_id_enabled(self, &PREDECESSOR_ACCOUNT_ID) { @@ -100,6 +121,7 @@ impl Accounts { S: IntoStorageKey, { let prefix = prefix.into_storage_key(); + Self { accounts: IterableMap::new(prefix.as_slice().nest(AccountsPrefix::Accounts)), prefix, diff --git a/defuse/src/contract/events.rs b/defuse/src/contract/events.rs index cd59f287..8da3cfb2 100644 --- a/defuse/src/contract/events.rs +++ b/defuse/src/contract/events.rs @@ -6,10 +6,6 @@ use defuse_nep245::{MtBurnEvent, MtEvent}; pub struct PostponedMtBurnEvents(Vec>); impl PostponedMtBurnEvents { - pub const fn new() -> Self { - Self(Vec::new()) - } - pub fn mt_burn(&mut self, event: MtBurnEvent<'static>) { self.0.push(event); } diff --git a/defuse/src/contract/intents/mod.rs b/defuse/src/contract/intents/mod.rs index 4f3324d1..f8389327 100644 --- a/defuse/src/contract/intents/mod.rs +++ b/defuse/src/contract/intents/mod.rs @@ -50,7 +50,10 @@ impl Intents for Contract { intents_executed: inspector.intents_executed, min_deadline: inspector.min_deadline, invariant_violated, - state: StateOutput { fee: self.fee() }, + state: StateOutput { + fee: self.fee(), + current_salt: self.salts.current(), + }, } } } diff --git a/defuse/src/contract/intents/state.rs b/defuse/src/contract/intents/state.rs index 6517012a..01330a5c 100644 --- a/defuse/src/contract/intents/state.rs +++ b/defuse/src/contract/intents/state.rs @@ -1,5 +1,5 @@ use defuse_core::{ - DefuseError, Nonce, Result, + DefuseError, Nonce, NoncePrefix, Result, Salt, crypto::PublicKey, engine::{State, StateView}, fees::Pips, @@ -90,6 +90,10 @@ impl StateView for Contract { .map(Lock::as_inner_unchecked) .is_none_or(Account::is_auth_by_predecessor_id_enabled) } + + fn is_valid_salt(&self, salt: Salt) -> bool { + self.salts.is_valid(salt) + } } impl State for Contract { @@ -125,22 +129,18 @@ impl State for Contract { } #[inline] - fn cleanup_expired_nonces( + fn cleanup_nonce_by_prefix( &mut self, - account_id: &AccountId, - nonces: impl IntoIterator, - ) -> Result<()> { + account_id: &AccountIdRef, + prefix: NoncePrefix, + ) -> Result { let account = self .accounts .get_mut(account_id) - .ok_or_else(|| DefuseError::AccountNotFound(account_id.clone()))? + .ok_or_else(|| DefuseError::AccountNotFound(account_id.to_owned()))? .as_inner_unchecked_mut(); - for n in nonces { - account.clear_expired_nonce(n); - } - - Ok(()) + Ok(account.cleanup_nonce_by_prefix(prefix)) } fn internal_add_balance( diff --git a/defuse/src/contract/mod.rs b/defuse/src/contract/mod.rs index 5eb64804..8c81d0f4 100644 --- a/defuse/src/contract/mod.rs +++ b/defuse/src/contract/mod.rs @@ -6,22 +6,25 @@ pub mod config; mod events; mod fees; mod intents; +mod salts; mod state; mod tokens; mod upgrade; +mod versioned; use core::iter; +use defuse_borsh_utils::adapters::As; use defuse_core::Result; - -use events::PostponedMtBurnEvents; use impl_tools::autoimpl; use near_plugins::{AccessControlRole, AccessControllable, Pausable, access_control}; use near_sdk::{ - BorshStorageKey, PanicOnDefault, borsh::BorshDeserialize, near, require, store::LookupSet, + BorshStorageKey, IntoStorageKey, PanicOnDefault, borsh::BorshDeserialize, near, require, + store::LookupSet, }; +use versioned::MaybeVersionedContractStorage; -use crate::Defuse; +use crate::{Defuse, contract::events::PostponedMtBurnEvents}; use self::{ accounts::Accounts, @@ -45,6 +48,10 @@ pub enum Role { UnrestrictedAccountLocker, UnrestrictedAccountUnlocker, + + SaltManager, + + GarbageCollector, } #[access_control(role_type(Role))] @@ -60,16 +67,34 @@ pub enum Role { standard(standard = "nep245", version = "1.0.0"), ) )] +#[autoimpl(Deref using self.storage)] +#[autoimpl(DerefMut using self.storage)] +pub struct Contract { + #[borsh( + deserialize_with = "As::::deserialize", + serialize_with = "As::::serialize" + )] + storage: ContractStorage, + + #[borsh(skip)] + runtime: Runtime, +} + +#[derive(Debug)] #[autoimpl(Deref using self.state)] #[autoimpl(DerefMut using self.state)] -pub struct Contract { +#[near(serializers = [borsh])] +pub struct ContractStorage { accounts: Accounts, + state: ContractState, relayer_keys: LookupSet, +} - #[borsh(skip)] - postponed_burns: PostponedMtBurnEvents, +#[derive(Debug, Default)] +pub struct Runtime { + pub postponed_burns: PostponedMtBurnEvents, } #[near] @@ -79,10 +104,12 @@ impl Contract { #[allow(clippy::use_self)] // Clippy seems to not play well with near-sdk, or there is a bug in clippy - seen in shared security analysis pub fn new(config: DefuseConfig) -> Self { let mut contract = Self { - accounts: Accounts::new(Prefix::Accounts), - state: ContractState::new(Prefix::State, config.wnear_id, config.fees), - relayer_keys: LookupSet::new(Prefix::RelayerKeys), - postponed_burns: PostponedMtBurnEvents::new(), + storage: ContractStorage { + accounts: Accounts::new(Prefix::Accounts), + state: ContractState::new(Prefix::State, config.wnear_id, config.fees), + relayer_keys: LookupSet::new(Prefix::RelayerKeys), + }, + runtime: Runtime::default(), }; contract.init_acl(config.roles); contract @@ -120,3 +147,9 @@ enum Prefix { State, RelayerKeys, } + +pub trait MigrateStorageWithPrefix: Sized { + fn migrate(val: T, prefix: S) -> Self + where + S: IntoStorageKey; +} diff --git a/defuse/src/contract/salts.rs b/defuse/src/contract/salts.rs new file mode 100644 index 00000000..eb507794 --- /dev/null +++ b/defuse/src/contract/salts.rs @@ -0,0 +1,61 @@ +use std::collections::BTreeSet; + +use defuse_core::{Salt, accounts::SaltRotationEvent, events::DefuseIntentEmit}; +use defuse_near_utils::UnwrapOrPanic; +use near_plugins::{AccessControllable, access_control_any}; +use near_sdk::{assert_one_yocto, near}; + +use super::{Contract, ContractExt, Role}; +use crate::salts::SaltManager; + +#[near] +impl SaltManager for Contract { + #[access_control_any(roles(Role::DAO, Role::SaltManager))] + #[payable] + fn update_current_salt(&mut self) -> Salt { + assert_one_yocto(); + + self.salts.set_new().unwrap_or_panic(); + let current = self.salts.current(); + + SaltRotationEvent { + current, + invalidated: BTreeSet::new(), + } + .emit(); + + current + } + + #[access_control_any(roles(Role::DAO, Role::SaltManager))] + #[payable] + fn invalidate_salts(&mut self, salts: Vec) -> Salt { + assert_one_yocto(); + + // NOTE: omits any errors + let invalidated = salts + .into_iter() + .filter(|s| self.salts.invalidate(*s).is_ok()) + .collect(); + + let current = self.salts.current(); + + SaltRotationEvent { + current, + invalidated, + } + .emit(); + + current + } + + #[inline] + fn is_valid_salt(&self, salt: Salt) -> bool { + self.salts.is_valid(salt) + } + + #[inline] + fn current_salt(&self) -> Salt { + self.salts.current() + } +} diff --git a/defuse/src/contract/state.rs b/defuse/src/contract/state/mod.rs similarity index 69% rename from defuse/src/contract/state.rs rename to defuse/src/contract/state/mod.rs index 31c360e0..2ae6f4cb 100644 --- a/defuse/src/contract/state.rs +++ b/defuse/src/contract/state/mod.rs @@ -1,4 +1,8 @@ -use defuse_core::{amounts::Amounts, fees::FeesConfig, token_id::TokenId}; +mod v0; + +pub use v0::ContractStateV0; + +use defuse_core::{SaltRegistry, amounts::Amounts, fees::FeesConfig, token_id::TokenId}; use defuse_near_utils::NestPrefix; use near_sdk::{ AccountId, BorshStorageKey, IntoStorageKey, borsh::BorshSerialize, near, store::IterableMap, @@ -14,6 +18,8 @@ pub struct ContractState { pub wnear_id: AccountId, pub fees: FeesConfig, + + pub salts: SaltRegistry, } impl ContractState { @@ -22,12 +28,15 @@ impl ContractState { where S: IntoStorageKey, { + let prefix = prefix.into_storage_key(); + Self { total_supplies: TokenBalances::new(IterableMap::new( - prefix.into_storage_key().nest(Prefix::TotalSupplies), + prefix.as_slice().nest(Prefix::TotalSupplies), )), wnear_id, fees, + salts: SaltRegistry::new(prefix.as_slice().nest(Prefix::Salts)), } } } @@ -36,4 +45,5 @@ impl ContractState { #[borsh(crate = "::near_sdk::borsh")] enum Prefix { TotalSupplies, + Salts, } diff --git a/defuse/src/contract/state/v0.rs b/defuse/src/contract/state/v0.rs new file mode 100644 index 00000000..23f127fa --- /dev/null +++ b/defuse/src/contract/state/v0.rs @@ -0,0 +1,64 @@ +use defuse_core::{SaltRegistry, fees::FeesConfig}; +use defuse_near_utils::NestPrefix; +use near_sdk::{AccountId, IntoStorageKey, near}; + +use crate::contract::{ + MigrateStorageWithPrefix, + state::{ContractState, Prefix, TokenBalances}, +}; + +#[near(serializers = [borsh])] +#[derive(Debug)] +pub struct ContractStateV0 { + pub total_supplies: TokenBalances, + + pub wnear_id: AccountId, + + pub fees: FeesConfig, +} + +impl MigrateStorageWithPrefix for ContractState { + fn migrate( + ContractStateV0 { + total_supplies, + wnear_id, + fees, + }: ContractStateV0, + prefix: S, + ) -> Self + where + S: IntoStorageKey, + { + Self { + total_supplies, + wnear_id, + fees, + salts: SaltRegistry::new(prefix.into_storage_key().nest(Prefix::Salts)), + } + } +} + +/// Legacy implementation of [`ContractStorageV0`] +#[cfg(test)] +pub(super) mod tests { + + use super::*; + use near_sdk::{AccountId, store::IterableMap}; + + impl ContractStateV0 { + #[inline] + pub fn new(prefix: S, wnear_id: AccountId, fees: FeesConfig) -> Self + where + S: IntoStorageKey, + { + let prefix = prefix.into_storage_key(); + Self { + total_supplies: TokenBalances::new(IterableMap::new( + prefix.as_slice().nest(Prefix::TotalSupplies), + )), + wnear_id, + fees, + } + } + } +} diff --git a/defuse/src/contract/tokens/mod.rs b/defuse/src/contract/tokens/mod.rs index 2420589d..4ccf81e9 100644 --- a/defuse/src/contract/tokens/mod.rs +++ b/defuse/src/contract/tokens/mod.rs @@ -18,6 +18,7 @@ impl Contract { memo: Option<&str>, ) -> Result<()> { let owner = self + .storage .accounts .get_or_create(owner_id.clone()) // deposits are allowed for locked accounts @@ -39,6 +40,7 @@ impl Contract { mint_event.amounts.to_mut().push(U128(amount)); let total_supply = self + .storage .state .total_supplies .add(token_id.clone(), amount) @@ -51,6 +53,7 @@ impl Contract { } TokenId::Nep141(_) | TokenId::Nep245(_) => {} } + owner .token_balances .add(token_id, amount) @@ -72,6 +75,7 @@ impl Contract { force: bool, ) -> Result<()> { let owner = self + .storage .accounts .get_mut(owner_id) .ok_or_else(|| DefuseError::AccountNotFound(owner_id.to_owned()))? @@ -99,7 +103,8 @@ impl Contract { .sub(token_id.clone(), amount) .ok_or(DefuseError::BalanceOverflow)?; - self.state + self.storage + .state .total_supplies .sub(token_id, amount) .ok_or(DefuseError::BalanceOverflow)?; @@ -110,7 +115,7 @@ impl Contract { // `mt_transfer` arrives. This can happen due to postponed // delta-matching during intents execution. if !burn_event.amounts.is_empty() { - self.postponed_burns.mt_burn(burn_event); + self.runtime.postponed_burns.mt_burn(burn_event); } Ok(()) diff --git a/defuse/src/contract/versioned/mod.rs b/defuse/src/contract/versioned/mod.rs new file mode 100644 index 00000000..658f0e87 --- /dev/null +++ b/defuse/src/contract/versioned/mod.rs @@ -0,0 +1,115 @@ +mod v0; + +use std::{ + borrow::Cow, + io::{self, Read}, +}; + +use defuse_borsh_utils::adapters::{BorshDeserializeAs, BorshSerializeAs}; +use defuse_near_utils::PanicOnClone; +use near_sdk::{ + borsh::{BorshDeserialize, BorshSerialize}, + near, +}; + +use super::ContractStorage; +use v0::ContractStorageV0; + +/// Versioned [Contract] state for de/serialization. +#[derive(Debug)] +#[near(serializers = [borsh])] +enum VersionedContractStorage<'a> { + V0(Cow<'a, PanicOnClone>), + // When upgrading to a new version, given current version `N`: + // 1. Copy current `ContractStorage` struct definition and name it `ContractStorageVN` + // 2. Add variant `VN(Cow<'a, PanicOnClone>)` before `Latest` + // 3. Handle new variant in `match` expessions below + // 4. Add tests for `VN -> Latest` migration + Latest(Cow<'a, PanicOnClone>), +} + +impl From> for ContractStorage { + fn from(versioned: VersionedContractStorage<'_>) -> Self { + // Borsh always deserializes into `Cow::Owned`, so it's + // safe to call `Cow::>::into_owned()` here. + match versioned { + VersionedContractStorage::V0(contract) => contract.into_owned().into_inner().into(), + VersionedContractStorage::Latest(contract) => contract.into_owned().into_inner(), + } + } +} + +// Used for current contract serialization +impl<'a> From<&'a ContractStorage> for VersionedContractStorage<'a> { + fn from(value: &'a ContractStorage) -> Self { + // always serialize as latest version + Self::Latest(Cow::Borrowed(PanicOnClone::from_ref(value))) + } +} + +// Used for legacy contract deserialization +impl From for VersionedContractStorage<'_> { + fn from(value: ContractStorageV0) -> Self { + Self::V0(Cow::Owned(value.into())) + } +} + +pub struct MaybeVersionedContractStorage; + +impl MaybeVersionedContractStorage { + /// This is a magic number that is used to differentiate between + /// borsh-serialized representations of legacy and versioned [`Contract`]s: + /// * versioned [`Contract`]s always start with this prefix + /// * legacy [`Contract`] starts with other 4 bytes + /// + /// This is safe to assume that legacy [`Contract`] doesn't start with + /// this prefix, since the first 4 bytes in legacy [`Contract`] were used + /// to denote the length of `keys: Vector,` in [`IterableMap`] for + /// `accounts`, so coincidence can happen in case the number of accounts + /// approaches the maximum possible, which is unlikely at this time + /// given the number of accounts stored in the contract. + const VERSIONED_MAGIC_PREFIX: u32 = u32::MAX; +} + +impl BorshDeserializeAs for MaybeVersionedContractStorage { + fn deserialize_as(reader: &mut R) -> io::Result + where + R: io::Read, + { + // There will always be 4 bytes for u32: + // * either `VERSIONED_MAGIC_PREFIX`, + // * or u32 for `Contract.accounts.keys.len` + let mut buf = [0u8; size_of::()]; + reader.read_exact(&mut buf)?; + let prefix = u32::deserialize_reader(&mut buf.as_slice())?; + + if prefix == Self::VERSIONED_MAGIC_PREFIX { + VersionedContractStorage::deserialize_reader(reader) + } else { + // legacy state + ContractStorageV0::deserialize_reader( + // prepend already consumed part of the reader + &mut buf.chain(reader), + ) + .map(Into::into) + } + .map(Into::into) + } +} + +impl BorshSerializeAs for MaybeVersionedContractStorage +where + for<'a> VersionedContractStorage<'a>: From<&'a T>, +{ + fn serialize_as(source: &T, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + ( + // always serialize as versioned and prepend magic prefix + Self::VERSIONED_MAGIC_PREFIX, + VersionedContractStorage::from(source), + ) + .serialize(writer) + } +} diff --git a/defuse/src/contract/versioned/v0.rs b/defuse/src/contract/versioned/v0.rs new file mode 100644 index 00000000..d970340b --- /dev/null +++ b/defuse/src/contract/versioned/v0.rs @@ -0,0 +1,36 @@ +use impl_tools::autoimpl; +use near_sdk::{near, store::LookupSet}; + +use crate::contract::{ + ContractStorage, MigrateStorageWithPrefix, Prefix, + accounts::Accounts, + state::{ContractState, ContractStateV0}, +}; + +#[derive(Debug)] +#[autoimpl(Deref using self.state)] +#[autoimpl(DerefMut using self.state)] +#[near(serializers = [borsh])] +pub struct ContractStorageV0 { + accounts: Accounts, + + state: ContractStateV0, + + relayer_keys: LookupSet, +} + +impl From for ContractStorage { + fn from( + ContractStorageV0 { + accounts, + state, + relayer_keys, + }: ContractStorageV0, + ) -> Self { + Self { + accounts, + state: ContractState::migrate(state, Prefix::State), + relayer_keys, + } + } +} diff --git a/defuse/src/intents.rs b/defuse/src/intents.rs index 5d93f40a..7884eaec 100644 --- a/defuse/src/intents.rs +++ b/defuse/src/intents.rs @@ -1,5 +1,5 @@ use defuse_core::{ - Deadline, Result, + Deadline, Result, Salt, accounts::{AccountEvent, NonceEvent}, engine::deltas::InvariantViolated, fees::Pips, @@ -11,10 +11,10 @@ use near_plugins::AccessControllable; use near_sdk::{Promise, PublicKey, ext_contract, near}; use serde_with::serde_as; -use crate::fees::FeesManager; +use crate::{fees::FeesManager, salts::SaltManager}; #[ext_contract(ext_intents)] -pub trait Intents: FeesManager { +pub trait Intents: FeesManager + SaltManager { fn execute_intents(&mut self, signed: Vec); fn simulate_intents(&self, signed: Vec) -> SimulationOutput; @@ -59,6 +59,8 @@ impl SimulationOutput { #[derive(Debug, Clone)] pub struct StateOutput { pub fee: Pips, + + pub current_salt: Salt, } #[ext_contract(ext_relayer_keys)] diff --git a/defuse/src/lib.rs b/defuse/src/lib.rs index 1de64408..02357abc 100644 --- a/defuse/src/lib.rs +++ b/defuse/src/lib.rs @@ -5,6 +5,7 @@ pub mod contract; pub mod accounts; pub mod fees; pub mod intents; +pub mod salts; pub mod tokens; pub use defuse_core as core; diff --git a/defuse/src/salts.rs b/defuse/src/salts.rs new file mode 100644 index 00000000..0ebf4305 --- /dev/null +++ b/defuse/src/salts.rs @@ -0,0 +1,21 @@ +use defuse_core::Salt; +use near_sdk::ext_contract; + +#[ext_contract(ext_salt_manager)] +#[allow(clippy::module_name_repetitions)] +pub trait SaltManager { + /// Sets the current salt to a new one, previous salt remains valid. + /// Returns the new current salt. + fn update_current_salt(&mut self) -> Salt; + + /// Invalidates the provided salt: invalidates provided salt, + /// sets a new one if it was current salt. + /// Returns the current salt. + fn invalidate_salts(&mut self, salts: Vec) -> Salt; + + /// Returns whether the provided salt is valid + fn is_valid_salt(&self, salt: Salt) -> bool; + + /// Returns the current salt + fn current_salt(&self) -> Salt; +} diff --git a/tests/src/tests/defuse/accounts/mod.rs b/tests/src/tests/defuse/accounts/mod.rs index 0300f8db..8155943d 100644 --- a/tests/src/tests/defuse/accounts/mod.rs +++ b/tests/src/tests/defuse/accounts/mod.rs @@ -23,8 +23,9 @@ pub trait AccountManagerExt { public_key: PublicKey, ) -> anyhow::Result<()>; - async fn cleanup_expired_nonces( + async fn cleanup_nonces( &self, + defuse_contract_id: &AccountId, data: &[(AccountId, Vec)], ) -> anyhow::Result; @@ -90,8 +91,9 @@ impl AccountManagerExt for near_workspaces::Account { Ok(()) } - async fn cleanup_expired_nonces( + async fn cleanup_nonces( &self, + defuse_contract_id: &AccountId, data: &[(AccountId, Vec)], ) -> anyhow::Result { let nonces = data @@ -104,7 +106,8 @@ impl AccountManagerExt for near_workspaces::Account { .collect::>)>>(); let res = self - .call(self.id(), "cleanup_expired_nonces") + .call(defuse_contract_id, "cleanup_nonces") + .deposit(NearToken::from_yoctonear(1)) .args_json(json!({ "nonces": nonces, })) @@ -201,11 +204,14 @@ impl AccountManagerExt for near_workspaces::Contract { .await } - async fn cleanup_expired_nonces( + async fn cleanup_nonces( &self, + defuse_contract_id: &AccountId, data: &[(AccountId, Vec)], ) -> anyhow::Result { - self.as_account().cleanup_expired_nonces(data).await + self.as_account() + .cleanup_nonces(defuse_contract_id, data) + .await } async fn defuse_has_public_key( diff --git a/tests/src/tests/defuse/accounts/nonces.rs b/tests/src/tests/defuse/accounts/nonces.rs index 8d95e34a..0f972b96 100644 --- a/tests/src/tests/defuse/accounts/nonces.rs +++ b/tests/src/tests/defuse/accounts/nonces.rs @@ -1,6 +1,11 @@ use arbitrary::{Arbitrary, Unstructured}; use chrono::{TimeDelta, Utc}; -use defuse::core::{Deadline, ExpirableNonce, intents::DefuseIntents}; +use defuse::{ + contract::Role, + core::{ + Deadline, ExpirableNonce, Nonce, Salt, SaltedNonce, VersionedNonce, intents::DefuseIntents, + }, +}; use itertools::Itertools; use std::time::Duration; @@ -8,177 +13,348 @@ use tokio::time::sleep; use defuse_test_utils::{ asserts::ResultAssertsExt, - random::{Rng, rng}, + random::{Rng, random_bytes, rng}, }; use near_sdk::AccountId; use rstest::rstest; -use crate::tests::defuse::{ - DefuseSigner, SigningStandard, accounts::AccountManagerExt, env::Env, - intents::ExecuteIntentsExt, +use crate::{ + tests::defuse::{ + DefuseSigner, SigningStandard, accounts::AccountManagerExt, env::Env, + intents::ExecuteIntentsExt, state::SaltManagerExt, + }, + utils::acl::AclExt, }; +fn create_random_salted_nonce(salt: Salt, deadline: Deadline, mut rng: impl Rng) -> Nonce { + VersionedNonce::V1(SaltedNonce::new( + salt, + ExpirableNonce { + deadline, + nonce: rng.random::<[u8; 15]>(), + }, + )) + .into() +} + #[tokio::test] #[rstest] -async fn test_commit_nonces(#[notrace] mut rng: impl Rng) { - let env = Env::builder().build().await; +async fn test_commit_nonces(random_bytes: Vec, #[notrace] mut rng: impl Rng) { + let env = Env::builder().deployer_as_super_admin().build().await; let current_timestamp = Utc::now(); - let timeout_delta = TimeDelta::seconds(4); + let current_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); + let timeout_delta = TimeDelta::days(1); + let u = &mut Unstructured::new(&random_bytes); // legacy nonce - let deadline = Deadline::MAX; - let legacy_nonce = rng.random(); - - env.defuse - .execute_intents([env.user1.sign_defuse_message( - SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())).unwrap(), - env.defuse.id(), - legacy_nonce, - deadline, - DefuseIntents { intents: [].into() }, - )]) - .await - .unwrap(); + { + let deadline = Deadline::MAX; + let legacy_nonce = rng.random(); - assert!( env.defuse - .is_nonce_used(env.user1.id(), &legacy_nonce) + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + legacy_nonce, + deadline, + DefuseIntents { intents: [].into() }, + )]) .await - .unwrap(), - ); + .unwrap(); + + assert!( + env.defuse + .is_nonce_used(env.user1.id(), &legacy_nonce) + .await + .unwrap(), + ); + } - // nonce is expired - let deadline = Deadline::new(current_timestamp.checked_sub_signed(timeout_delta).unwrap()); - let expired_nonce = ExpirableNonce::new(deadline, rng.random::<[u8; 20]>()).into(); - - env.defuse - .execute_intents([env.user1.sign_defuse_message( - SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())).unwrap(), - env.defuse.id(), - expired_nonce, - deadline, - DefuseIntents { intents: [].into() }, - )]) - .await - .assert_err_contains("deadline has expired"); + // invalid salt + { + let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); + let random_salt = Salt::arbitrary(u).unwrap(); + let salted = create_random_salted_nonce(random_salt, deadline, &mut rng); + + env.defuse + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + salted, + deadline, + DefuseIntents { intents: [].into() }, + )]) + .await + .assert_err_contains("invalid salt"); + } // deadline is greater than nonce - let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); - let expired_nonce = ExpirableNonce::new(deadline, rng.random::<[u8; 20]>()).into(); - - env.defuse - .execute_intents([env.user1.sign_defuse_message( - SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())).unwrap(), - env.defuse.id(), - expired_nonce, - Deadline::MAX, - DefuseIntents { intents: [].into() }, - )]) - .await - .assert_err_contains("deadline is greater than nonce"); + { + let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); + let expired_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); + + env.defuse + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + expired_nonce, + Deadline::MAX, + DefuseIntents { intents: [].into() }, + )]) + .await + .assert_err_contains("deadline is greater than nonce"); + } + + // nonce is expired + { + let deadline = Deadline::new(current_timestamp.checked_sub_signed(timeout_delta).unwrap()); + let expired_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); + + env.defuse + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + expired_nonce, + deadline, + DefuseIntents { intents: [].into() }, + )]) + .await + .assert_err_contains("deadline has expired"); + } // nonce can be committed - let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); - let expirable_nonce = ExpirableNonce::new(deadline, rng.random::<[u8; 20]>()).into(); - - env.defuse - .execute_intents([env.user1.sign_defuse_message( - SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())).unwrap(), - env.defuse.id(), - expirable_nonce, - deadline, - DefuseIntents { intents: [].into() }, - )]) - .await - .unwrap(); + { + let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); + let expirable_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); + + env.defuse + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + expirable_nonce, + deadline, + DefuseIntents { intents: [].into() }, + )]) + .await + .unwrap(); + + assert!( + env.defuse + .is_nonce_used(env.user1.id(), &expirable_nonce) + .await + .unwrap(), + ); + } + + // nonce can be committed with previous salt + { + env.acl_grant_role(env.defuse.id(), Role::SaltManager, env.user1.id()) + .await + .expect("failed to grant role"); + + env.user1 + .update_current_salt(env.defuse.id()) + .await + .expect("unable to rotate salt"); + + let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); + let old_salt_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); - assert!( env.defuse - .is_nonce_used(env.user1.id(), &expirable_nonce) + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + old_salt_nonce, + deadline, + DefuseIntents { intents: [].into() }, + )]) .await - .unwrap(), - ); + .unwrap(); + + assert!( + env.defuse + .is_nonce_used(env.user1.id(), &old_salt_nonce) + .await + .unwrap(), + ); + } + + // nonce can't be committed with invalidated salt + { + let current_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); + env.user1 + .invalidate_salts(env.defuse.id(), &[current_salt]) + .await + .expect("unable to invalidate salt"); + + let deadline = Deadline::new(current_timestamp.checked_add_signed(timeout_delta).unwrap()); + let invalid_salt_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); + + env.defuse + .execute_intents([env.user1.sign_defuse_message( + SigningStandard::arbitrary(u).unwrap(), + env.defuse.id(), + invalid_salt_nonce, + deadline, + DefuseIntents { intents: [].into() }, + )]) + .await + .assert_err_contains("invalid salt"); + } } #[tokio::test] #[rstest] -async fn test_cleanup_expired_nonces(#[notrace] mut rng: impl Rng) { +async fn test_cleanup_nonces(#[notrace] mut rng: impl Rng) { const WAITING_TIME: TimeDelta = TimeDelta::seconds(3); - let env = Env::builder().build().await; + let env = Env::builder().deployer_as_super_admin().build().await; let current_timestamp = Utc::now(); + let current_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); - // commit expirable nonces let deadline = Deadline::new( current_timestamp .checked_add_signed(TimeDelta::seconds(1)) .unwrap(), ); - let expirable_nonce = ExpirableNonce::new(deadline, rng.random::<[u8; 20]>()).into(); let long_term_deadline = Deadline::new( current_timestamp .checked_add_signed(TimeDelta::hours(1)) .unwrap(), ); - let long_term_expirable_nonce = - ExpirableNonce::new(long_term_deadline, rng.random::<[u8; 20]>()).into(); - env.defuse - .execute_intents([ - env.user1.sign_defuse_message( - SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())) - .unwrap(), - env.defuse.id(), - expirable_nonce, - deadline, - DefuseIntents { intents: [].into() }, - ), - env.user1.sign_defuse_message( - SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())) - .unwrap(), - env.defuse.id(), - long_term_expirable_nonce, - long_term_deadline, - DefuseIntents { intents: [].into() }, - ), - ]) - .await - .unwrap(); + let legacy_nonce: Nonce = rng.random(); + let expirable_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); + let long_term_expirable_nonce = + create_random_salted_nonce(current_salt, long_term_deadline, &mut rng); - assert!( + // commit nonces + { env.defuse - .is_nonce_used(env.user1.id(), &expirable_nonce) + .execute_intents([ + env.user1.sign_defuse_message( + SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())) + .unwrap(), + env.defuse.id(), + legacy_nonce, + deadline, + DefuseIntents { intents: [].into() }, + ), + env.user1.sign_defuse_message( + SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())) + .unwrap(), + env.defuse.id(), + expirable_nonce, + deadline, + DefuseIntents { intents: [].into() }, + ), + env.user1.sign_defuse_message( + SigningStandard::arbitrary(&mut Unstructured::new(&rng.random::<[u8; 1]>())) + .unwrap(), + env.defuse.id(), + long_term_expirable_nonce, + long_term_deadline, + DefuseIntents { intents: [].into() }, + ), + ]) .await - .unwrap(), - ); + .unwrap(); + } sleep(Duration::from_secs_f64(WAITING_TIME.as_seconds_f64())).await; + // only DAO or garbage collector can cleanup nonces + { + env.user1 + .cleanup_nonces( + env.defuse.id(), + &[(env.user1.id().clone(), vec![expirable_nonce])], + ) + .await + .assert_err_contains("Insufficient permissions for method"); + } + // nonce is expired - env.defuse - .cleanup_expired_nonces(&[(env.user1.id().clone(), vec![expirable_nonce])]) - .await - .unwrap(); + { + env.acl_grant_role(env.defuse.id(), Role::GarbageCollector, env.user1.id()) + .await + .expect("failed to grant role"); - assert!( - !env.defuse - .is_nonce_used(env.user1.id(), &expirable_nonce) + env.user1 + .cleanup_nonces( + env.defuse.id(), + &[(env.user1.id().clone(), vec![expirable_nonce])], + ) .await - .unwrap(), - ); + .unwrap(); + + assert!( + !env.defuse + .is_nonce_used(env.user1.id(), &expirable_nonce) + .await + .unwrap(), + ); + } - let unknown_user: AccountId = "unknown-user.near".parse().unwrap(); + // skip if nonce is legacy / already cleared / is not expired / user does not exist + { + let unknown_user: AccountId = "unknown-user.near".parse().unwrap(); - // skip if nonce already cleared / is not expired / user does not exist - env.defuse - .cleanup_expired_nonces(&[ - (env.user1.id().clone(), vec![expirable_nonce]), - (env.user1.id().clone(), vec![long_term_expirable_nonce]), - (unknown_user, vec![expirable_nonce]), - ]) - .await - .unwrap(); + env.user1 + .cleanup_nonces( + env.defuse.id(), + &[ + (env.user1.id().clone(), vec![expirable_nonce]), + (env.user1.id().clone(), vec![legacy_nonce]), + (env.user1.id().clone(), vec![long_term_expirable_nonce]), + (unknown_user, vec![expirable_nonce]), + ], + ) + .await + .unwrap(); + + assert!( + env.defuse + .is_nonce_used(env.user1.id(), &legacy_nonce) + .await + .unwrap(), + ); + + assert!( + env.defuse + .is_nonce_used(env.user1.id(), &long_term_expirable_nonce) + .await + .unwrap(), + ); + } + + // clean invalid salt + { + env.acl_grant_role(env.defuse.id(), Role::SaltManager, env.user1.id()) + .await + .expect("failed to grant role"); + + env.user1 + .invalidate_salts(env.defuse.id(), &[current_salt]) + .await + .expect("unable to rotate salt"); + + env.user1 + .cleanup_nonces( + env.defuse.id(), + &[(env.user1.id().clone(), vec![long_term_expirable_nonce])], + ) + .await + .unwrap(); + + assert!( + !env.defuse + .is_nonce_used(env.user1.id(), &long_term_expirable_nonce) + .await + .unwrap(), + ); + } } #[tokio::test] @@ -190,8 +366,13 @@ async fn cleanup_multiple_nonces( const CHUNK_SIZE: usize = 10; const WAITING_TIME: TimeDelta = TimeDelta::seconds(3); - let env = Env::builder().build().await; + let env = Env::builder().deployer_as_super_admin().build().await; let mut nonces = Vec::with_capacity(nonce_count); + let current_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); + + env.acl_grant_role(env.defuse.id(), Role::GarbageCollector, env.user1.id()) + .await + .expect("failed to grant role"); for chunk in &(0..nonce_count).chunks(CHUNK_SIZE) { let current_timestamp = Utc::now(); @@ -201,8 +382,7 @@ async fn cleanup_multiple_nonces( // commit expirable nonce let deadline = Deadline::new(current_timestamp.checked_add_signed(WAITING_TIME).unwrap()); - let expirable_nonce = - ExpirableNonce::new(deadline, rng.random::<[u8; 20]>()).into(); + let expirable_nonce = create_random_salted_nonce(current_salt, deadline, &mut rng); nonces.push(expirable_nonce); @@ -222,8 +402,8 @@ async fn cleanup_multiple_nonces( sleep(Duration::from_secs_f64(WAITING_TIME.as_seconds_f64())).await; let gas_used = env - .defuse - .cleanup_expired_nonces(&[(env.user1.id().clone(), nonces)]) + .user1 + .cleanup_nonces(env.defuse.id(), &[(env.user1.id().clone(), nonces)]) .await .unwrap(); diff --git a/tests/src/tests/defuse/mod.rs b/tests/src/tests/defuse/mod.rs index dcb18766..2fcafc24 100644 --- a/tests/src/tests/defuse/mod.rs +++ b/tests/src/tests/defuse/mod.rs @@ -1,6 +1,7 @@ pub mod accounts; mod env; mod intents; +mod state; mod storage; mod tokens; mod upgrade; diff --git a/tests/src/tests/defuse/state/extensions/fee.rs b/tests/src/tests/defuse/state/extensions/fee.rs new file mode 100644 index 00000000..66d6a5a3 --- /dev/null +++ b/tests/src/tests/defuse/state/extensions/fee.rs @@ -0,0 +1,85 @@ +use defuse::core::fees::Pips; +use near_sdk::{AccountId, NearToken}; +use serde_json::json; + +pub trait FeesManagerExt { + async fn set_fee(&self, defuse_contract_id: &AccountId, fee: Pips) -> anyhow::Result<()>; + + async fn fee(&self, defuse_contract_id: &AccountId) -> anyhow::Result; + async fn set_fee_collector( + &self, + defuse_contract_id: &AccountId, + fee_collector: &AccountId, + ) -> anyhow::Result<()>; + async fn fee_collector(&self, defuse_contract_id: &AccountId) -> anyhow::Result; +} + +impl FeesManagerExt for near_workspaces::Account { + async fn set_fee(&self, defuse_contract_id: &AccountId, fee: Pips) -> anyhow::Result<()> { + self.call(defuse_contract_id, "set_fee") + .deposit(NearToken::from_yoctonear(1)) + .args_json(json!({ + "fee": fee, + })) + .max_gas() + .transact() + .await? + .into_result()?; + Ok(()) + } + + async fn fee(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.view(defuse_contract_id, "fee") + .await? + .json() + .map_err(Into::into) + } + + async fn set_fee_collector( + &self, + defuse_contract_id: &AccountId, + fee_collector: &AccountId, + ) -> anyhow::Result<()> { + self.call(defuse_contract_id, "set_fee_collector") + .deposit(NearToken::from_yoctonear(1)) + .args_json(json!({ + "fee_collector": fee_collector, + })) + .max_gas() + .transact() + .await? + .into_result()?; + + Ok(()) + } + async fn fee_collector(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.view(defuse_contract_id, "fee_collector") + .await? + .json() + .map_err(Into::into) + } +} + +impl FeesManagerExt for near_workspaces::Contract { + async fn set_fee(&self, defuse_contract_id: &AccountId, fee: Pips) -> anyhow::Result<()> { + self.as_account().set_fee(defuse_contract_id, fee).await + } + + async fn fee(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.as_account().fee(defuse_contract_id).await + } + + async fn set_fee_collector( + &self, + defuse_contract_id: &AccountId, + fee_collector: &AccountId, + ) -> anyhow::Result<()> { + self.as_account() + .set_fee_collector(defuse_contract_id, fee_collector) + .await + } + + async fn fee_collector(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.as_account().fee_collector(defuse_contract_id).await + } +} diff --git a/tests/src/tests/defuse/state/extensions/mod.rs b/tests/src/tests/defuse/state/extensions/mod.rs new file mode 100644 index 00000000..8527b4cb --- /dev/null +++ b/tests/src/tests/defuse/state/extensions/mod.rs @@ -0,0 +1,2 @@ +pub mod fee; +pub mod salt; diff --git a/tests/src/tests/defuse/state/extensions/salt.rs b/tests/src/tests/defuse/state/extensions/salt.rs new file mode 100644 index 00000000..97d74cb1 --- /dev/null +++ b/tests/src/tests/defuse/state/extensions/salt.rs @@ -0,0 +1,101 @@ +use defuse::core::Salt; +use near_sdk::{AccountId, NearToken}; +use serde_json::json; + +pub trait SaltManagerExt { + async fn update_current_salt(&self, defuse_contract_id: &AccountId) -> anyhow::Result; + + async fn invalidate_salts( + &self, + defuse_contract_id: &AccountId, + salts: &[Salt], + ) -> anyhow::Result; + + async fn is_valid_salt( + &self, + defuse_contract_id: &AccountId, + salt: &Salt, + ) -> anyhow::Result; + + async fn current_salt(&self, defuse_contract_id: &AccountId) -> anyhow::Result; +} + +impl SaltManagerExt for near_workspaces::Account { + async fn update_current_salt(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.call(defuse_contract_id, "update_current_salt") + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await? + .into_result()? + .json() + .map_err(Into::into) + } + + async fn invalidate_salts( + &self, + defuse_contract_id: &AccountId, + salts: &[Salt], + ) -> anyhow::Result { + self.call(defuse_contract_id, "invalidate_salts") + .args_json(json!({ "salts": salts })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await? + .into_result()? + .json() + .map_err(Into::into) + } + + async fn is_valid_salt( + &self, + defuse_contract_id: &AccountId, + salt: &Salt, + ) -> anyhow::Result { + self.view(defuse_contract_id, "is_valid_salt") + .args_json(json!({ "salt": salt })) + .await? + .json() + .map_err(Into::into) + } + + async fn current_salt(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.view(defuse_contract_id, "current_salt") + .await? + .json() + .map_err(Into::into) + } +} + +impl SaltManagerExt for near_workspaces::Contract { + async fn update_current_salt(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.as_account() + .update_current_salt(defuse_contract_id) + .await + } + + async fn invalidate_salts( + &self, + defuse_contract_id: &AccountId, + salts: &[Salt], + ) -> anyhow::Result { + self.as_account() + .invalidate_salts(defuse_contract_id, salts) + .await + } + + async fn is_valid_salt( + &self, + defuse_contract_id: &AccountId, + salt: &Salt, + ) -> anyhow::Result { + self.as_account() + .is_valid_salt(defuse_contract_id, salt) + .await + } + + async fn current_salt(&self, defuse_contract_id: &AccountId) -> anyhow::Result { + self.as_account().current_salt(defuse_contract_id).await + } +} diff --git a/tests/src/tests/defuse/state/fee.rs b/tests/src/tests/defuse/state/fee.rs new file mode 100644 index 00000000..ec9c4468 --- /dev/null +++ b/tests/src/tests/defuse/state/fee.rs @@ -0,0 +1,74 @@ +use defuse::{contract::Role, core::fees::Pips}; + +use defuse_test_utils::asserts::ResultAssertsExt; +use near_sdk::AccountId; +use rstest::rstest; + +use crate::{ + tests::defuse::{env::Env, state::FeesManagerExt}, + utils::acl::AclExt, +}; + +#[tokio::test] +#[rstest] +async fn set_fee() { + let env = Env::builder().deployer_as_super_admin().build().await; + let prev_fee = env.defuse.fee(env.defuse.id()).await.unwrap(); + let fee = Pips::from_pips(100).unwrap(); + + // only DAO or fee manager can set fee + { + env.user2 + .set_fee(env.defuse.id(), fee) + .await + .assert_err_contains("Insufficient permissions for method"); + } + + // set fee by fee manager + { + env.acl_grant_role(env.defuse.id(), Role::FeesManager, env.user1.id()) + .await + .expect("failed to grant role"); + + env.user1 + .set_fee(env.defuse.id(), fee) + .await + .expect("unable to set fee"); + + let current_fee = env.defuse.fee(env.defuse.id()).await.unwrap(); + + assert_ne!(prev_fee, current_fee); + assert_eq!(current_fee, fee); + } +} + +#[tokio::test] +#[rstest] +async fn set_fee_collector() { + let env = Env::builder().deployer_as_super_admin().build().await; + let fee_collector: AccountId = "fee-collector.near".to_string().parse().unwrap(); + + // only DAO or fee manager can set fee collector + { + env.user2 + .set_fee_collector(env.defuse.id(), &fee_collector) + .await + .assert_err_contains("Insufficient permissions for method"); + } + + // set fee by fee manager + { + env.acl_grant_role(env.defuse.id(), Role::FeesManager, env.user1.id()) + .await + .expect("failed to grant role"); + + env.user1 + .set_fee_collector(env.defuse.id(), &fee_collector) + .await + .expect("unable to set fee"); + + let current_collector = env.defuse.fee_collector(env.defuse.id()).await.unwrap(); + + assert_eq!(current_collector, fee_collector); + } +} diff --git a/tests/src/tests/defuse/state/mod.rs b/tests/src/tests/defuse/state/mod.rs new file mode 100644 index 00000000..94c631e8 --- /dev/null +++ b/tests/src/tests/defuse/state/mod.rs @@ -0,0 +1,5 @@ +mod extensions; +mod fee; +mod salt; + +pub use extensions::{fee::FeesManagerExt, salt::SaltManagerExt}; diff --git a/tests/src/tests/defuse/state/salt.rs b/tests/src/tests/defuse/state/salt.rs new file mode 100644 index 00000000..878a4c02 --- /dev/null +++ b/tests/src/tests/defuse/state/salt.rs @@ -0,0 +1,107 @@ +use defuse::contract::Role; + +use defuse_test_utils::asserts::ResultAssertsExt; +use rstest::rstest; + +use crate::{ + tests::defuse::{env::Env, state::SaltManagerExt}, + utils::acl::AclExt, +}; + +#[tokio::test] +#[rstest] +async fn update_current_salt() { + let env = Env::builder().deployer_as_super_admin().build().await; + let prev_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); + + // only DAO or salt manager can rotate salt + { + env.user2 + .update_current_salt(env.defuse.id()) + .await + .assert_err_contains("Insufficient permissions for method"); + } + + // rotate salt by salt manager + { + env.acl_grant_role(env.defuse.id(), Role::SaltManager, env.user1.id()) + .await + .expect("failed to grant role"); + + let new_salt = env + .user1 + .update_current_salt(env.defuse.id()) + .await + .expect("unable to rotate salt"); + + let current_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); + + assert_ne!(prev_salt, current_salt); + assert_eq!(new_salt, current_salt); + assert!( + env.defuse + .is_valid_salt(env.defuse.id(), &prev_salt) + .await + .unwrap() + ); + } +} + +#[tokio::test] +#[rstest] +async fn invalidate_salts() { + let env = Env::builder().deployer_as_super_admin().build().await; + let mut current_salt = env.defuse.current_salt(env.defuse.id()).await.unwrap(); + let mut prev_salt = current_salt; + + // only DAO or salt manager can invalidate salt + { + env.user2 + .invalidate_salts(env.defuse.id(), &[prev_salt]) + .await + .assert_err_contains("Insufficient permissions for method"); + } + + // invalidate prev salt by salt manager + { + env.acl_grant_role(env.defuse.id(), Role::SaltManager, env.user1.id()) + .await + .expect("failed to grant role"); + + current_salt = env + .user1 + .update_current_salt(env.defuse.id()) + .await + .expect("unable to rotate salt"); + + env.user1 + .invalidate_salts(env.defuse.id(), &[prev_salt]) + .await + .expect("unable to rotate salt"); + + assert!( + !env.defuse + .is_valid_salt(env.defuse.id(), &prev_salt) + .await + .unwrap() + ); + } + + // invalidate current salt by salt manager + { + prev_salt = current_salt; + current_salt = env + .user1 + .invalidate_salts(env.defuse.id(), &[current_salt]) + .await + .expect("unable to rotate salt"); + + assert!( + !env.defuse + .is_valid_salt(env.defuse.id(), &prev_salt) + .await + .unwrap() + ); + assert_ne!(prev_salt, current_salt); + } +}