diff --git a/Cargo.lock b/Cargo.lock index 9ad28c7c..4bc57a61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -575,18 +575,24 @@ dependencies = [ "bnum", "defuse-admin-utils", "defuse-bitmap", + "defuse-borsh-utils", "defuse-controller", "defuse-core", + "defuse-io-utils", "defuse-map-utils", "defuse-near-utils", "defuse-nep245", "defuse-serde-utils", + "defuse-test-utils", "defuse-wnear", + "derive_more 2.0.1", + "hex", "impl-tools", "near-account-id", "near-contract-standards", "near-plugins", "near-sdk", + "rstest", "serde_with", "strum 0.27.1", "thiserror 2.0.12", @@ -612,6 +618,8 @@ dependencies = [ name = "defuse-borsh-utils" version = "0.1.0" dependencies = [ + "defuse-io-utils", + "impl-tools", "near-sdk", ] @@ -631,6 +639,7 @@ dependencies = [ "defuse-crypto", "defuse-erc191", "defuse-map-utils", + "defuse-near-utils", "defuse-nep245", "defuse-nep413", "defuse-num-utils", @@ -672,6 +681,10 @@ dependencies = [ "serde_with", ] +[[package]] +name = "defuse-io-utils" +version = "0.1.0" + [[package]] name = "defuse-map-utils" version = "0.1.0" @@ -684,6 +697,8 @@ dependencies = [ name = "defuse-near-utils" version = "0.1.0" dependencies = [ + "defuse-borsh-utils", + "impl-tools", "near-sdk", ] @@ -742,6 +757,13 @@ dependencies = [ "near-sdk", ] +[[package]] +name = "defuse-randomness" +version = "0.1.0" +dependencies = [ + "rand 0.9.1", +] + [[package]] name = "defuse-serde-utils" version = "0.1.0" @@ -751,6 +773,15 @@ dependencies = [ "serde_with", ] +[[package]] +name = "defuse-test-utils" +version = "0.1.0" +dependencies = [ + "defuse-randomness", + "rand_chacha 0.9.0", + "rstest", +] + [[package]] name = "defuse-tests" version = "0.1.0" @@ -759,16 +790,16 @@ dependencies = [ "bnum", "defuse", "defuse-poa-factory", + "defuse-randomness", + "defuse-test-utils", "hex-literal", "impl-tools", "near-contract-standards", "near-crypto", "near-sdk", "near-workspaces", - "randomness", "rstest", "serde_json", - "test-utils", "tokio", ] @@ -2794,13 +2825,6 @@ dependencies = [ "getrandom 0.3.2", ] -[[package]] -name = "randomness" -version = "0.1.0" -dependencies = [ - "rand 0.9.1", -] - [[package]] name = "rayon" version = "1.10.0" @@ -3584,15 +3608,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "test-utils" -version = "0.1.0" -dependencies = [ - "rand_chacha 0.9.0", - "randomness", - "rstest", -] - [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 9c251f96..eae7d628 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crypto", "defuse", "erc191", + "io-utils", "map-utils", "near-utils", "nep245", @@ -40,6 +41,7 @@ defuse-core.path = "core" defuse-crypto.path = "crypto" defuse.path = "defuse" defuse-erc191.path = "erc191" +defuse-io-utils.path = "io-utils" defuse-map-utils.path = "map-utils" defuse-near-utils.path = "near-utils" defuse-nep245.path = "nep245" @@ -52,8 +54,8 @@ defuse-poa-token.path = "poa-token" defuse-serde-utils.path = "serde-utils" defuse-wnear.path = "wnear" -randomness.path = "randomness" -test-utils.path = "test-utils" +defuse-randomness.path = "randomness" +defuse-test-utils.path = "test-utils" anyhow = "1" bnum = { version = "0.13", features = ["borsh"] } diff --git a/borsh-utils/Cargo.toml b/borsh-utils/Cargo.toml index acc7a410..88849f99 100644 --- a/borsh-utils/Cargo.toml +++ b/borsh-utils/Cargo.toml @@ -4,5 +4,11 @@ edition.workspace = true version.workspace = true repository.workspace = true +[lints] +workspace = true + [dependencies] +defuse-io-utils.workspace = true + near-sdk.workspace = true +impl-tools.workspace = true diff --git a/borsh-utils/src/as.rs b/borsh-utils/src/as.rs new file mode 100644 index 00000000..24139fba --- /dev/null +++ b/borsh-utils/src/as.rs @@ -0,0 +1,458 @@ +//! Analog of [serde_with](https://docs.rs/serde_with) for [borsh](https://docs.rs/borsh) + +use std::{ + fmt::{self}, + io::{self, Read}, + marker::PhantomData, + mem::MaybeUninit, + rc::Rc, + sync::Arc, +}; + +use defuse_io_utils::ReadExt; +use impl_tools::autoimpl; +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; + +pub trait BorshSerializeAs { + fn serialize_as(source: &T, writer: &mut W) -> io::Result<()> + where + W: io::Write; +} + +pub trait BorshDeserializeAs { + fn deserialize_as(reader: &mut R) -> io::Result + where + R: io::Read; +} + +pub struct As(PhantomData); + +impl As { + #[inline] + pub fn serialize(obj: &U, writer: &mut W) -> io::Result<()> + where + T: BorshSerializeAs, + W: io::Write, + U: ?Sized, + { + T::serialize_as(obj, writer) + } + + #[inline] + pub fn deserialize(reader: &mut R) -> io::Result + where + T: BorshDeserializeAs, + R: io::Read, + { + T::deserialize_as(reader) + } +} + +pub struct Same; + +impl BorshSerializeAs for Same +where + T: BorshSerialize, +{ + #[inline] + fn serialize_as(source: &T, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + source.serialize(writer) + } +} + +impl BorshDeserializeAs for Same +where + T: BorshDeserialize, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result + where + R: io::Read, + { + T::deserialize_reader(reader) + } +} + +#[autoimpl(Deref using self.value)] +#[autoimpl(DerefMut using self.value)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AsWrap { + value: T, + _marker: PhantomData, +} + +impl AsWrap { + #[must_use] + #[inline] + pub const fn new(value: T) -> Self { + Self { + value, + _marker: PhantomData, + } + } + + /// Return the inner value of type `T`. + #[inline] + pub fn into_inner(self) -> T { + self.value + } +} + +impl From for AsWrap { + #[inline] + fn from(value: T) -> Self { + Self::new(value) + } +} + +impl BorshDeserialize for AsWrap +where + As: BorshDeserializeAs + ?Sized, +{ + #[inline] + fn deserialize_reader(reader: &mut R) -> io::Result { + As::deserialize_as(reader).map(Self::new) + } +} + +impl BorshSerialize for AsWrap +where + As: BorshSerializeAs + ?Sized, +{ + #[inline] + fn serialize(&self, writer: &mut W) -> io::Result<()> { + As::serialize_as(&self.value, writer) + } +} + +impl fmt::Debug for AsWrap +where + T: fmt::Debug, + As: ?Sized, +{ + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.value, f) + } +} + +impl fmt::Display for AsWrap +where + T: fmt::Display, + As: ?Sized, +{ + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.value, f) + } +} + +impl BorshSerializeAs<&T> for &As +where + T: ?Sized, + As: BorshSerializeAs + ?Sized, +{ + #[inline] + fn serialize_as(source: &&T, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + As::serialize_as(source, writer) + } +} + +impl BorshSerializeAs<&mut T> for &mut As +where + T: ?Sized, + As: BorshSerializeAs + ?Sized, +{ + #[inline] + fn serialize_as(source: &&mut T, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + As::serialize_as(source, writer) + } +} + +impl BorshSerializeAs> for Option +where + As: BorshSerializeAs, +{ + #[inline] + fn serialize_as(source: &Option, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + source + .as_ref() + .map(AsWrap::<&T, &As>::new) + .serialize(writer) + } +} + +impl BorshDeserializeAs> for Option +where + As: BorshDeserializeAs, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result> + where + R: io::Read, + { + Ok(Option::>::deserialize_reader(reader)?.map(AsWrap::into_inner)) + } +} + +impl BorshSerializeAs> for Box +where + As: BorshSerializeAs + ?Sized, +{ + #[inline] + fn serialize_as(source: &Box, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + AsWrap::<&T, &As>::new(source).serialize(writer) + } +} + +impl BorshDeserializeAs> for Box +where + As: BorshDeserializeAs + ?Sized, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result> + where + R: io::Read, + { + AsWrap::::deserialize_reader(reader) + .map(AsWrap::into_inner) + .map(Box::new) + } +} + +impl BorshSerializeAs> for Rc +where + As: BorshSerializeAs + ?Sized, +{ + #[inline] + fn serialize_as(source: &Rc, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + AsWrap::<&T, &As>::new(source).serialize(writer) + } +} + +impl BorshDeserializeAs> for Rc +where + As: BorshDeserializeAs + ?Sized, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result> + where + R: io::Read, + { + AsWrap::::deserialize_reader(reader) + .map(AsWrap::into_inner) + .map(Rc::new) + } +} + +impl BorshSerializeAs> for Arc +where + As: BorshSerializeAs + ?Sized, +{ + #[inline] + fn serialize_as(source: &Arc, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + AsWrap::<&T, &As>::new(source).serialize(writer) + } +} + +impl BorshDeserializeAs> for Arc +where + As: BorshDeserializeAs + ?Sized, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result> + where + R: io::Read, + { + AsWrap::::deserialize_reader(reader) + .map(AsWrap::into_inner) + .map(Arc::new) + } +} + +impl BorshSerializeAs<[T]> for [As] +where + As: BorshSerializeAs, +{ + #[inline] + fn serialize_as(source: &[T], writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + source.iter().try_for_each(|v| As::serialize_as(v, writer)) + } +} + +impl BorshSerializeAs<[T; N]> for [As; N] +where + As: BorshSerializeAs, +{ + #[inline] + fn serialize_as(source: &[T; N], writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + <&[As]>::serialize_as(&source.as_slice(), writer) + } +} + +impl BorshDeserializeAs<[T; N]> for [As; N] +where + As: BorshDeserializeAs, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result<[T; N]> + where + R: io::Read, + { + let mut arr: [MaybeUninit; N] = unsafe { MaybeUninit::uninit().assume_init() }; + for a in &mut arr { + a.write(As::deserialize_as(reader)?); + } + Ok(unsafe { arr.as_ptr().cast::<[T; N]>().read() }) + } +} + +macro_rules! impl_borsh_serde_as_for_tuple { + ($($n:tt:$t:ident as $a:ident),+) => { + impl<$($t, $a),+> BorshSerializeAs<($($t,)+)> for ($($a,)+) + where $( + $a: BorshSerializeAs<$t>, + )+ + { + #[inline] + fn serialize_as(source: &($($t,)+), writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + $( + $a::serialize_as(&source.$n, writer)?; + )+ + Ok(()) + } + } + + impl<$($t, $a),+> BorshDeserializeAs<($($t,)+)> for ($($a,)+) + where $( + $a: BorshDeserializeAs<$t>, + )+ + { + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result<($($t,)+)> + where + R: io::Read, + { + Ok(($( + $a::deserialize_as(reader)?, + )+)) + } + } + }; +} +impl_borsh_serde_as_for_tuple!(0:T0 as As0); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3,4:T4 as As4); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3,4:T4 as As4,5:T5 as As5); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3,4:T4 as As4,5:T5 as As5,6:T6 as As6); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3,4:T4 as As4,5:T5 as As5,6:T6 as As6,7:T7 as As7); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3,4:T4 as As4,5:T5 as As5,6:T6 as As6,7:T7 as As7,8:T8 as As8); +impl_borsh_serde_as_for_tuple!(0:T0 as As0,1:T1 as As1,2:T2 as As2,3:T3 as As3,4:T4 as As4,5:T5 as As5,6:T6 as As6,7:T7 as As7,8:T8 as As8,9:T9 as As9); + +pub struct FromInto(PhantomData); + +impl BorshSerializeAs for FromInto +where + T: Into + Clone, + U: BorshSerialize, +{ + #[inline] + fn serialize_as(source: &T, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + source.clone().into().serialize(writer) + } +} + +impl BorshDeserializeAs for FromInto +where + U: BorshDeserialize + Into, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result + where + R: io::Read, + { + U::deserialize_reader(reader).map(Into::into) + } +} + +pub struct FromIntoRef(PhantomData); + +impl BorshSerializeAs for FromIntoRef +where + for<'a> &'a T: Into, + U: BorshSerialize, +{ + #[inline] + fn serialize_as(source: &T, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + source.into().serialize(writer) + } +} + +impl BorshDeserializeAs for FromIntoRef +where + U: BorshDeserialize + Into, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result + where + R: io::Read, + { + U::deserialize_reader(reader).map(Into::into) + } +} + +pub struct Or(PhantomData, PhantomData); + +impl BorshDeserializeAs for Or +where + As1: BorshDeserializeAs + ?Sized, + As2: BorshDeserializeAs + ?Sized, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result + where + R: io::Read, + { + let mut buf = Vec::new(); + As1::deserialize_as(&mut reader.tee(&mut buf)) + .or_else(|_| As2::deserialize_as(&mut buf.chain(reader))) + } +} diff --git a/borsh-utils/src/lib.rs b/borsh-utils/src/lib.rs index b479ddd6..36fc744a 100644 --- a/borsh-utils/src/lib.rs +++ b/borsh-utils/src/lib.rs @@ -1,2 +1,3 @@ +pub mod r#as; pub mod base64; pub mod string; diff --git a/core/Cargo.toml b/core/Cargo.toml index 0bc34dfa..377be4c3 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -8,9 +8,10 @@ repository.workspace = true defuse-bitmap.workspace = true defuse-crypto = { workspace = true, features = ["serde"] } defuse-erc191.workspace = true +defuse-map-utils.workspace = true +defuse-near-utils.workspace = true defuse-nep245.workspace = true defuse-nep413.workspace = true -defuse-map-utils.workspace = true defuse-num-utils.workspace = true defuse-serde-utils.workspace = true defuse-webauthn.workspace = true diff --git a/core/src/engine/mod.rs b/core/src/engine/mod.rs index f14d0bed..58868d3d 100644 --- a/core/src/engine/mod.rs +++ b/core/src/engine/mod.rs @@ -70,13 +70,11 @@ where // make sure the account has this public key if !self.state.has_public_key(&signer_id, &public_key) { - return Err(DefuseError::PublicKeyNotExist); + return Err(DefuseError::PublicKeyNotExist(signer_id, public_key)); } // commit nonce - if !self.state.commit_nonce(signer_id.clone(), nonce) { - return Err(DefuseError::NonceUsed); - } + self.state.commit_nonce(signer_id.clone(), nonce)?; intents.execute_intent(&signer_id, self, hash)?; self.inspector.on_intent_executed(&signer_id, hash); diff --git a/core/src/engine/state/cached.rs b/core/src/engine/state/cached.rs index f4631d75..bf14b1d9 100644 --- a/core/src/engine/state/cached.rs +++ b/core/src/engine/state/cached.rs @@ -5,6 +5,7 @@ use std::{ use defuse_bitmap::{U248, U256}; use defuse_crypto::PublicKey; +use defuse_near_utils::Lock; use near_sdk::{AccountId, AccountIdRef}; use crate::{ @@ -61,7 +62,7 @@ where } fn has_public_key(&self, account_id: &AccountIdRef, public_key: &PublicKey) -> bool { - if let Some(account) = self.accounts.get(account_id) { + if let Some(account) = self.accounts.get(account_id).map(Lock::as_inner_unchecked) { if account.public_keys_added.contains(public_key) { return true; } @@ -73,7 +74,7 @@ where } fn iter_public_keys(&self, account_id: &AccountIdRef) -> impl Iterator + '_ { - let account = self.accounts.get(account_id); + let account = self.accounts.get(account_id).map(Lock::as_inner_unchecked); self.view .iter_public_keys(account_id) .filter(move |pk| account.is_none_or(|a| !a.public_keys_removed.contains(pk))) @@ -89,6 +90,7 @@ where fn is_nonce_used(&self, account_id: &AccountIdRef, nonce: Nonce) -> bool { self.accounts .get(account_id) + .map(Lock::as_inner_unchecked) .is_some_and(|account| account.is_nonce_used(nonce)) || self.view.is_nonce_used(account_id, nonce) } @@ -96,43 +98,76 @@ where fn balance_of(&self, account_id: &AccountIdRef, token_id: &TokenId) -> u128 { self.accounts .get(account_id) + .map(Lock::as_inner_unchecked) .and_then(|account| account.token_amounts.get(token_id).copied()) .unwrap_or_else(|| self.view.balance_of(account_id, token_id)) } + + fn is_account_locked(&self, account_id: &AccountIdRef) -> bool { + self.accounts + .get(account_id) + .map_or_else(|| self.view.is_account_locked(account_id), Lock::is_locked) + } } impl State for CachedState where W: StateView, { - #[must_use] - fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool { - let had = self.has_public_key(&account_id, &public_key); - let account = self.accounts.get_or_create(account_id.clone()); - if had { + fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()> { + let had = self.view.has_public_key(&account_id, &public_key); + let account = self + .accounts + .get_or_create(account_id.clone(), |account_id| { + self.view.is_account_locked(account_id) + }) + .as_unlocked_mut() + .ok_or_else(|| DefuseError::AccountLocked(account_id.clone()))?; + let added = if had { account.public_keys_removed.remove(&public_key) } else { account.public_keys_added.insert(public_key) + }; + if !added { + return Err(DefuseError::PublicKeyExists(account_id, public_key)); } + Ok(()) } - #[must_use] - fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool { - let had = self.has_public_key(&account_id, &public_key); - let account = self.accounts.get_or_create(account_id.clone()); - if had { + fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()> { + let had = self.view.has_public_key(&account_id, &public_key); + let account = self + .accounts + .get_or_create(account_id.clone(), |account_id| { + self.view.is_account_locked(account_id) + }) + .as_unlocked_mut() + .ok_or_else(|| DefuseError::AccountLocked(account_id.clone()))?; + let removed = if had { account.public_keys_removed.insert(public_key) } else { account.public_keys_added.remove(&public_key) + }; + if !removed { + return Err(DefuseError::PublicKeyNotExist(account_id, public_key)); } + Ok(()) } - #[must_use] - fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> bool { + fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> Result<()> { if self.is_nonce_used(&account_id, nonce) { - return false; + return Err(DefuseError::NonceUsed); } - self.accounts.get_or_create(account_id).commit_nonce(nonce) + + self.accounts + .get_or_create(account_id.clone(), |account_id| { + self.view.is_account_locked(account_id) + }) + .as_unlocked_mut() + .ok_or(DefuseError::AccountLocked(account_id))? + .commit_nonce(nonce) + .then_some(()) + .ok_or(DefuseError::NonceUsed) } fn internal_add_balance( @@ -140,7 +175,12 @@ where owner_id: AccountId, token_amounts: impl IntoIterator, ) -> Result<()> { - let account = self.accounts.get_or_create(owner_id.clone()); + let account = self + .accounts + .get_or_create(owner_id.clone(), |owner_id| { + self.view.is_account_locked(owner_id) + }) + .as_inner_unchecked_mut(); for (token_id, amount) in token_amounts { if account.token_amounts.get(&token_id).is_none() { account @@ -163,8 +203,11 @@ where ) -> Result<()> { let account = self .accounts - .get_mut(owner_id) - .ok_or(DefuseError::AccountNotFound)?; + .get_or_create(owner_id.to_owned(), |owner_id| { + self.view.is_account_locked(owner_id) + }) + .as_unlocked_mut() + .ok_or_else(|| DefuseError::AccountLocked(owner_id.to_owned()))?; for (token_id, amount) in token_amounts { if amount == 0 { return Err(DefuseError::InvalidIntent); @@ -260,7 +303,7 @@ where } #[derive(Debug, Default, Clone)] -pub struct CachedAccounts(HashMap); +pub struct CachedAccounts(HashMap>); impl CachedAccounts { #[must_use] @@ -270,18 +313,24 @@ impl CachedAccounts { } #[inline] - pub fn get(&self, account_id: &AccountIdRef) -> Option<&CachedAccount> { + pub fn get(&self, account_id: &AccountIdRef) -> Option<&Lock> { self.0.get(account_id) } #[inline] - pub fn get_mut(&mut self, account_id: &AccountIdRef) -> Option<&mut CachedAccount> { + pub fn get_mut(&mut self, account_id: &AccountIdRef) -> Option<&mut Lock> { self.0.get_mut(account_id) } #[inline] - pub fn get_or_create(&mut self, account_id: AccountId) -> &mut CachedAccount { - self.0.entry(account_id).or_default() + pub fn get_or_create( + &mut self, + account_id: AccountId, + default_locked: impl FnOnce(&AccountId) -> bool, + ) -> &mut Lock { + self.0.entry(account_id).or_insert_with_key(|account_id| { + Lock::new(CachedAccount::default(), default_locked(account_id)) + }) } } diff --git a/core/src/engine/state/deltas.rs b/core/src/engine/state/deltas.rs index 854f8821..ca807142 100644 --- a/core/src/engine/state/deltas.rs +++ b/core/src/engine/state/deltas.rs @@ -86,27 +86,29 @@ where fn balance_of(&self, account_id: &AccountIdRef, token_id: &TokenId) -> u128 { self.state.balance_of(account_id, token_id) } + + #[inline] + fn is_account_locked(&self, account_id: &AccountIdRef) -> bool { + self.state.is_account_locked(account_id) + } } impl State for Deltas where S: State, { - #[must_use] #[inline] - fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool { + fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()> { self.state.add_public_key(account_id, public_key) } - #[must_use] #[inline] - fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool { + fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()> { self.state.remove_public_key(account_id, public_key) } - #[must_use] #[inline] - fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> bool { + fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> Result<()> { self.state.commit_nonce(account_id, nonce) } diff --git a/core/src/engine/state/mod.rs b/core/src/engine/state/mod.rs index e1bc1386..e8b8333a 100644 --- a/core/src/engine/state/mod.rs +++ b/core/src/engine/state/mod.rs @@ -33,6 +33,8 @@ pub trait StateView { #[must_use] fn balance_of(&self, account_id: &AccountIdRef, token_id: &TokenId) -> u128; + fn is_account_locked(&self, account_id: &AccountIdRef) -> bool; + #[inline] fn cached(self) -> CachedState where @@ -44,14 +46,11 @@ pub trait StateView { #[autoimpl(for &mut T, Box)] pub trait State: StateView { - #[must_use] - fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool; + fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()>; - #[must_use] - fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool; + fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()>; - #[must_use] - fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> bool; + fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> Result<()>; fn internal_add_balance( &mut self, diff --git a/core/src/error.rs b/core/src/error.rs index 0098f011..fcaf0c23 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -1,3 +1,4 @@ +use defuse_crypto::PublicKey; use near_sdk::{AccountId, FunctionError, serde_json}; use thiserror::Error as ThisError; @@ -10,8 +11,11 @@ pub type Result = ::core::result::Result; #[derive(Debug, ThisError, FunctionError)] pub enum DefuseError { - #[error("account not found")] - AccountNotFound, + #[error("account '{0}' not found")] + AccountNotFound(AccountId), + + #[error("account '{0}' is locked")] + AccountLocked(AccountId), #[error("insufficient balance or overflow")] BalanceOverflow, @@ -40,11 +44,11 @@ pub enum DefuseError { #[error("nonce was already used")] NonceUsed, - #[error("public key already exists")] - PublicKeyExists, + #[error("public key '{1}' already exists for account '{0}'")] + PublicKeyExists(AccountId, PublicKey), - #[error("public key doesn't exist")] - PublicKeyNotExist, + #[error("public key '{1}' doesn't exist for account '{0}'")] + PublicKeyNotExist(AccountId, PublicKey), #[error("token_id: {0}")] ParseTokenId(#[from] ParseTokenIdError), diff --git a/core/src/events.rs b/core/src/events.rs index 72de0932..d0517ea6 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -33,6 +33,13 @@ pub enum DefuseEvent<'a> { #[event_version("0.2.1")] IntentsExecuted(Cow<'a, [IntentEvent>]>), + + #[event_version("0.2.1")] + #[from(skip)] + AccountLocked(AccountEvent<'a, ()>), + #[event_version("0.2.1")] + #[from(skip)] + AccountUnlocked(AccountEvent<'a, ()>), } pub trait DefuseIntentEmit<'a>: Into> { diff --git a/core/src/intents/account.rs b/core/src/intents/account.rs index 7043eb1e..50cb7574 100644 --- a/core/src/intents/account.rs +++ b/core/src/intents/account.rs @@ -4,7 +4,7 @@ use near_sdk::{AccountIdRef, CryptoHash, near}; use serde_with::serde_as; use crate::{ - DefuseError, Nonce, Result, + Nonce, Result, engine::{Engine, Inspector, State}, }; @@ -33,13 +33,9 @@ impl ExecutableIntent for AddPublicKey { S: State, I: Inspector, { - if !engine + engine .state .add_public_key(signer_id.to_owned(), self.public_key) - { - return Err(DefuseError::PublicKeyExists); - } - Ok(()) } } @@ -62,13 +58,9 @@ impl ExecutableIntent for RemovePublicKey { S: State, I: Inspector, { - if !engine + engine .state .remove_public_key(signer_id.to_owned(), self.public_key) - { - return Err(DefuseError::PublicKeyNotExist); - } - Ok(()) } } @@ -96,16 +88,13 @@ impl ExecutableIntent for InvalidateNonces { signer_id: &AccountIdRef, engine: &mut Engine, _intent_hash: CryptoHash, - ) -> crate::Result<()> + ) -> Result<()> where S: State, I: Inspector, { - for nonce in self.nonces { - if !engine.state.commit_nonce(signer_id.to_owned(), nonce) { - return Err(DefuseError::NonceUsed); - } - } - Ok(()) + self.nonces + .into_iter() + .try_for_each(|n| engine.state.commit_nonce(signer_id.to_owned(), n)) } } diff --git a/core/src/intents/tokens.rs b/core/src/intents/tokens.rs index 4291bb10..3961e407 100644 --- a/core/src/intents/tokens.rs +++ b/core/src/intents/tokens.rs @@ -7,11 +7,13 @@ use serde_with::{DisplayFromStr, serde_as}; use crate::{ DefuseError, Result, engine::{Engine, Inspector, State}, - tokens::Amounts, + tokens::{Amounts, TokenId}, }; use super::ExecutableIntent; +pub type TokenAmounts = Amounts>; + #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), serde_as(schemars = true) @@ -27,7 +29,7 @@ pub struct Transfer { pub receiver_id: AccountId, #[serde_as(as = "Amounts>")] - pub tokens: Amounts, + pub tokens: TokenAmounts, #[serde(default, skip_serializing_if = "Option::is_none")] pub memo: Option, diff --git a/core/src/nonce.rs b/core/src/nonce.rs index 1cb861fc..fd659867 100644 --- a/core/src/nonce.rs +++ b/core/src/nonce.rs @@ -13,16 +13,19 @@ impl Nonces where T: Map, { + #[must_use] #[inline] pub const fn new(bitmap: T) -> Self { Self(BitMap256::new(bitmap)) } + #[must_use] #[inline] pub fn is_used(&self, n: Nonce) -> bool { self.0.get_bit(n) } + #[must_use] #[inline] pub fn commit(&mut self, n: Nonce) -> bool { !self.0.set_bit(n) diff --git a/defuse/Cargo.toml b/defuse/Cargo.toml index 5017c5ee..76c0f613 100644 --- a/defuse/Cargo.toml +++ b/defuse/Cargo.toml @@ -13,8 +13,10 @@ workspace = true [dependencies] defuse-admin-utils.workspace = true defuse-bitmap = { workspace = true, optional = true } +defuse-borsh-utils = { workspace = true, optional = true } defuse-controller.workspace = true defuse-core.workspace = true +defuse-io-utils = { workspace = true, optional = true } defuse-near-utils.workspace = true defuse-nep245.workspace = true defuse-map-utils = { workspace = true, optional = true } @@ -22,6 +24,7 @@ defuse-serde-utils.workspace = true defuse-wnear = { workspace = true, optional = true } bnum.workspace = true +derive_more.workspace = true impl-tools.workspace = true near-account-id.workspace = true near-contract-standards.workspace = true @@ -31,6 +34,17 @@ serde_with.workspace = true strum.workspace = true thiserror.workspace = true +[dev-dependencies] +defuse-test-utils.workspace = true +hex.workspace = true +rstest.workspace = true + [features] abi = ["defuse-core/abi"] -contract = ["dep:defuse-wnear", "dep:defuse-map-utils", "dep:defuse-bitmap"] +contract = [ + "dep:defuse-wnear", + "dep:defuse-map-utils", + "dep:defuse-bitmap", + "dep:defuse-borsh-utils", + "dep:defuse-io-utils", +] diff --git a/defuse/src/accounts.rs b/defuse/src/accounts.rs index 9485a5ea..e8b6e045 100644 --- a/defuse/src/accounts.rs +++ b/defuse/src/accounts.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use defuse_core::{Nonce, crypto::PublicKey}; use defuse_serde_utils::base64::AsBase64; +use near_plugins::AccessControllable; use near_sdk::{AccountId, ext_contract}; #[ext_contract(ext_public_key_manager)] @@ -21,7 +22,7 @@ pub trait AccountManager { /// i.e. this key can't be used to make any actions unless it's re-created. /// /// NOTE: MUST attach 1 yⓃ for security purposes. - fn remove_public_key(&mut self, public_key: &PublicKey); + fn remove_public_key(&mut self, public_key: PublicKey); /// Returns whether given nonce was already used by the account /// NOTE: nonces are non-sequential and follow @@ -31,3 +32,23 @@ pub trait AccountManager { /// NOTE: MUST attach 1 yⓃ for security purposes. fn invalidate_nonces(&mut self, nonces: Vec>); } + +#[ext_contract(ext_force_account_locker)] +pub trait AccountForceLocker: AccessControllable { + /// Returns whether the given`account_id` is locked + fn is_account_locked(&self, account_id: &AccountId) -> bool; + + /// Locks given `account_id` from modifying its own state, including + /// token balances. + /// Returns `false` if the account was already in locked state. + /// + /// Attached deposit of 1yN is required for security purposes. + /// + /// NOTE: this still allows for force withdrawals/transfers + fn force_lock_account(&mut self, account_id: AccountId) -> bool; + /// Unlocks given `account_id`. + /// Returns `false` if the account wasn't in locked state. + /// + /// Attached deposit of 1yN is required for security purposes. + fn force_unlock_account(&mut self, account_id: &AccountId) -> bool; +} diff --git a/defuse/src/contract/accounts/account.rs b/defuse/src/contract/accounts/account.rs index d14b8fda..9e0eacba 100644 --- a/defuse/src/contract/accounts/account.rs +++ b/defuse/src/contract/accounts/account.rs @@ -1,23 +1,48 @@ -use std::borrow::Cow; +use std::{ + borrow::Cow, + io::{self, Read}, +}; use defuse_bitmap::{U248, U256}; +use defuse_borsh_utils::r#as::{As, BorshDeserializeAs, BorshSerializeAs}; use defuse_core::{ Nonces, accounts::{AccountEvent, PublicKeyEvent}, crypto::PublicKey, events::DefuseEvent, }; -use defuse_near_utils::NestPrefix; +use defuse_io_utils::ReadExt; +use defuse_near_utils::{Lock, NestPrefix, PanicOnClone}; use impl_tools::autoimpl; use near_sdk::{ AccountIdRef, BorshStorageKey, IntoStorageKey, - borsh::BorshSerialize, + borsh::{BorshDeserialize, BorshSerialize}, near, store::{IterableSet, LookupMap}, }; use super::AccountState; +#[derive(Debug)] +#[autoimpl(Deref using self.0)] +#[autoimpl(DerefMut using self.0)] +#[autoimpl(AsRef using self.0)] +#[autoimpl(AsMut using self.0)] +#[near(serializers = [borsh])] +pub struct AccountEntry( + #[borsh( + deserialize_with = "As::::deserialize", + serialize_with = "As::::serialize" + )] + pub Lock, +); + +impl From> for AccountEntry { + fn from(account: Lock) -> Self { + Self(account) + } +} + #[derive(Debug)] #[near(serializers = [borsh])] #[autoimpl(Deref using self.state)] @@ -141,3 +166,141 @@ enum AccountPrefix { PublicKeys, State, } + +/// This is a magic number that is used to differentiate between +/// borsh-serialized representations of legacy and versioned [`Account`]s: +/// * versioned [`Account`]s always start with this prefix +/// * legacy [`Account`] starts with other 4 bytes +/// +/// This is safe to assume that legacy [`Account`] doesn't start with +/// this prefix, since the first 4 bytes in legacy [`Account`] were used +/// to detote the length of `prefix: Box<[u8]>` in [`LookupMap`] for +/// `nonces`. Given that the original prefix is reused for other fields of +/// [`Account`] for creating other nested prefixes, then the length of +/// this prefix can't be the maximum of what `Box<[u8]>` can be +/// serialized to. +const VERSIONED_MAGIC_PREFIX: u32 = u32::MAX; + +/// Versioned [Account] state for de/serialization +#[derive(Debug)] +#[near(serializers = [borsh])] +enum VersionedAccountEntry<'a> { + V0(Cow<'a, PanicOnClone>), + V1(Cow<'a, PanicOnClone>>), +} + +impl From for VersionedAccountEntry<'_> { + fn from(value: Account) -> Self { + Self::V0(Cow::Owned(value.into())) + } +} + +impl<'a> From<&'a Lock> for VersionedAccountEntry<'a> { + fn from(value: &'a Lock) -> Self { + // always serialize as latest version + Self::V1(Cow::Borrowed(PanicOnClone::from_ref(value))) + } +} + +impl From> for Lock { + fn from(versioned: VersionedAccountEntry<'_>) -> Self { + // Borsh always deserializes into `Cow::Owned`, so it's + // safe to call `Cow::>::into_owned()` here. + match versioned { + VersionedAccountEntry::V0(account) => account.into_owned().into_inner().into(), + VersionedAccountEntry::V1(account) => account.into_owned().into_inner(), + } + } +} + +struct MaybeVersionedAccountEntry; + +impl BorshDeserializeAs> for MaybeVersionedAccountEntry { + fn deserialize_as(reader: &mut R) -> io::Result> + where + R: io::Read, + { + let mut buf = Vec::new(); + // There will always be 4 bytes for u32: + // * either `VERSIONED_MAGIC_PREFIX`, + // * or u32 for `Account.nonces.prefix` + let prefix = u32::deserialize_reader(&mut reader.tee(&mut buf))?; + + if prefix == VERSIONED_MAGIC_PREFIX { + VersionedAccountEntry::deserialize_reader(reader) + } else { + Account::deserialize_reader( + // prepend already consumed part of the reader + &mut buf.chain(reader), + ) + .map(Into::into) + } + .map(Into::into) + } +} + +impl BorshSerializeAs> for MaybeVersionedAccountEntry { + fn serialize_as(source: &Lock, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + ( + // always serialize as versioned and prepend magic prefix + VERSIONED_MAGIC_PREFIX, + VersionedAccountEntry::from(source), + ) + .serialize(writer) + } +} + +#[cfg(test)] +mod tests { + use defuse_core::tokens::TokenId; + use defuse_test_utils::random::{Rng, Seed, make_seedable_rng, random_seed}; + use near_sdk::borsh; + use rstest::rstest; + + use crate::contract::{Prefix, accounts::AccountsPrefix}; + + use super::*; + + #[rstest] + #[test] + fn legacy_upgrade(random_seed: Seed) { + const ACCOUNT_ID: &AccountIdRef = AccountIdRef::new_or_panic("test.near"); + let ft = TokenId::Nep141("wrap.near".parse().unwrap()); + + let mut rng = make_seedable_rng(random_seed); + let nonce = rng.random(); + + let serialized_legacy = { + let mut legacy = Account::new( + Prefix::Accounts + .into_storage_key() + .as_slice() + .nest(AccountsPrefix::Account(ACCOUNT_ID)), + ACCOUNT_ID, + ); + legacy.token_balances.add(ft.clone(), 123).unwrap(); + legacy.commit_nonce(nonce); + borsh::to_vec(&legacy).expect("unable to serialize legacy Account") + }; + + let serialized_versioned = { + let mut versioned: AccountEntry = borsh::from_slice(&serialized_legacy).unwrap(); + let account = versioned + .lock() + .expect("legacy accounts must be unlocked by default"); + assert_eq!(account.token_balances.amount_for(&ft), 123); + assert!(account.is_nonce_used(nonce)); + borsh::to_vec(&versioned).expect("unable to serialize versioned account") + }; + + { + let versioned: AccountEntry = borsh::from_slice(&serialized_versioned).unwrap(); + let account = versioned.as_locked().expect("should be locked by now"); + assert_eq!(account.token_balances.amount_for(&ft), 123); + assert!(account.is_nonce_used(nonce)); + } + } +} diff --git a/defuse/src/contract/accounts/mod.rs b/defuse/src/contract/accounts/mod.rs index 4e3859d7..38937732 100644 --- a/defuse/src/contract/accounts/mod.rs +++ b/defuse/src/contract/accounts/mod.rs @@ -5,86 +5,72 @@ pub use self::{account::*, state::*}; use std::collections::HashSet; -use defuse_core::{DefuseError, Nonce, crypto::PublicKey}; -use defuse_near_utils::{NestPrefix, PREDECESSOR_ACCOUNT_ID}; +use defuse_core::{ + Nonce, + accounts::AccountEvent, + crypto::PublicKey, + engine::{State, StateView}, + events::DefuseEvent, +}; +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, + AccountId, AccountIdRef, BorshStorageKey, IntoStorageKey, assert_one_yocto, borsh::BorshSerialize, near, store::IterableMap, }; use crate::{ - accounts::AccountManager, - contract::{Contract, ContractExt}, + accounts::{AccountForceLocker, AccountManager}, + contract::{Contract, ContractExt, Role}, }; #[near] impl AccountManager for Contract { fn has_public_key(&self, account_id: &AccountId, public_key: &PublicKey) -> bool { - self.accounts.get(account_id).map_or_else( - || account_id == &public_key.to_implicit_account_id(), - |account| account.has_public_key(account_id, public_key), - ) + StateView::has_public_key(&self, account_id, public_key) } fn public_keys_of(&self, account_id: &AccountId) -> HashSet { - self.accounts.get(account_id).map_or_else( - || { - PublicKey::from_implicit_account_id(account_id) - .into_iter() - .collect() - }, - |account| account.iter_public_keys(account_id).collect(), - ) + StateView::iter_public_keys(&self, account_id).collect() } #[payable] fn add_public_key(&mut self, public_key: PublicKey) { assert_one_yocto(); - if !self - .accounts - .get_or_create(PREDECESSOR_ACCOUNT_ID.clone()) - .add_public_key(&PREDECESSOR_ACCOUNT_ID, public_key) - { - DefuseError::PublicKeyExists.panic() - } + State::add_public_key(self, PREDECESSOR_ACCOUNT_ID.clone(), public_key).unwrap_or_panic(); } #[payable] - fn remove_public_key(&mut self, public_key: &PublicKey) { + fn remove_public_key(&mut self, public_key: PublicKey) { assert_one_yocto(); - if !self - .accounts - // create account if doesn't exist, so the user can opt out of implicit public key - .get_or_create(PREDECESSOR_ACCOUNT_ID.clone()) - .remove_public_key(&PREDECESSOR_ACCOUNT_ID, public_key) - { - DefuseError::PublicKeyNotExist.panic() - } + State::remove_public_key(self, PREDECESSOR_ACCOUNT_ID.clone(), public_key) + .unwrap_or_panic(); } fn is_nonce_used(&self, account_id: &AccountId, nonce: AsBase64) -> bool { - self.accounts - .get(account_id) - .is_some_and(move |account| account.is_nonce_used(nonce.into_inner())) + StateView::is_nonce_used(&self, account_id, nonce.into_inner()) } #[payable] fn invalidate_nonces(&mut self, nonces: Vec>) { assert_one_yocto(); - let account = self.accounts.get_or_create(PREDECESSOR_ACCOUNT_ID.clone()); - for n in nonces.into_iter().map(AsBase64::into_inner) { - if !account.commit_nonce(n) { - DefuseError::NonceUsed.panic() - } - } + nonces + .into_iter() + .map(AsBase64::into_inner) + .try_for_each(|n| State::commit_nonce(self, PREDECESSOR_ACCOUNT_ID.clone(), n)) + .unwrap_or_panic(); } } #[derive(Debug)] #[near(serializers = [borsh])] pub struct Accounts { - accounts: IterableMap, + // Initially, it was `IterableMap`, but then the support + // for locking accounts was introduces, so we lazily migrate the storage. + // Unfortunately, we can't use `#[borsh(deserialize_with = "...")]` here, + // as IterableMap requires K and V parameters to implement BorshSerialize + accounts: IterableMap, prefix: Vec, } @@ -102,26 +88,29 @@ impl Accounts { } #[inline] - pub fn get(&self, account_id: &AccountIdRef) -> Option<&Account> { - self.accounts.get(account_id) + pub fn get(&self, account_id: &AccountIdRef) -> Option<&Lock> { + self.accounts.get(account_id).map(|a| &**a) } #[inline] - pub fn get_mut(&mut self, account_id: &AccountIdRef) -> Option<&mut Account> { - self.accounts.get_mut(account_id) + pub fn get_mut(&mut self, account_id: &AccountIdRef) -> Option<&mut Lock> { + self.accounts.get_mut(account_id).map(|a| &mut **a) } + /// Gets or creates an account with given `account_id`. + /// NOTE: The created account will be unblocked by default. #[inline] - pub fn get_or_create(&mut self, account_id: AccountId) -> &mut Account { + pub fn get_or_create(&mut self, account_id: AccountId) -> &mut Lock { self.accounts .entry(account_id) .or_insert_with_key(|account_id| { - Account::new( + Lock::unlocked(Account::new( self.prefix .as_slice() .nest(AccountsPrefix::Account(account_id)), account_id, - ) + )) + .into() }) } } @@ -130,5 +119,42 @@ impl Accounts { #[borsh(crate = "::near_sdk::borsh")] enum AccountsPrefix<'a> { Accounts, - Account(&'a AccountId), + Account(&'a AccountIdRef), +} + +#[near] +impl AccountForceLocker for Contract { + fn is_account_locked(&self, account_id: &AccountId) -> bool { + StateView::is_account_locked(self, account_id) + } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountLocker))] + #[payable] + fn force_lock_account(&mut self, account_id: AccountId) -> bool { + assert_one_yocto(); + let locked = self + .accounts + .get_or_create(account_id.clone()) + .lock() + .is_some(); + if locked { + DefuseEvent::AccountLocked(AccountEvent::new(account_id, ())).emit(); + } + locked + } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountUnlocker))] + #[payable] + fn force_unlock_account(&mut self, account_id: &AccountId) -> bool { + assert_one_yocto(); + let unlocked = self + .accounts + .get_mut(account_id) + .and_then(Lock::unlock) + .is_some(); + if unlocked { + DefuseEvent::AccountUnlocked(AccountEvent::new(account_id, ())).emit(); + } + unlocked + } } diff --git a/defuse/src/contract/intents/state.rs b/defuse/src/contract/intents/state.rs index 2c750036..069d69ea 100644 --- a/defuse/src/contract/intents/state.rs +++ b/defuse/src/contract/intents/state.rs @@ -8,7 +8,7 @@ use defuse_core::{ intents::tokens::{FtWithdraw, MtWithdraw, NativeWithdraw, NftWithdraw, StorageDeposit}, tokens::TokenId, }; -use defuse_near_utils::CURRENT_ACCOUNT_ID; +use defuse_near_utils::{CURRENT_ACCOUNT_ID, Lock}; use defuse_wnear::{NEAR_WITHDRAW_GAS, ext_wnear}; use near_sdk::{AccountId, AccountIdRef, NearToken, json_types::U128}; @@ -37,14 +37,17 @@ impl StateView for Contract { #[inline] fn has_public_key(&self, account_id: &AccountIdRef, public_key: &PublicKey) -> bool { - self.accounts.get(account_id).map_or_else( - || account_id == public_key.to_implicit_account_id(), - |account| account.has_public_key(account_id, public_key), - ) + self.accounts + .get(account_id) + .map(Lock::as_inner_unchecked) + .map_or_else( + || account_id == public_key.to_implicit_account_id(), + |account| account.has_public_key(account_id, public_key), + ) } fn iter_public_keys(&self, account_id: &AccountIdRef) -> impl Iterator + '_ { - let account = self.accounts.get(account_id); + let account = self.accounts.get(account_id).map(Lock::as_inner_unchecked); account .map(|account| account.iter_public_keys(account_id)) .into_iter() @@ -60,6 +63,7 @@ impl StateView for Contract { fn is_nonce_used(&self, account_id: &AccountIdRef, nonce: Nonce) -> bool { self.accounts .get(account_id) + .map(Lock::as_inner_unchecked) .is_some_and(|account| account.is_nonce_used(nonce)) } @@ -67,32 +71,50 @@ impl StateView for Contract { fn balance_of(&self, account_id: &AccountIdRef, token_id: &TokenId) -> u128 { self.accounts .get(account_id) + .map(Lock::as_inner_unchecked) .map(|account| account.token_balances.amount_for(token_id)) .unwrap_or_default() } + + #[inline] + fn is_account_locked(&self, account_id: &AccountIdRef) -> bool { + self.accounts.get(account_id).is_some_and(Lock::is_locked) + } } impl State for Contract { - #[must_use] #[inline] - fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool { + fn add_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()> { self.accounts .get_or_create(account_id.clone()) + .as_unlocked_mut() + .ok_or_else(|| DefuseError::AccountLocked(account_id.clone()))? .add_public_key(&account_id, public_key) + .then_some(()) + .ok_or(DefuseError::PublicKeyExists(account_id, public_key)) } - #[must_use] #[inline] - fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> bool { + fn remove_public_key(&mut self, account_id: AccountId, public_key: PublicKey) -> Result<()> { self.accounts + // create account if doesn't exist, so the user can opt out of implicit public key .get_or_create(account_id.clone()) + .as_unlocked_mut() + .ok_or_else(|| DefuseError::AccountLocked(account_id.clone()))? .remove_public_key(&account_id, &public_key) + .then_some(()) + .ok_or(DefuseError::PublicKeyNotExist(account_id, public_key)) } - #[must_use] #[inline] - fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> bool { - self.accounts.get_or_create(account_id).commit_nonce(nonce) + fn commit_nonce(&mut self, account_id: AccountId, nonce: Nonce) -> Result<()> { + self.accounts + .get_or_create(account_id.clone()) + .as_unlocked_mut() + .ok_or(DefuseError::AccountLocked(account_id))? + .commit_nonce(nonce) + .then_some(()) + .ok_or(DefuseError::NonceUsed) } fn internal_add_balance( @@ -100,7 +122,11 @@ impl State for Contract { owner_id: AccountId, tokens: impl IntoIterator, ) -> Result<()> { - let owner = self.accounts.get_or_create(owner_id); + let owner = self + .accounts + .get_or_create(owner_id.clone()) + // we allow locked accounts to accept deposits and incoming deposits + .as_inner_unchecked_mut(); for (token_id, amount) in tokens { if amount == 0 { @@ -123,7 +149,9 @@ impl State for Contract { let owner = self .accounts .get_mut(owner_id) - .ok_or(DefuseError::AccountNotFound)?; + .ok_or_else(|| DefuseError::AccountNotFound(owner_id.to_owned()))? + .as_unlocked_mut() + .ok_or_else(|| DefuseError::AccountLocked(owner_id.to_owned()))?; for (token_id, amount) in tokens { if amount == 0 { @@ -140,19 +168,19 @@ impl State for Contract { } fn ft_withdraw(&mut self, owner_id: &AccountIdRef, withdraw: FtWithdraw) -> Result<()> { - self.internal_ft_withdraw(owner_id.to_owned(), withdraw) + self.internal_ft_withdraw(owner_id.to_owned(), withdraw, false) // detach promise .map(|_promise| ()) } fn nft_withdraw(&mut self, owner_id: &AccountIdRef, withdraw: NftWithdraw) -> Result<()> { - self.internal_nft_withdraw(owner_id.to_owned(), withdraw) + self.internal_nft_withdraw(owner_id.to_owned(), withdraw, false) // detach promise .map(|_promise| ()) } fn mt_withdraw(&mut self, owner_id: &AccountIdRef, withdraw: MtWithdraw) -> Result<()> { - self.internal_mt_withdraw(owner_id.to_owned(), withdraw) + self.internal_mt_withdraw(owner_id.to_owned(), withdraw, false) // detach promise .map(|_promise| ()) } @@ -165,6 +193,7 @@ impl State for Contract { withdraw.amount.as_yoctonear(), )], Some("withdraw"), + false, )?; // detach promise @@ -194,6 +223,7 @@ impl State for Contract { storage_deposit.amount.as_yoctonear(), )], Some("withdraw"), + false, )?; // detach promise diff --git a/defuse/src/contract/mod.rs b/defuse/src/contract/mod.rs index 62519f24..699e0944 100644 --- a/defuse/src/contract/mod.rs +++ b/defuse/src/contract/mod.rs @@ -42,6 +42,9 @@ pub enum Role { PauseManager, Upgrader, UnpauseManager, + + UnrestrictedAccountLocker, + UnrestrictedAccountUnlocker, } #[access_control(role_type(Role))] diff --git a/defuse/src/contract/tokens/mod.rs b/defuse/src/contract/tokens/mod.rs index cc1f774a..9fccdfee 100644 --- a/defuse/src/contract/tokens/mod.rs +++ b/defuse/src/contract/tokens/mod.rs @@ -19,7 +19,11 @@ impl Contract { tokens: impl IntoIterator, memo: Option<&str>, ) -> Result<()> { - let owner = self.accounts.get_or_create(owner_id.clone()); + let owner = self + .accounts + .get_or_create(owner_id.clone()) + // deposits are allowed for locked accounts + .as_inner_unchecked_mut(); let mut mint_event = MtMintEvent { owner_id: owner_id.into(), @@ -63,11 +67,14 @@ impl Contract { owner_id: &AccountIdRef, token_amounts: impl IntoIterator, memo: Option>, + force: bool, ) -> Result<()> { let owner = self .accounts .get_mut(owner_id) - .ok_or(DefuseError::AccountNotFound)?; + .ok_or_else(|| DefuseError::AccountNotFound(owner_id.to_owned()))? + .as_unlocked_or_mut(force) + .ok_or_else(|| DefuseError::AccountLocked(owner_id.to_owned()))?; let mut burn_event = MtBurnEvent { owner_id: Cow::Owned(owner_id.to_owned()), diff --git a/defuse/src/contract/tokens/nep141/withdraw.rs b/defuse/src/contract/tokens/nep141/withdraw.rs index 7ea6e272..a0cb2a0c 100644 --- a/defuse/src/contract/tokens/nep141/withdraw.rs +++ b/defuse/src/contract/tokens/nep141/withdraw.rs @@ -48,6 +48,7 @@ impl FungibleTokenWithdrawer for Contract { msg, storage_deposit: None, }, + false, ) .unwrap_or_panic() } @@ -58,6 +59,7 @@ impl Contract { &mut self, owner_id: AccountId, withdraw: FtWithdraw, + force: bool, ) -> Result> { self.withdraw( &owner_id, @@ -70,6 +72,7 @@ impl Contract { }), ), Some("withdraw"), + force, )?; let is_call = withdraw.msg.is_some(); @@ -217,6 +220,7 @@ impl FungibleTokenForceWithdrawer for Contract { msg, storage_deposit: None, }, + true, ) .unwrap_or_panic() } diff --git a/defuse/src/contract/tokens/nep171/withdraw.rs b/defuse/src/contract/tokens/nep171/withdraw.rs index ef62938d..b04c59fe 100644 --- a/defuse/src/contract/tokens/nep171/withdraw.rs +++ b/defuse/src/contract/tokens/nep171/withdraw.rs @@ -48,6 +48,7 @@ impl NonFungibleTokenWithdrawer for Contract { msg, storage_deposit: None, }, + false, ) .unwrap_or_panic() } @@ -58,6 +59,7 @@ impl Contract { &mut self, owner_id: AccountId, withdraw: NftWithdraw, + force: bool, ) -> Result> { self.withdraw( &owner_id, @@ -72,6 +74,7 @@ impl Contract { ) })), Some("withdraw"), + force, )?; let is_call = withdraw.msg.is_some(); @@ -207,6 +210,7 @@ impl NonFungibleTokenForceWithdrawer for Contract { msg, storage_deposit: None, }, + true, ) .unwrap_or_panic() } diff --git a/defuse/src/contract/tokens/nep245/core.rs b/defuse/src/contract/tokens/nep245/core.rs index cba5393a..cd106da7 100644 --- a/defuse/src/contract/tokens/nep245/core.rs +++ b/defuse/src/contract/tokens/nep245/core.rs @@ -49,6 +49,7 @@ impl MultiTokenCore for Contract { token_ids, amounts, memo.as_deref(), + false, ) .unwrap_or_panic() } @@ -95,6 +96,7 @@ impl MultiTokenCore for Contract { amounts, memo.as_deref(), msg, + false, ) .unwrap_or_panic() } @@ -165,6 +167,7 @@ impl Contract { token_ids: Vec, amounts: Vec, memo: Option<&str>, + force: bool, ) -> Result<()> { if sender_id == receiver_id || token_ids.len() != amounts.len() || amounts.is_empty() { return Err(DefuseError::InvalidIntent); @@ -178,12 +181,16 @@ impl Contract { self.accounts .get_mut(sender_id) - .ok_or(DefuseError::AccountNotFound)? + .ok_or_else(|| DefuseError::AccountNotFound(sender_id.to_owned()))? + .as_unlocked_or_mut(force) + .ok_or_else(|| DefuseError::AccountLocked(sender_id.to_owned()))? .token_balances .sub(token_id.clone(), amount) .ok_or(DefuseError::BalanceOverflow)?; self.accounts .get_or_create(receiver_id.clone()) + // locked accounts are allowed to receive incoming transfers + .as_inner_unchecked_mut() .token_balances .add(token_id, amount) .ok_or(DefuseError::BalanceOverflow)?; @@ -206,6 +213,7 @@ impl Contract { Ok(()) } + #[allow(clippy::too_many_arguments)] pub(crate) fn internal_mt_batch_transfer_call( &mut self, sender_id: AccountId, @@ -214,6 +222,7 @@ impl Contract { amounts: Vec, memo: Option<&str>, msg: String, + force: bool, ) -> Result>> { self.internal_mt_batch_transfer( &sender_id, @@ -221,6 +230,7 @@ impl Contract { token_ids.clone(), amounts.clone(), memo, + force, )?; let previous_owner_ids = vec![sender_id.clone(); token_ids.len()]; diff --git a/defuse/src/contract/tokens/nep245/enumeration.rs b/defuse/src/contract/tokens/nep245/enumeration.rs index 648301a0..0282893e 100644 --- a/defuse/src/contract/tokens/nep245/enumeration.rs +++ b/defuse/src/contract/tokens/nep245/enumeration.rs @@ -39,19 +39,19 @@ impl MultiTokenEnumeration for Contract { return Vec::new(); }; - let iter = - account - .state - .token_balances - .iter() - .skip(from_index) - .map(|(token_id, _amount)| Token { - token_id: token_id.to_string(), - owner_id: match token_id.into() { - TokenIdType::Nep171 => Some(account_id.clone()), - TokenIdType::Nep141 | TokenIdType::Nep245 => None, - }, - }); + let iter = account + .as_inner_unchecked() + .state + .token_balances + .iter() + .skip(from_index) + .map(|(token_id, _amount)| Token { + token_id: token_id.to_string(), + owner_id: match token_id.into() { + TokenIdType::Nep171 => Some(account_id.clone()), + TokenIdType::Nep141 | TokenIdType::Nep245 => None, + }, + }); match limit { Some(l) => iter.take(l.try_into().unwrap_or_panic_display()).collect(), diff --git a/defuse/src/contract/tokens/nep245/force.rs b/defuse/src/contract/tokens/nep245/force.rs new file mode 100644 index 00000000..ba89177f --- /dev/null +++ b/defuse/src/contract/tokens/nep245/force.rs @@ -0,0 +1,110 @@ +#![allow(clippy::too_many_arguments)] + +use defuse_near_utils::UnwrapOrPanic; +use defuse_nep245::TokenId; +use near_plugins::{AccessControllable, access_control_any}; +use near_sdk::{AccountId, PromiseOrValue, assert_one_yocto, json_types::U128, near, require}; + +use crate::{ + contract::{Contract, ContractExt, Role}, + tokens::nep245::MultiTokenForceCore, +}; + +#[near] +impl MultiTokenForceCore for Contract { + #[access_control_any(roles(Role::DAO, Role::UnrestrictedWithdrawer))] + #[payable] + fn mt_force_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + amount: U128, + approval: Option<(AccountId, u64)>, + memo: Option, + ) { + self.mt_force_batch_transfer( + owner_id, + receiver_id, + [token_id].into(), + [amount].into(), + approval.map(|a| vec![Some(a)]), + memo, + ); + } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedWithdrawer))] + #[payable] + fn mt_force_batch_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_ids: Vec, + amounts: Vec, + approvals: Option>>, + memo: Option, + ) { + assert_one_yocto(); + require!(approvals.is_none(), "approvals are not supported"); + + self.internal_mt_batch_transfer( + &owner_id, + receiver_id, + token_ids, + amounts, + memo.as_deref(), + true, + ) + .unwrap_or_panic() + } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedWithdrawer))] + #[payable] + fn mt_force_transfer_call( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + amount: U128, + approval: Option<(AccountId, u64)>, + memo: Option, + msg: String, + ) -> PromiseOrValue> { + self.mt_force_batch_transfer_call( + owner_id, + receiver_id, + [token_id].into(), + [amount].into(), + approval.map(|a| vec![Some(a)]), + memo, + msg, + ) + } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedWithdrawer))] + #[payable] + fn mt_force_batch_transfer_call( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_ids: Vec, + amounts: Vec, + approvals: Option>>, + memo: Option, + msg: String, + ) -> PromiseOrValue> { + assert_one_yocto(); + require!(approvals.is_none(), "approvals are not supported"); + + self.internal_mt_batch_transfer_call( + owner_id, + receiver_id, + token_ids, + amounts, + memo.as_deref(), + msg, + true, + ) + .unwrap_or_panic() + } +} diff --git a/defuse/src/contract/tokens/nep245/mod.rs b/defuse/src/contract/tokens/nep245/mod.rs index 52970436..bc2a7712 100644 --- a/defuse/src/contract/tokens/nep245/mod.rs +++ b/defuse/src/contract/tokens/nep245/mod.rs @@ -1,5 +1,6 @@ mod core; mod deposit; mod enumeration; +mod force; mod resolver; mod withdraw; diff --git a/defuse/src/contract/tokens/nep245/resolver.rs b/defuse/src/contract/tokens/nep245/resolver.rs index 4dc267f6..044c7ba2 100644 --- a/defuse/src/contract/tokens/nep245/resolver.rs +++ b/defuse/src/contract/tokens/nep245/resolver.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use defuse_near_utils::{UnwrapOrPanic, UnwrapOrPanicError}; +use defuse_near_utils::{Lock, UnwrapOrPanic, UnwrapOrPanicError}; use defuse_nep245::{ ClearedApproval, MtEventEmit, MtTransferEvent, TokenId, resolver::MultiTokenResolver, }; @@ -51,8 +51,25 @@ impl MultiTokenResolver for Contract { ); refund.0 = refund.0.min(amount.0); - let Some(receiver) = self.accounts.get_mut(&receiver_id) else { - // receiver doesn't have an account, so nowhere to refund from + let Some(receiver) = self + .accounts + .get_mut(&receiver_id) + // NOTE: Interacting with locked receivers might result in loss of funds. + // + // Receiver's account might have been locked between `mt_transfer_call()` and + // `mt_resolve_transfer()`, so that outgoing transfers are no longer allowed + // for this account. If we allow refunds to happen, then it would lead to + // `mt_transfer` events emitted with `old_owner_id` being the locked account, + // which we want to avoid. + // + // So, since we allow locked accounts to receive incoming transfers, it means + // that `mt_transfer_call()` to locked recepients will always result in full + // transfer without any refunds, even if the receiver returned non-zero refund + // amounts from `mt_on_transfer`. + .and_then(Lock::as_unlocked_mut) + else { + // receiver doesn't have an account or it's locked, + // so nowhere to refund from return amounts; }; let receiver_balance = receiver.token_balances.amount_for(&token_id); @@ -69,8 +86,10 @@ impl MultiTokenResolver for Contract { .sub(token_id.clone(), refund.0) .unwrap_or_panic(); // deposit refund - let previous_owner = self.accounts.get_or_create(previous_owner_id); - previous_owner + self.accounts + .get_or_create(previous_owner_id) + // refunds are allowed for locked accounts + .as_inner_unchecked_mut() .token_balances .add(token_id, refund.0) .unwrap_or_panic(); diff --git a/defuse/src/contract/tokens/nep245/withdraw.rs b/defuse/src/contract/tokens/nep245/withdraw.rs index 4f53344c..16b9a25f 100644 --- a/defuse/src/contract/tokens/nep245/withdraw.rs +++ b/defuse/src/contract/tokens/nep245/withdraw.rs @@ -51,6 +51,7 @@ impl MultiTokenWithdrawer for Contract { msg, storage_deposit: None, }, + false, ) .unwrap_or_panic() } @@ -61,6 +62,7 @@ impl Contract { &mut self, owner_id: AccountId, withdraw: MtWithdraw, + force: bool, ) -> Result>> { if withdraw.token_ids.len() != withdraw.amounts.len() || withdraw.token_ids.is_empty() { return Err(DefuseError::InvalidIntent); @@ -79,6 +81,7 @@ impl Contract { ) })), Some("withdraw"), + force, )?; let is_call = withdraw.msg.is_some(); @@ -251,6 +254,7 @@ impl MultiTokenForceWithdrawer for Contract { msg, storage_deposit: None, }, + true, ) .unwrap_or_panic() } diff --git a/defuse/src/lib.rs b/defuse/src/lib.rs index 7d6af755..bff3d764 100644 --- a/defuse/src/lib.rs +++ b/defuse/src/lib.rs @@ -6,6 +6,7 @@ pub mod fees; pub mod intents; pub mod tokens; +use accounts::AccountForceLocker; pub use defuse_core as core; pub use defuse_nep245 as nep245; @@ -20,6 +21,7 @@ use near_contract_standards::{ }; use near_plugins::{AccessControllable, Pausable}; use near_sdk::ext_contract; +use tokens::nep245::MultiTokenForceCore; use self::{ accounts::AccountManager, @@ -49,9 +51,11 @@ pub trait Defuse: + MultiTokenEnumeration // Governance + AccessControllable + + MultiTokenForceCore + FungibleTokenForceWithdrawer + NonFungibleTokenForceWithdrawer + MultiTokenForceWithdrawer + + AccountForceLocker + Pausable + ControllerUpgradable + FullAccessKeys diff --git a/defuse/src/tokens/nep245.rs b/defuse/src/tokens/nep245.rs index 7083259c..08f75699 100644 --- a/defuse/src/tokens/nep245.rs +++ b/defuse/src/tokens/nep245.rs @@ -1,6 +1,6 @@ #![allow(clippy::too_many_arguments)] -use defuse_nep245::{TokenId, receiver::MultiTokenReceiver}; +use defuse_nep245::{MultiTokenCore, TokenId, receiver::MultiTokenReceiver}; use near_plugins::AccessControllable; use near_sdk::{AccountId, PromiseOrValue, ext_contract, json_types::U128}; @@ -48,3 +48,48 @@ pub trait MultiTokenForceWithdrawer: MultiTokenWithdrawer + AccessControllable { msg: Option, ) -> PromiseOrValue>; } + +#[ext_contract(ext_mt_force_core)] +pub trait MultiTokenForceCore: MultiTokenCore + AccessControllable { + fn mt_force_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + amount: U128, + approval: Option<(AccountId, u64)>, + memo: Option, + ); + + fn mt_force_batch_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_ids: Vec, + amounts: Vec, + approvals: Option>>, + memo: Option, + ); + + fn mt_force_transfer_call( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + amount: U128, + approval: Option<(AccountId, u64)>, + memo: Option, + msg: String, + ) -> PromiseOrValue>; + + fn mt_force_batch_transfer_call( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_ids: Vec, + amounts: Vec, + approvals: Option>>, + memo: Option, + msg: String, + ) -> PromiseOrValue>; +} diff --git a/io-utils/Cargo.toml b/io-utils/Cargo.toml new file mode 100644 index 00000000..3b4fc220 --- /dev/null +++ b/io-utils/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "defuse-io-utils" +edition.workspace = true +version = "0.1.0" + +[lints] +workspace = true diff --git a/io-utils/src/lib.rs b/io-utils/src/lib.rs new file mode 100644 index 00000000..8e05f853 --- /dev/null +++ b/io-utils/src/lib.rs @@ -0,0 +1,45 @@ +use std::io::{Read, Result, Write}; + +pub trait ReadExt: Read { + fn tee(self, writer: W) -> TeeReader + where + Self: Sized, + W: Write, + { + TeeReader { + reader: self, + writer, + } + } +} +impl ReadExt for R where R: Read {} + +pub struct TeeReader { + reader: R, + writer: W, +} + +impl TeeReader { + #[inline] + pub fn into_inner(self) -> (R, W) { + (self.reader, self.writer) + } +} + +impl Read for TeeReader +where + R: Read, + W: Write, +{ + fn read(&mut self, buf: &mut [u8]) -> Result { + let n = self.reader.read(buf)?; + self.writer.write_all(&buf[..n])?; + Ok(n) + } + + fn read_to_end(&mut self, buf: &mut Vec) -> Result { + let n = self.reader.read_to_end(buf)?; + self.writer.write_all(&buf[..n])?; + Ok(n) + } +} diff --git a/near-utils/Cargo.toml b/near-utils/Cargo.toml index e24d0fc7..fddc5566 100644 --- a/near-utils/Cargo.toml +++ b/near-utils/Cargo.toml @@ -5,4 +5,7 @@ version.workspace = true repository.workspace = true [dependencies] +defuse-borsh-utils.workspace = true + +impl-tools.workspace = true near-sdk.workspace = true diff --git a/near-utils/src/lib.rs b/near-utils/src/lib.rs index 0cc8c68f..4ba23a76 100644 --- a/near-utils/src/lib.rs +++ b/near-utils/src/lib.rs @@ -2,9 +2,10 @@ mod cache; mod gas; mod lock; mod panic; +mod panic_on_clone; mod prefix; -pub use self::{cache::*, gas::*, lock::*, panic::*, prefix::*}; +pub use self::{cache::*, gas::*, lock::*, panic::*, panic_on_clone::*, prefix::*}; #[macro_export] macro_rules! method_name { diff --git a/near-utils/src/lock.rs b/near-utils/src/lock.rs index d815ad67..bfe7e19c 100644 --- a/near-utils/src/lock.rs +++ b/near-utils/src/lock.rs @@ -1,4 +1,10 @@ -use near_sdk::near; +use std::io; + +use defuse_borsh_utils::r#as::{AsWrap, BorshDeserializeAs, BorshSerializeAs}; +use near_sdk::{ + borsh::{BorshDeserialize, BorshSerialize}, + near, +}; /// A persistent lock, which stores its state (whether it's locked or unlocked) /// on-chain, so that the inner value can be accessed depending on @@ -6,21 +12,21 @@ use near_sdk::near; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[near(serializers = [borsh, json])] pub struct Lock { - #[serde(flatten)] - value: T, #[serde( default, // do not serialize `false` skip_serializing_if = "::core::ops::Not::not" )] locked: bool, + #[serde(flatten)] + value: T, } impl Lock { #[must_use] #[inline] pub const fn new(value: T, locked: bool) -> Self { - Self { value, locked } + Self { locked, value } } #[must_use] @@ -35,81 +41,176 @@ impl Lock { Self::new(value, true) } + #[inline] + pub const fn set_locked(&mut self, locked: bool) -> &mut Self { + self.locked = locked; + self + } + + #[inline] + pub const fn as_inner_unchecked(&self) -> &T { + &self.value + } + + #[inline] + pub const fn as_inner_unchecked_mut(&mut self) -> &mut T { + &mut self.value + } + + #[inline] + pub fn into_inner_unchecked(self) -> T { + self.value + } + + #[must_use] #[inline] pub const fn is_locked(&self) -> bool { self.locked } + #[must_use] #[inline] pub const fn as_locked(&self) -> Option<&T> { - if !self.locked { + if !self.is_locked() { return None; } - Some(&self.value) + Some(self.as_inner_unchecked()) } + #[must_use] #[inline] - pub fn as_locked_mut(&mut self) -> Option<&mut T> { - if !self.locked { + pub const fn as_locked_mut(&mut self) -> Option<&mut T> { + if !self.is_locked() { return None; } - Some(&mut self.value) + Some(self.as_inner_unchecked_mut()) + } + + #[must_use] + #[inline] + pub const fn as_locked_or_mut(&mut self, force: bool) -> Option<&mut T> { + if force { + Some(self.as_inner_unchecked_mut()) + } else { + self.as_locked_mut() + } } + #[must_use] #[inline] - pub fn lock(&mut self) -> Option<&mut T> { - if self.locked { + pub fn into_locked(self) -> Option { + if !self.is_locked() { return None; } - self.locked = true; - Some(&mut self.value) + Some(self.value) } + #[must_use] #[inline] - pub fn force_lock(&mut self) -> &mut T { + pub const fn lock(&mut self) -> Option<&mut T> { + if self.is_locked() { + return None; + } self.locked = true; - &mut self.value + Some(self.as_inner_unchecked_mut()) } #[inline] - pub const fn is_unlocked(&self) -> bool { - !self.locked + pub const fn force_lock(&mut self) -> &mut T { + self.locked = true; + self.as_inner_unchecked_mut() } + #[must_use] #[inline] pub const fn as_unlocked(&self) -> Option<&T> { - if self.locked { + if self.is_locked() { return None; } - Some(&self.value) + Some(self.as_inner_unchecked()) } + #[must_use] #[inline] - pub fn as_unlocked_mut(&mut self) -> Option<&mut T> { - if self.locked { + pub const fn as_unlocked_mut(&mut self) -> Option<&mut T> { + if self.is_locked() { return None; } - Some(&mut self.value) + Some(self.as_inner_unchecked_mut()) } + #[must_use] #[inline] - pub fn unlock(&mut self) -> Option<&mut T> { - if !self.locked { + pub const fn as_unlocked_or_mut(&mut self, force: bool) -> Option<&mut T> { + if force { + Some(self.as_inner_unchecked_mut()) + } else { + self.as_unlocked_mut() + } + } + + #[must_use] + #[inline] + pub fn into_unlocked(self) -> Option { + if self.is_locked() { + return None; + } + Some(self.value) + } + + #[must_use] + #[inline] + pub const fn unlock(&mut self) -> Option<&mut T> { + if !self.is_locked() { return None; } self.locked = false; - Some(&mut self.value) + Some(self.as_inner_unchecked_mut()) } #[inline] - pub fn force_unlock(&mut self) -> &mut T { + pub const fn force_unlock(&mut self) -> &mut T { self.locked = false; - &mut self.value + self.as_inner_unchecked_mut() } } impl From for Lock { + #[inline] fn from(value: T) -> Self { Self::unlocked(value) } } + +impl BorshSerializeAs> for Lock +where + As: BorshSerializeAs, +{ + #[inline] + fn serialize_as(source: &Lock, writer: &mut W) -> io::Result<()> + where + W: io::Write, + { + Lock { + locked: source.locked, + value: AsWrap::<&T, &As>::new(&source.value), + } + .serialize(writer) + } +} + +impl BorshDeserializeAs> for Lock +where + As: BorshDeserializeAs, +{ + #[inline] + fn deserialize_as(reader: &mut R) -> io::Result> + where + R: io::Read, + { + Lock::>::deserialize_reader(reader).map(|v| Lock { + locked: v.locked, + value: v.value.into_inner(), + }) + } +} diff --git a/near-utils/src/panic_on_clone.rs b/near-utils/src/panic_on_clone.rs new file mode 100644 index 00000000..fe613291 --- /dev/null +++ b/near-utils/src/panic_on_clone.rs @@ -0,0 +1,65 @@ +use impl_tools::autoimpl; +use near_sdk::{env, near}; + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +#[autoimpl(Deref using self.0)] +#[autoimpl(DerefMut using self.0)] +#[autoimpl(AsRef using self.0)] +#[autoimpl(AsMut using self.0)] +#[near(serializers = [borsh])] +#[repr(transparent)] // needed for `transmute()` below +pub struct PanicOnClone(T); + +impl PanicOnClone { + #[inline] + pub const fn new(value: T) -> Self { + Self(value) + } + + #[inline] + pub const fn from_ref(value: &T) -> &Self { + // this is safe due to `#[repr(transparent)]` + unsafe { ::core::mem::transmute::<&T, &Self>(value) } + } + + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + +impl From for PanicOnClone { + fn from(value: T) -> Self { + Self::new(value) + } +} + +impl Clone for PanicOnClone { + #[track_caller] + fn clone(&self) -> Self { + env::panic_str("PanicOnClone") + } +} + +#[cfg(test)] +mod tests { + use core::ptr; + + use near_sdk::store::IterableMap; + + use super::*; + + #[test] + fn from_ref() { + let value = "example".to_string(); + let poc = PanicOnClone::from_ref(&value); + assert!(ptr::eq(&**poc, &value)); + assert_eq!(&**poc, &value); + } + + #[test] + #[should_panic] + fn panics_on_clone() { + let _ = PanicOnClone::new(IterableMap::<(), ()>::new(0)).clone(); + } +} diff --git a/randomness/Cargo.toml b/randomness/Cargo.toml index 42a2b592..5ee14361 100644 --- a/randomness/Cargo.toml +++ b/randomness/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "randomness" +name = "defuse-randomness" version.workspace = true edition.workspace = true repository.workspace = true diff --git a/test-utils/Cargo.toml b/test-utils/Cargo.toml index afea17bc..66b916c2 100644 --- a/test-utils/Cargo.toml +++ b/test-utils/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "test-utils" +name = "defuse-test-utils" version.workspace = true edition.workspace = true repository.workspace = true @@ -8,6 +8,7 @@ repository.workspace = true workspace = true [dependencies] -randomness.workspace = true +defuse-randomness.workspace = true + rand_chacha.workspace = true rstest.workspace = true diff --git a/test-utils/src/random.rs b/test-utils/src/random.rs index aa1e419f..4813ae59 100644 --- a/test-utils/src/random.rs +++ b/test-utils/src/random.rs @@ -1,5 +1,5 @@ +pub use defuse_randomness::{self as randomness, CryptoRng, Rng, SeedableRng, seq::IteratorRandom}; use rand_chacha::{ChaChaRng, rand_core::RngCore}; -pub use randomness::{self, CryptoRng, Rng, SeedableRng, seq::IteratorRandom}; use rstest::fixture; use std::{num::ParseIntError, str::FromStr}; diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 76538a80..3be31f40 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [dev-dependencies] defuse = { workspace = true, features = ["contract"] } defuse-poa-factory = { workspace = true, features = ["contract"] } +defuse-test-utils.workspace = true +defuse-randomness.workspace = true anyhow.workspace = true bnum = { workspace = true, features = ["rand"] } @@ -19,8 +21,6 @@ near-crypto.workspace = true near-sdk = { workspace = true, features = ["unit-testing"] } near-workspaces.workspace = true near-contract-standards.workspace = true -randomness.workspace = true rstest.workspace = true serde_json.workspace = true -test-utils.workspace = true tokio = { workspace = true, features = ["macros"] } diff --git a/tests/src/tests/defuse/accounts.rs b/tests/src/tests/defuse/accounts.rs index 8dd05bc1..e370955a 100644 --- a/tests/src/tests/defuse/accounts.rs +++ b/tests/src/tests/defuse/accounts.rs @@ -1,7 +1,28 @@ -use defuse::core::crypto::PublicKey; -use near_sdk::{AccountId, NearToken}; +use std::time::Duration; + +use defuse::{ + contract::Role, + core::{ + Deadline, + crypto::PublicKey, + intents::{ + DefuseIntents, + tokens::{TokenAmounts, Transfer}, + }, + tokens::TokenId, + }, +}; +use defuse_randomness::Rng; +use defuse_test_utils::random::{Seed, make_seedable_rng, random_seed}; +use near_sdk::{AccountId, AccountIdRef, NearToken}; +use rstest::rstest; use serde_json::json; +use crate::{ + tests::defuse::{DefuseSigner, env::Env, intents::ExecuteIntentsExt}, + utils::mt::MtExt, +}; + pub trait AccountManagerExt { async fn add_public_key( &self, @@ -9,6 +30,12 @@ pub trait AccountManagerExt { public_key: PublicKey, ) -> anyhow::Result<()>; + async fn remove_public_key( + &self, + defuse_contract_id: &AccountId, + public_key: PublicKey, + ) -> anyhow::Result<()>; + async fn defuse_has_public_key( &self, defuse_contract_id: &AccountId, @@ -29,7 +56,6 @@ impl AccountManagerExt for near_workspaces::Account { defuse_contract_id: &AccountId, public_key: PublicKey, ) -> anyhow::Result<()> { - // TODO: check bool output self.call(defuse_contract_id, "add_public_key") .deposit(NearToken::from_yoctonear(1)) .args_json(json!({ @@ -42,6 +68,23 @@ impl AccountManagerExt for near_workspaces::Account { Ok(()) } + async fn remove_public_key( + &self, + defuse_contract_id: &AccountId, + public_key: PublicKey, + ) -> anyhow::Result<()> { + self.call(defuse_contract_id, "remove_public_key") + .deposit(NearToken::from_yoctonear(1)) + .args_json(json!({ + "public_key": public_key, + })) + .max_gas() + .transact() + .await? + .into_result()?; + Ok(()) + } + async fn defuse_has_public_key( &self, defuse_contract_id: &AccountId, @@ -79,6 +122,16 @@ impl AccountManagerExt for near_workspaces::Contract { .await } + async fn remove_public_key( + &self, + defuse_contract_id: &AccountId, + public_key: PublicKey, + ) -> anyhow::Result<()> { + self.as_account() + .remove_public_key(defuse_contract_id, public_key) + .await + } + async fn defuse_has_public_key( &self, defuse_contract_id: &AccountId, @@ -100,3 +153,360 @@ impl AccountManagerExt for near_workspaces::Contract { .await } } + +pub trait AccountForceLockerExt { + async fn is_account_locked(&self, account_id: &AccountIdRef) -> anyhow::Result; + async fn defuse_is_account_locked( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result; + + async fn force_lock_account(&self, account_id: &AccountIdRef) -> anyhow::Result; + async fn defuse_force_lock_account( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result; + async fn force_unlock_account(&self, account_id: &AccountIdRef) -> anyhow::Result; + async fn defuse_force_unlock_account( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result; +} + +impl AccountForceLockerExt for near_workspaces::Account { + async fn is_account_locked(&self, account_id: &AccountIdRef) -> anyhow::Result { + self.defuse_is_account_locked(self.id(), account_id).await + } + + async fn defuse_is_account_locked( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result { + self.view(defuse_contract_id, "is_account_locked") + .args_json(json!({ + "account_id": account_id, + })) + .await? + .json() + .map_err(Into::into) + } + + async fn force_lock_account(&self, account_id: &AccountIdRef) -> anyhow::Result { + self.defuse_force_lock_account(self.id(), account_id).await + } + + async fn defuse_force_lock_account( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result { + self.call(defuse_contract_id, "force_lock_account") + .args_json(json!({ + "account_id": account_id, + })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await? + .into_result()? + .json() + .map_err(Into::into) + } + + async fn force_unlock_account(&self, account_id: &AccountIdRef) -> anyhow::Result { + self.defuse_force_unlock_account(self.id(), account_id) + .await + } + + async fn defuse_force_unlock_account( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result { + self.call(defuse_contract_id, "force_unlock_account") + .args_json(json!({ + "account_id": account_id, + })) + .deposit(NearToken::from_yoctonear(1)) + .max_gas() + .transact() + .await? + .into_result()? + .json() + .map_err(Into::into) + } +} + +impl AccountForceLockerExt for near_workspaces::Contract { + async fn is_account_locked(&self, account_id: &AccountIdRef) -> anyhow::Result { + self.as_account().is_account_locked(account_id).await + } + + async fn defuse_is_account_locked( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result { + self.as_account() + .defuse_is_account_locked(defuse_contract_id, account_id) + .await + } + + async fn force_lock_account(&self, account_id: &AccountIdRef) -> anyhow::Result { + self.as_account().force_lock_account(account_id).await + } + + async fn defuse_force_lock_account( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result { + self.as_account() + .defuse_force_lock_account(defuse_contract_id, account_id) + .await + } + + async fn force_unlock_account(&self, account_id: &AccountIdRef) -> anyhow::Result { + self.as_account().force_unlock_account(account_id).await + } + + async fn defuse_force_unlock_account( + &self, + defuse_contract_id: &AccountId, + account_id: &AccountIdRef, + ) -> anyhow::Result { + self.as_account() + .defuse_force_unlock_account(defuse_contract_id, account_id) + .await + } +} + +#[tokio::test] +#[rstest] +#[trace] +async fn test_lock_account(random_seed: Seed) { + let mut rng = make_seedable_rng(random_seed); + + let env = Env::builder() + .self_as_grantee(Role::UnrestrictedAccountLocker) + .self_as_grantee(Role::UnrestrictedAccountUnlocker) + .build() + .await; + + let ft1 = TokenId::Nep141(env.ft1.clone()); + env.defuse_ft_deposit_to(&env.ft1, 1000, env.user1.id()) + .await + .unwrap(); + + // lock + { + assert!( + env.defuse + .force_lock_account(env.user2.id()) + .await + .expect("unable to lock an account") + ); + assert!( + env.defuse.is_account_locked(env.user2.id()).await.unwrap(), + "account wasn't locked" + ); + assert!( + !env.defuse + .force_lock_account(env.user2.id()) + .await + .expect("unable to lock already locked account"), + "second attempt to lock an account should not fail, but return `false`" + ); + + env.defuse_ft_deposit_to(&env.ft1, 250, env.user2.id()) + .await + .expect("deposits should be allowed for locked account"); + + env.user1 + .mt_transfer( + env.defuse.id(), + env.user2.id(), + &ft1.to_string(), + 300, + None, + None, + ) + .await + .expect("locked accounts should be allowed to accept incoming transfers"); + + env.defuse + .execute_intents([env.user1.sign_defuse_message( + env.defuse.id(), + rng.random(), + Deadline::timeout(Duration::from_secs(120)), + DefuseIntents { + intents: [Transfer { + receiver_id: env.user2.id().clone(), + tokens: TokenAmounts::default().with_add(ft1.clone(), 100).unwrap(), + memo: None, + } + .into()] + .into(), + }, + )]) + .await + .expect("locked accounts should be allowed to accept incoming transfers"); + + assert_eq!( + env.defuse + .mt_balance_of( + env.user1.id(), + &TokenId::Nep141(env.ft1.clone()).to_string() + ) + .await + .unwrap(), + 600, + ); + assert_eq!( + env.defuse + .mt_balance_of( + env.user2.id(), + &TokenId::Nep141(env.ft1.clone()).to_string() + ) + .await + .unwrap(), + 650, // 250 + 300 + 100 + "locked account balance mismatch" + ); + + env.user2 + .mt_transfer( + env.defuse.id(), + env.user3.id(), + &ft1.to_string(), + 100, + None, + None, + ) + .await + .expect_err("locked accounts should not be able to make outgoing transfers"); + + env.defuse + .execute_intents([env.user2.sign_defuse_message( + env.defuse.id(), + rng.random(), + Deadline::timeout(Duration::from_secs(120)), + DefuseIntents { + intents: [Transfer { + receiver_id: env.user3.id().clone(), + tokens: TokenAmounts::default().with_add(ft1.clone(), 200).unwrap(), + memo: None, + } + .into()] + .into(), + }, + )]) + .await + .expect_err("locked accounts should not be able to make outgoing transfers"); + + assert_eq!( + env.defuse + .mt_balance_of( + env.user2.id(), + &TokenId::Nep141(env.ft1.clone()).to_string() + ) + .await + .unwrap(), + 650, // 250 + 300 + 100 + "balance of locked account should stay the same" + ); + + env.user2 + .add_public_key(env.defuse.id(), PublicKey::Ed25519(rng.random())) + .await + .expect_err("locked accounts should not be able to add new public keys"); + env.user2 + .remove_public_key( + env.defuse.id(), + env.user1 + .secret_key() + .public_key() + .to_string() + .parse() + .unwrap(), + ) + .await + .expect_err("locked accounts should not be able to remove public keys"); + } + + // unlock + { + assert!( + env.defuse + .force_unlock_account(env.user2.id()) + .await + .expect("unable to unlock a locked account") + ); + assert!( + !env.defuse.is_account_locked(env.user2.id()).await.unwrap(), + "account wasn't unlocked" + ); + assert!( + !env.defuse + .force_unlock_account(env.user2.id()) + .await + .expect("unable to unlock already unlocked account"), + "second attempt to unlock already unlocked account should not fail, but return `false`" + ); + + env.user2 + .mt_transfer( + env.defuse.id(), + env.user3.id(), + &ft1.to_string(), + 100, + None, + None, + ) + .await + .expect("unlocked account should be allowed to transfer"); + + env.defuse + .execute_intents([env.user2.sign_defuse_message( + env.defuse.id(), + rng.random(), + Deadline::timeout(Duration::from_secs(120)), + DefuseIntents { + intents: [Transfer { + receiver_id: env.user3.id().clone(), + tokens: TokenAmounts::default().with_add(ft1.clone(), 200).unwrap(), + memo: None, + } + .into()] + .into(), + }, + )]) + .await + .expect("unlocked account should be allowed to transfer"); + + assert_eq!( + env.defuse + .mt_balance_of( + env.user2.id(), + &TokenId::Nep141(env.ft1.clone()).to_string() + ) + .await + .unwrap(), + 350, // 250 + 300 + 100 - 100 - 200 + ); + assert_eq!( + env.defuse + .mt_balance_of( + env.user3.id(), + &TokenId::Nep141(env.ft1.clone()).to_string() + ) + .await + .unwrap(), + 300, // 100 + 200 + ); + } +} diff --git a/tests/src/tests/defuse/env.rs b/tests/src/tests/defuse/env.rs index d70f622d..0721c1a6 100644 --- a/tests/src/tests/defuse/env.rs +++ b/tests/src/tests/defuse/env.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use std::{ops::Deref, sync::LazyLock}; +use std::{collections::HashSet, ops::Deref, sync::LazyLock}; use anyhow::anyhow; use defuse::{ @@ -149,8 +149,14 @@ pub struct EnvBuilder { // roles roles: RolesConfig, + self_as_admin: HashSet, + self_as_grantee: HashSet, self_as_super_admin: bool, + + deployer_as_admin: HashSet, + deployer_as_grantee: HashSet, deployer_as_super_admin: bool, + disable_ft_storage_deposit: bool, disable_registration: bool, } @@ -171,28 +177,48 @@ impl EnvBuilder { self } + pub fn admin(mut self, role: Role, admin: AccountId) -> Self { + self.roles.admins.entry(role).or_default().insert(admin); + self + } + + pub fn grantee(mut self, role: Role, grantee: AccountId) -> Self { + self.roles.grantees.entry(role).or_default().insert(grantee); + self + } + + pub fn self_as_admin(mut self, role: Role) -> Self { + self.self_as_admin.insert(role); + self + } + + pub fn self_as_grantee(mut self, role: Role) -> Self { + self.self_as_grantee.insert(role); + self + } + pub fn self_as_super_admin(mut self) -> Self { self.self_as_super_admin = true; self } - pub fn deployer_as_super_admin(mut self) -> Self { - self.deployer_as_super_admin = true; + pub fn deployer_as_admin(mut self, role: Role) -> Self { + self.deployer_as_admin.insert(role); self } - pub fn disable_ft_storage_deposit(mut self) -> Self { - self.disable_ft_storage_deposit = true; + pub fn deployer_as_grantee(mut self, role: Role) -> Self { + self.deployer_as_grantee.insert(role); self } - pub fn admin(mut self, role: Role, admin: AccountId) -> Self { - self.roles.admins.entry(role).or_default().insert(admin); + pub fn deployer_as_super_admin(mut self) -> Self { + self.deployer_as_super_admin = true; self } - pub fn grantee(mut self, role: Role, grantee: AccountId) -> Self { - self.roles.grantees.entry(role).or_default().insert(grantee); + pub fn disable_ft_storage_deposit(mut self) -> Self { + self.disable_ft_storage_deposit = true; self } @@ -228,17 +254,33 @@ impl EnvBuilder { let wnear = sandbox.deploy_wrap_near("wnear").await.unwrap(); - if self.self_as_super_admin { + let defuse_contract_id: AccountId = format!("defuse.{}", root.id()).parse().unwrap(); + + for role in self.self_as_admin { + self.roles + .admins + .entry(role) + .or_default() + .insert(defuse_contract_id.clone()); + } + + for role in self.self_as_grantee { self.roles - .super_admins - .insert(format!("defuse.{}", root.id()).parse().unwrap()); + .grantees + .entry(role) + .or_default() + .insert(defuse_contract_id.clone()); + } + + if self.self_as_super_admin { + self.roles.super_admins.insert(defuse_contract_id); } if self.deployer_as_super_admin { self.roles.super_admins.insert(root.id().clone()); } - let env_result = Env { + let env = Env { user1: sandbox.create_account("user1").await, user2: sandbox.create_account("user2").await, user3: sandbox.create_account("user3").await, @@ -273,62 +315,60 @@ impl EnvBuilder { sandbox, }; - env_result - .near_deposit(env_result.wnear.id(), NearToken::from_near(100)) + env.near_deposit(env.wnear.id(), NearToken::from_near(100)) .await .unwrap(); if !self.disable_ft_storage_deposit { - env_result - .ft_storage_deposit( - env_result.wnear.id(), - &[ - env_result.user1.id(), - env_result.user2.id(), - env_result.user3.id(), - env_result.defuse.id(), - root.id(), - ], - ) - .await - .unwrap(); + env.ft_storage_deposit( + env.wnear.id(), + &[ + env.user1.id(), + env.user2.id(), + env.user3.id(), + env.defuse.id(), + root.id(), + ], + ) + .await + .unwrap(); - poa_factory + env.poa_factory .ft_storage_deposit_many( - &env_result.ft1, + &env.ft1, &[ - env_result.user1.id(), - env_result.user2.id(), - env_result.user3.id(), - env_result.defuse.id(), + env.user1.id(), + env.user2.id(), + env.user3.id(), + env.defuse.id(), root.id(), ], ) .await .unwrap(); - poa_factory + env.poa_factory .ft_storage_deposit_many( - &env_result.ft2, + &env.ft2, &[ - env_result.user1.id(), - env_result.user2.id(), - env_result.user3.id(), - env_result.defuse.id(), + env.user1.id(), + env.user2.id(), + env.user3.id(), + env.defuse.id(), root.id(), ], ) .await .unwrap(); - poa_factory + env.poa_factory .ft_storage_deposit_many( - &env_result.ft3, + &env.ft3, &[ - env_result.user1.id(), - env_result.user2.id(), - env_result.user3.id(), - env_result.defuse.id(), + env.user1.id(), + env.user2.id(), + env.user3.id(), + env.defuse.id(), root.id(), ], ) @@ -337,27 +377,24 @@ impl EnvBuilder { } for token in ["ft1", "ft2", "ft3"] { - env_result - .poa_factory_ft_deposit( - env_result.poa_factory.id(), - token, - root.id(), - 1_000_000_000, - None, - None, - ) - .await - .unwrap(); + env.poa_factory_ft_deposit( + env.poa_factory.id(), + token, + root.id(), + 1_000_000_000, + None, + None, + ) + .await + .unwrap(); } // NOTE: near_workspaces uses the same signer all subaccounts - env_result - .user1 + env.user1 .add_public_key( - env_result.defuse.id(), + env.defuse.id(), // HACK: near_worspaces does not expose near_crypto API - env_result - .user1 + env.user1 .secret_key() .public_key() .to_string() @@ -367,12 +404,10 @@ impl EnvBuilder { .await .unwrap(); - env_result - .user2 + env.user2 .add_public_key( - env_result.defuse.id(), - env_result - .user2 + env.defuse.id(), + env.user2 .secret_key() .public_key() .to_string() @@ -382,12 +417,10 @@ impl EnvBuilder { .await .unwrap(); - env_result - .user3 + env.user3 .add_public_key( - env_result.defuse.id(), - env_result - .user3 + env.defuse.id(), + env.user3 .secret_key() .public_key() .to_string() @@ -398,14 +431,13 @@ impl EnvBuilder { .unwrap(); if self.disable_registration { - let root_secret_key = env_result.sandbox.root_account().secret_key(); + let root_secret_key = env.sandbox.root_account().secret_key(); let root_access_key = root_secret_key.public_key(); - let worker = env_result.sandbox.worker().clone(); + let worker = env.sandbox.worker().clone(); - for ft in [&env_result.ft1, &env_result.ft2, &env_result.ft3] { - env_result - .poa_factory + for ft in [&env.ft1, &env.ft2, &env.ft3] { + env.poa_factory .as_account() .batch(ft) .call( @@ -431,6 +463,6 @@ impl EnvBuilder { } } - env_result + env } } diff --git a/tests/src/tests/defuse/intents/ft_withdraw.rs b/tests/src/tests/defuse/intents/ft_withdraw.rs index e0f46415..9308c533 100644 --- a/tests/src/tests/defuse/intents/ft_withdraw.rs +++ b/tests/src/tests/defuse/intents/ft_withdraw.rs @@ -9,11 +9,11 @@ use defuse::{ tokens::TokenId, }, }; +use defuse_randomness::Rng; +use defuse_test_utils::random::make_seedable_rng; +use defuse_test_utils::random::{Seed, random_seed}; use near_sdk::{AccountId, NearToken}; -use randomness::Rng; use rstest::rstest; -use test_utils::random::make_seedable_rng; -use test_utils::random::{Seed, random_seed}; use super::ExecuteIntentsExt; use crate::{ diff --git a/tests/src/tests/defuse/intents/mod.rs b/tests/src/tests/defuse/intents/mod.rs index 480061f2..65fb4f05 100644 --- a/tests/src/tests/defuse/intents/mod.rs +++ b/tests/src/tests/defuse/intents/mod.rs @@ -7,8 +7,8 @@ use defuse::{ }, intents::SimulationOutput, }; +use defuse_randomness::{Rng, make_true_rng}; use near_sdk::{AccountId, AccountIdRef}; -use randomness::{Rng, make_true_rng}; use rstest::rstest; use serde_json::json; diff --git a/tests/src/tests/defuse/intents/relayers.rs b/tests/src/tests/defuse/intents/relayers.rs index 1a5194ad..30f9599d 100644 --- a/tests/src/tests/defuse/intents/relayers.rs +++ b/tests/src/tests/defuse/intents/relayers.rs @@ -1,9 +1,9 @@ use defuse::contract::Role; +use defuse_test_utils::asserts::ResultAssertsExt; use near_sdk::{AccountId, NearToken, PublicKey}; use near_workspaces::{Account, types::SecretKey}; use rstest::rstest; use serde_json::json; -use test_utils::asserts::ResultAssertsExt; use crate::{ tests::defuse::{env::Env, intents::ExecuteIntentsExt}, diff --git a/tests/src/tests/defuse/intents/token_diff.rs b/tests/src/tests/defuse/intents/token_diff.rs index 9491dfd2..b3076b62 100644 --- a/tests/src/tests/defuse/intents/token_diff.rs +++ b/tests/src/tests/defuse/intents/token_diff.rs @@ -10,9 +10,9 @@ use defuse::core::{ payload::multi::MultiPayload, tokens::TokenId, }; +use defuse_randomness::{Rng, make_true_rng}; use near_sdk::AccountId; use near_workspaces::Account; -use randomness::{Rng, make_true_rng}; use rstest::rstest; use crate::{ diff --git a/tests/src/tests/defuse/storage/mod.rs b/tests/src/tests/defuse/storage/mod.rs index a87730cd..6cb699fd 100644 --- a/tests/src/tests/defuse/storage/mod.rs +++ b/tests/src/tests/defuse/storage/mod.rs @@ -4,11 +4,11 @@ use crate::{ }; use defuse::core::Deadline; use defuse::core::intents::{DefuseIntents, tokens::StorageDeposit}; +use defuse_randomness::Rng; +use defuse_test_utils::random::random_seed; +use defuse_test_utils::random::{Seed, make_seedable_rng}; use near_sdk::NearToken; -use randomness::Rng; use rstest::rstest; -use test_utils::random::random_seed; -use test_utils::random::{Seed, make_seedable_rng}; const MIN_FT_STORAGE_DEPOSIT_VALUE: NearToken = NearToken::from_yoctonear(1_250_000_000_000_000_000_000); diff --git a/tests/src/tests/defuse/tokens/nep141.rs b/tests/src/tests/defuse/tokens/nep141.rs index 046a5062..40b442de 100644 --- a/tests/src/tests/defuse/tokens/nep141.rs +++ b/tests/src/tests/defuse/tokens/nep141.rs @@ -9,8 +9,8 @@ use defuse::{ }, tokens::DepositMessage, }; +use defuse_randomness::{Rng, make_true_rng}; use near_sdk::{AccountId, NearToken, json_types::U128}; -use randomness::{Rng, make_true_rng}; use rstest::rstest; use serde_json::json; diff --git a/tests/src/tests/defuse/upgrade.rs b/tests/src/tests/defuse/upgrade.rs index bbf6ba72..abf31ebf 100644 --- a/tests/src/tests/defuse/upgrade.rs +++ b/tests/src/tests/defuse/upgrade.rs @@ -1,12 +1,12 @@ use defuse::core::crypto::PublicKey; +use defuse_randomness::{Rng, make_true_rng}; use near_sdk::AccountId; -use randomness::{Rng, make_true_rng}; use crate::{tests::defuse::accounts::AccountManagerExt, utils::mt::MtExt}; use super::DEFUSE_WASM; -#[ignore = "only for simple upgrades"] +#[ignore = "only for simple upgrades with state size <50Kb"] #[tokio::test] async fn test_upgrade() { let old_contract_id: AccountId = "intents.near".parse().unwrap(); @@ -18,14 +18,15 @@ async fn test_upgrade() { let sandbox = near_workspaces::sandbox().await.unwrap(); let new_contract = sandbox .import_contract(&old_contract_id, &mainnet) - .with_data() + .with_data() // large state results into errors... .transact() .await .unwrap(); new_contract - .as_account() + .batch() .deploy(&DEFUSE_WASM) + .transact() .await .unwrap() .into_result() diff --git a/tests/src/tests/utils.rs b/tests/src/tests/utils.rs index cb1c9655..7adfa1e7 100644 --- a/tests/src/tests/utils.rs +++ b/tests/src/tests/utils.rs @@ -1,8 +1,8 @@ use defuse::core::fees::Pips; +use defuse_randomness::Rng; +use defuse_test_utils::random::{Seed, make_seedable_rng, random_seed}; use near_sdk::borsh; -use randomness::Rng; use rstest::rstest; -use test_utils::random::{Seed, make_seedable_rng, random_seed}; #[rstest] #[trace]