diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeddbc80a..6966a73f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: with: cache: false - name: Install cargo-audit - run: cargo install cargo-audit --version "^0.21" --locked + run: cargo install cargo-audit --version "^0.22" --locked - uses: rustsec/audit-check@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -188,7 +188,8 @@ jobs: - name: Install Cargo Plugins run: cargo install cargo-audit --locked - name: Run security audit - run: cargo audit --deny unsound --deny yanked + # TODO: ignoring lru vulnerability in near-sdk, remove when fixed + run: cargo audit --deny unsound --deny yanked --ignore RUSTSEC-2026-0002 # run: cargo audit --deny warnings # Warnings include: unmaintained, unsound and yanked contract_analysis: diff --git a/Makefile.toml b/Makefile.toml index a2dc45bab..a267e598e 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -40,7 +40,7 @@ args = [ "--manifest-path", "./defuse/Cargo.toml", "--features", - "abi,contract", + "abi,contract,imt", "--out-dir", "${TARGET_DIR}", "--no-embed-abi", diff --git a/core/Cargo.toml b/core/Cargo.toml index c92688ad0..2a07d2051 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -55,6 +55,7 @@ arbitrary = [ "defuse-token-id/arbitrary", "defuse-ton-connect/arbitrary", ] +imt = ["defuse-token-id/imt"] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] chrono = { workspace = true, features = ["now"] } diff --git a/core/src/engine/state/cached.rs b/core/src/engine/state/cached.rs index a87b8b920..715c76a7c 100644 --- a/core/src/engine/state/cached.rs +++ b/core/src/engine/state/cached.rs @@ -374,6 +374,21 @@ where Ok(()) } + + #[inline] + fn mint(&mut self, owner_id: AccountId, tokens: Amounts, _memo: Option) -> Result<()> { + self.internal_add_balance(owner_id, tokens) + } + + #[inline] + fn burn( + &mut self, + owner_id: &AccountIdRef, + tokens: Amounts, + _memo: Option, + ) -> Result<()> { + self.internal_sub_balance(owner_id, tokens) + } } #[derive(Debug, Default)] diff --git a/core/src/engine/state/deltas.rs b/core/src/engine/state/deltas.rs index 4809dc6fb..08030d7c1 100644 --- a/core/src/engine/state/deltas.rs +++ b/core/src/engine/state/deltas.rs @@ -213,6 +213,21 @@ where fn auth_call(&mut self, signer_id: &AccountIdRef, auth_call: AuthCall) -> Result<()> { self.state.auth_call(signer_id, auth_call) } + + #[inline] + fn mint(&mut self, owner_id: AccountId, tokens: Amounts, memo: Option) -> Result<()> { + self.state.mint(owner_id, tokens, memo) + } + + #[inline] + fn burn( + &mut self, + owner_id: &AccountIdRef, + tokens: Amounts, + memo: Option, + ) -> Result<()> { + self.state.burn(owner_id, tokens, memo) + } } /// Accumulates internal deposits and withdrawals on different tokens diff --git a/core/src/engine/state/mod.rs b/core/src/engine/state/mod.rs index 9eb933df5..edfeab79b 100644 --- a/core/src/engine/state/mod.rs +++ b/core/src/engine/state/mod.rs @@ -127,4 +127,13 @@ pub trait State: StateView { fn set_auth_by_predecessor_id(&mut self, account_id: AccountId, enable: bool) -> Result; fn auth_call(&mut self, signer_id: &AccountIdRef, auth_call: AuthCall) -> Result<()>; + + fn mint(&mut self, owner_id: AccountId, tokens: Amounts, memo: Option) -> Result<()>; + + fn burn( + &mut self, + owner_id: &AccountIdRef, + tokens: Amounts, + memo: Option, + ) -> Result<()>; } diff --git a/core/src/error.rs b/core/src/error.rs index 9c3efe2c2..46f4fa182 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -1,5 +1,6 @@ use crate::{ engine::deltas::InvariantViolated, + intents::tokens::MAX_TOKEN_ID_LEN, token_id::{TokenId, TokenIdError, nep171::Nep171TokenId}, }; use defuse_crypto::PublicKey; @@ -75,4 +76,7 @@ pub enum DefuseError { #[error("maximum attempts to generate a new salt reached")] SaltGenerationFailed, + + #[error("token_id is too long: max length is {MAX_TOKEN_ID_LEN}, got {0}")] + TokenIdTooLarge(usize), } diff --git a/core/src/events.rs b/core/src/events.rs index b999cafb7..c1b100e7a 100644 --- a/core/src/events.rs +++ b/core/src/events.rs @@ -54,6 +54,17 @@ pub enum DefuseEvent<'a> { #[event_version("0.3.0")] StorageDeposit(Cow<'a, [IntentEvent>>]>), + #[cfg(feature = "imt")] + #[event_version("0.3.0")] + ImtMint( + Cow<'a, [IntentEvent>>]>, + ), + + #[cfg(feature = "imt")] + #[event_version("0.3.0")] + ImtBurn( + Cow<'a, [IntentEvent>>]>, + ), #[event_version("0.3.0")] #[from(skip)] AccountLocked(AccountEvent<'a, ()>), diff --git a/core/src/intents/mod.rs b/core/src/intents/mod.rs index 3debe3d02..65611365f 100644 --- a/core/src/intents/mod.rs +++ b/core/src/intents/mod.rs @@ -68,6 +68,14 @@ pub enum Intent { /// See [`AuthCall`] AuthCall(AuthCall), + + // See [`ImtMint`] + #[cfg(feature = "imt")] + ImtMint(crate::intents::tokens::imt::ImtMint), + + // See [`ImtBurn`] + #[cfg(feature = "imt")] + ImtBurn(crate::intents::tokens::imt::ImtBurn), } pub trait ExecutableIntent { @@ -125,6 +133,10 @@ impl ExecutableIntent for Intent { intent.execute_intent(signer_id, engine, intent_hash) } Self::AuthCall(intent) => intent.execute_intent(signer_id, engine, intent_hash), + #[cfg(feature = "imt")] + Self::ImtMint(intent) => intent.execute_intent(signer_id, engine, intent_hash), + #[cfg(feature = "imt")] + Self::ImtBurn(intent) => intent.execute_intent(signer_id, engine, intent_hash), } } } diff --git a/core/src/intents/token_diff.rs b/core/src/intents/token_diff.rs index 8b92d81b4..60b29e55b 100644 --- a/core/src/intents/token_diff.rs +++ b/core/src/intents/token_diff.rs @@ -206,9 +206,14 @@ impl TokenDiff { match token_id { TokenIdType::Nep141 => {} TokenIdType::Nep245 if amount > 1 => {} - // do not take fees on NFTs and MTs with |delta| <= 1 TokenIdType::Nep171 | TokenIdType::Nep245 => return Pips::ZERO, + #[cfg(feature = "imt")] + TokenIdType::Imt => { + if amount <= 1 { + return Pips::ZERO; + } + } } fee } diff --git a/core/src/intents/tokens.rs b/core/src/intents/tokens.rs index 3ede44bad..16726a3f7 100644 --- a/core/src/intents/tokens.rs +++ b/core/src/intents/tokens.rs @@ -17,6 +17,11 @@ use crate::{ use super::{ExecutableIntent, IntentEvent}; +pub const MAX_TOKEN_ID_LEN: usize = 127; + +const MT_ON_TRANSFER_GAS_MIN: Gas = Gas::from_tgas(5); +const MT_ON_TRANSFER_GAS_DEFAULT: Gas = Gas::from_tgas(30); + #[must_use] #[near(serializers = [borsh, json])] #[derive(Debug, Clone)] @@ -73,17 +78,12 @@ pub struct Transfer { /// Optionally notify receiver_id via `mt_on_transfer()` /// /// NOTE: `min_gas` is adjusted with following values: - /// * default: 30TGas /// * minimum: 5TGas + /// * default: 30TGas #[serde(flatten, default, skip_serializing_if = "Option::is_none")] pub notification: Option, } -impl Transfer { - pub const MT_ON_TRANSFER_GAS_MIN: Gas = Gas::from_tgas(5); - pub const MT_ON_TRANSFER_GAS_DEFAULT: Gas = Gas::from_tgas(30); -} - impl ExecutableIntent for Transfer { fn execute_intent( self, @@ -127,9 +127,10 @@ impl ExecutableIntent for Transfer { notification.min_gas = Some( notification .min_gas - .unwrap_or(Self::MT_ON_TRANSFER_GAS_DEFAULT) - .max(Self::MT_ON_TRANSFER_GAS_MIN), + .unwrap_or(MT_ON_TRANSFER_GAS_DEFAULT) + .max(MT_ON_TRANSFER_GAS_MIN), ); + engine .state .notify_on_transfer(sender_id, self.receiver_id, self.tokens, notification); @@ -516,3 +517,172 @@ impl ExecutableIntent for StorageDeposit { engine.state.storage_deposit(owner_id, self) } } + +#[cfg(feature = "imt")] +pub mod imt { + use super::{MT_ON_TRANSFER_GAS_DEFAULT, MT_ON_TRANSFER_GAS_MIN}; + use crate::{Result, intents::tokens::MAX_TOKEN_ID_LEN}; + use defuse_token_id::TokenId; + use near_sdk::{AccountId, AccountIdRef, CryptoHash, near}; + use serde_with::{DisplayFromStr, serde_as}; + + use std::{borrow::Cow, collections::BTreeMap}; + + use crate::{ + DefuseError, + accounts::AccountEvent, + amounts::Amounts, + engine::{Engine, Inspector, State}, + events::DefuseEvent, + intents::{ExecutableIntent, IntentEvent, tokens::NotifyOnTransfer}, + }; + + pub type ImtTokens = Amounts>; + + impl ImtTokens { + #[inline] + fn into_generic_tokens( + self, + minter_id: &AccountIdRef, + ) -> Result>> { + let tokens = self + .into_iter() + .map(|(token_id, amount)| { + if token_id.len() > MAX_TOKEN_ID_LEN { + return Err(DefuseError::TokenIdTooLarge(token_id.len())); + } + + let token = defuse_token_id::imt::ImtTokenId::new(minter_id, token_id).into(); + + Ok((token, amount)) + }) + .collect::>()?; + + Ok(Amounts::new(tokens)) + } + } + + #[near(serializers = [borsh, json])] + #[derive(Debug, Clone)] + /// Mint a set of tokens from the signer to a specified account id, within the intents contract. + pub struct ImtMint { + /// Receiver of the minted tokens + pub receiver_id: AccountId, + + /// The token_ids will be wrapped to bind the token ID to the + /// minter authority (i.e. signer of this intent). + /// The final string representation of the token will be as follows: + /// `imt::` + #[serde_as(as = "Amounts>")] + pub tokens: ImtTokens, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memo: Option, + + /// Optionally notify receiver_id via `mt_on_transfer()` + /// + /// NOTE: `min_gas` is adjusted with following values: + /// * minimum: 5TGas + /// * default: 30TGas + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub notification: Option, + } + + impl ExecutableIntent for ImtMint { + #[inline] + fn execute_intent( + self, + signer_id: &AccountIdRef, + engine: &mut Engine, + intent_hash: CryptoHash, + ) -> Result<()> + where + S: State, + I: Inspector, + { + if self.tokens.is_empty() { + return Err(DefuseError::InvalidIntent); + } + + engine + .inspector + .on_event(DefuseEvent::ImtMint(Cow::Borrowed( + [IntentEvent::new( + AccountEvent::new(signer_id, Cow::Borrowed(&self)), + intent_hash, + )] + .as_slice(), + ))); + + let tokens = self.tokens.into_generic_tokens(signer_id)?; + engine + .state + .mint(self.receiver_id.clone(), tokens.clone(), self.memo)?; + + if let Some(mut notification) = self.notification { + notification.min_gas = Some( + notification + .min_gas + .unwrap_or(MT_ON_TRANSFER_GAS_DEFAULT) + .max(MT_ON_TRANSFER_GAS_MIN), + ); + + engine + .state + .notify_on_transfer(signer_id, self.receiver_id, tokens, notification); + } + + Ok(()) + } + } + + #[near(serializers = [borsh, json])] + #[derive(Debug, Clone)] + /// Burn a set of imt tokens, within the intents contract. + pub struct ImtBurn { + // The minter authority of the imt tokens + pub minter_id: AccountId, + + /// The token_ids will be wrapped to bind the token ID to the + /// minter authority. The final string representation of the + /// token will be as follows: + /// `imt::` + #[serde_as(as = "Amounts>")] + pub tokens: ImtTokens, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memo: Option, + } + + impl ExecutableIntent for ImtBurn { + #[inline] + fn execute_intent( + self, + signer_id: &AccountIdRef, + engine: &mut Engine, + intent_hash: CryptoHash, + ) -> Result<()> + where + S: State, + I: Inspector, + { + if self.tokens.is_empty() { + return Err(DefuseError::InvalidIntent); + } + + engine + .inspector + .on_event(DefuseEvent::ImtBurn(Cow::Borrowed( + [IntentEvent::new( + AccountEvent::new(signer_id, Cow::Borrowed(&self)), + intent_hash, + )] + .as_slice(), + ))); + + let tokens = self.tokens.into_generic_tokens(&self.minter_id)?; + + engine.state.burn(signer_id, tokens, self.memo) + } + } +} diff --git a/defuse/Cargo.toml b/defuse/Cargo.toml index baad2c447..13fcaed81 100644 --- a/defuse/Cargo.toml +++ b/defuse/Cargo.toml @@ -51,7 +51,7 @@ container_build_command = [ "build", "non-reproducible-wasm", "--locked", - "--features=abi,contract", + "--features=abi,contract,imt", "--no-embed-abi", ] @@ -72,6 +72,7 @@ sandbox = [ "dep:defuse-sandbox", "dep:defuse-test-utils", ] +imt = ["defuse-core/imt"] [dev-dependencies] defuse-core = { workspace = true, features = ["arbitrary"] } diff --git a/defuse/src/accounts.rs b/defuse/src/accounts.rs index b52123945..24681b1a3 100644 --- a/defuse/src/accounts.rs +++ b/defuse/src/accounts.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use defuse_core::{Nonce, crypto::PublicKey}; use defuse_serde_utils::base64::AsBase64; @@ -82,4 +82,15 @@ pub trait ForceAccountManager: AccessControllable { /// /// NOTE: MUST attach 1 yⓃ for security purposes. fn force_enable_auth_by_predecessor_ids(&mut self, account_ids: Vec); + + /// Registers or re-activates `public_key` under the user account_id. + /// + /// NOTE: MUST attach 1 yⓃ for security purposes. + fn force_add_public_keys(&mut self, public_keys: HashMap>); + + /// Deactivate `public_key` from the user account_id, + /// 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 force_remove_public_keys(&mut self, public_keys: HashMap>); } diff --git a/defuse/src/contract/accounts/force.rs b/defuse/src/contract/accounts/force.rs index 0d22069f4..3250377e6 100644 --- a/defuse/src/contract/accounts/force.rs +++ b/defuse/src/contract/accounts/force.rs @@ -1,5 +1,8 @@ +use std::collections::{HashMap, HashSet}; + use defuse_core::{ - DefuseError, Result, accounts::AccountEvent, engine::StateView, events::DefuseEvent, + DefuseError, Result, accounts::AccountEvent, crypto::PublicKey, engine::StateView, + events::DefuseEvent, }; use defuse_near_utils::Lock; use near_plugins::{AccessControllable, access_control_any}; @@ -16,7 +19,11 @@ impl ForceAccountManager for Contract { StateView::is_account_locked(self, account_id) } - #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountLocker))] + #[access_control_any(roles( + Role::DAO, + Role::UnrestrictedAccountLocker, + Role::UnrestrictedAccountManager + ))] #[payable] fn force_lock_account(&mut self, account_id: AccountId) -> bool { assert_one_yocto(); @@ -31,7 +38,11 @@ impl ForceAccountManager for Contract { locked } - #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountUnlocker))] + #[access_control_any(roles( + Role::DAO, + Role::UnrestrictedAccountUnlocker, + Role::UnrestrictedAccountManager + ))] #[payable] fn force_unlock_account(&mut self, account_id: &AccountId) -> bool { assert_one_yocto(); @@ -46,7 +57,11 @@ impl ForceAccountManager for Contract { unlocked } - #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountLocker))] + #[access_control_any(roles( + Role::DAO, + Role::UnrestrictedAccountLocker, + Role::UnrestrictedAccountManager + ))] #[payable] fn force_disable_auth_by_predecessor_ids(&mut self, account_ids: Vec) { assert_one_yocto(); @@ -57,7 +72,11 @@ impl ForceAccountManager for Contract { } } - #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountUnlocker))] + #[access_control_any(roles( + Role::DAO, + Role::UnrestrictedAccountUnlocker, + Role::UnrestrictedAccountManager + ))] #[payable] fn force_enable_auth_by_predecessor_ids(&mut self, account_ids: Vec) { assert_one_yocto(); @@ -67,6 +86,30 @@ impl ForceAccountManager for Contract { let _ = self.internal_set_auth_by_predecessor_id(&account_id, true, true); } } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountManager))] + #[payable] + fn force_add_public_keys(&mut self, public_keys: HashMap>) { + assert_one_yocto(); + + for (account_id, pks) in public_keys { + for pk in pks { + self.add_public_key(account_id.as_ref(), pk); + } + } + } + + #[access_control_any(roles(Role::DAO, Role::UnrestrictedAccountManager))] + #[payable] + fn force_remove_public_keys(&mut self, public_keys: HashMap>) { + assert_one_yocto(); + + for (account_id, pks) in public_keys { + for pk in pks { + self.remove_public_key(account_id.as_ref(), pk); + } + } + } } impl Contract { diff --git a/defuse/src/contract/accounts/mod.rs b/defuse/src/contract/accounts/mod.rs index 2d7fc9c69..7ce23146d 100644 --- a/defuse/src/contract/accounts/mod.rs +++ b/defuse/src/contract/accounts/mod.rs @@ -41,30 +41,16 @@ impl AccountManager for Contract { fn add_public_key(&mut self, public_key: PublicKey) { assert_one_yocto(); let account_id = self.ensure_auth_predecessor_id(); - State::add_public_key(self, account_id.clone(), public_key).unwrap_or_panic(); - DefuseEvent::PublicKeyAdded(AccountEvent::new( - Cow::Borrowed(account_id.as_ref()), - PublicKeyEvent { - public_key: Cow::Borrowed(&public_key), - }, - )) - .emit(); + self.add_public_key(account_id.as_ref(), public_key); } #[payable] fn remove_public_key(&mut self, public_key: PublicKey) { assert_one_yocto(); let account_id = self.ensure_auth_predecessor_id(); - State::remove_public_key(self, account_id.clone(), public_key).unwrap_or_panic(); - DefuseEvent::PublicKeyRemoved(AccountEvent::new( - Cow::Borrowed(account_id.as_ref()), - PublicKeyEvent { - public_key: Cow::Borrowed(&public_key), - }, - )) - .emit(); + self.remove_public_key(account_id.as_ref(), public_key); } fn is_nonce_used(&self, account_id: &AccountId, nonce: AsBase64) -> bool { @@ -92,6 +78,30 @@ impl Contract { } predecessor_account_id } + + pub fn add_public_key(&mut self, account_id: &AccountIdRef, public_key: PublicKey) { + State::add_public_key(self, account_id.into(), public_key).unwrap_or_panic(); + + DefuseEvent::PublicKeyAdded(AccountEvent::new( + Cow::Borrowed(account_id), + PublicKeyEvent { + public_key: Cow::Borrowed(&public_key), + }, + )) + .emit(); + } + + pub fn remove_public_key(&mut self, account_id: &AccountIdRef, public_key: PublicKey) { + State::remove_public_key(self, account_id.into(), public_key).unwrap_or_panic(); + + DefuseEvent::PublicKeyRemoved(AccountEvent::new( + Cow::Borrowed(account_id), + PublicKeyEvent { + public_key: Cow::Borrowed(&public_key), + }, + )) + .emit(); + } } #[derive(Debug)] diff --git a/defuse/src/contract/intents/state.rs b/defuse/src/contract/intents/state.rs index 6ceb20f00..5d052d85c 100644 --- a/defuse/src/contract/intents/state.rs +++ b/defuse/src/contract/intents/state.rs @@ -344,4 +344,19 @@ impl State for Contract { Ok(()) } + + #[inline] + fn mint(&mut self, owner_id: AccountId, tokens: Amounts, memo: Option) -> Result<()> { + self.deposit(owner_id, tokens, memo.as_deref()) + } + + #[inline] + fn burn( + &mut self, + owner_id: &AccountIdRef, + tokens: Amounts, + memo: Option, + ) -> Result<()> { + self.withdraw(owner_id, tokens, memo, false) + } } diff --git a/defuse/src/contract/mod.rs b/defuse/src/contract/mod.rs index e92b3cde8..674bf0ad6 100644 --- a/defuse/src/contract/mod.rs +++ b/defuse/src/contract/mod.rs @@ -53,6 +53,8 @@ pub enum Role { SaltManager, GarbageCollector, + + UnrestrictedAccountManager, } #[access_control(role_type(Role))] diff --git a/defuse/src/contract/tokens/mod.rs b/defuse/src/contract/tokens/mod.rs index b3d76d407..d969fe173 100644 --- a/defuse/src/contract/tokens/mod.rs +++ b/defuse/src/contract/tokens/mod.rs @@ -54,6 +54,8 @@ impl Contract { } } TokenId::Nep141(_) | TokenId::Nep245(_) => {} + #[cfg(feature = "imt")] + TokenId::Imt(_) => {} } owner @@ -222,9 +224,3 @@ impl Contract { .saturating_add("[\n]".len()) } } - -const MAX_TOKEN_ID_LEN: usize = 127; - -#[derive(thiserror::Error, Debug)] -#[error("token_id is too long: max length is {MAX_TOKEN_ID_LEN}, got {0}")] -pub struct TokenIdTooLarge(usize); diff --git a/defuse/src/contract/tokens/nep141/native.rs b/defuse/src/contract/tokens/nep141/native.rs index 999012baa..915045d73 100644 --- a/defuse/src/contract/tokens/nep141/native.rs +++ b/defuse/src/contract/tokens/nep141/native.rs @@ -5,7 +5,7 @@ use crate::contract::{Contract, ContractExt}; #[near] impl Contract { - pub(crate) const DO_NATIVE_WITHDRAW_GAS: Gas = Gas::from_tgas(10); + pub(crate) const DO_NATIVE_WITHDRAW_GAS: Gas = Gas::from_tgas(12); #[private] pub fn do_native_withdraw(withdraw: NativeWithdraw) -> Promise { diff --git a/defuse/src/contract/tokens/nep171/deposit.rs b/defuse/src/contract/tokens/nep171/deposit.rs index 08add6824..075410c24 100644 --- a/defuse/src/contract/tokens/nep171/deposit.rs +++ b/defuse/src/contract/tokens/nep171/deposit.rs @@ -1,14 +1,15 @@ -use defuse_core::token_id::{TokenId, nep171::Nep171TokenId}; +use defuse_core::{ + DefuseError, + intents::tokens::MAX_TOKEN_ID_LEN, + token_id::{TokenId, nep171::Nep171TokenId}, +}; use defuse_near_utils::{PanicError, UnwrapOrPanic, UnwrapOrPanicError}; use near_contract_standards::non_fungible_token::core::NonFungibleTokenReceiver; use near_plugins::{Pausable, pause}; use near_sdk::{AccountId, PromiseOrValue, env, json_types::U128, near}; use crate::{ - contract::{ - Contract, ContractExt, - tokens::{MAX_TOKEN_ID_LEN, TokenIdTooLarge}, - }, + contract::{Contract, ContractExt}, intents::{Intents, ext_intents}, tokens::{DepositAction, DepositMessage}, }; @@ -28,7 +29,7 @@ impl NonFungibleTokenReceiver for Contract { msg: String, ) -> PromiseOrValue { if token_id.len() > MAX_TOKEN_ID_LEN { - TokenIdTooLarge(token_id.len()).panic_display(); + DefuseError::TokenIdTooLarge(token_id.len()).panic_display(); } let DepositMessage { diff --git a/defuse/src/contract/tokens/nep245/deposit.rs b/defuse/src/contract/tokens/nep245/deposit.rs index aaca7370e..6707583ac 100644 --- a/defuse/src/contract/tokens/nep245/deposit.rs +++ b/defuse/src/contract/tokens/nep245/deposit.rs @@ -1,14 +1,13 @@ -use defuse_core::token_id::nep245::Nep245TokenId; +use defuse_core::{ + DefuseError, intents::tokens::MAX_TOKEN_ID_LEN, token_id::nep245::Nep245TokenId, +}; use defuse_near_utils::{PanicError, UnwrapOrPanic, UnwrapOrPanicError}; use defuse_nep245::receiver::MultiTokenReceiver; use near_plugins::{Pausable, pause}; use near_sdk::{AccountId, PromiseOrValue, env, json_types::U128, near, require}; use crate::{ - contract::{ - Contract, ContractExt, - tokens::{MAX_TOKEN_ID_LEN, TokenIdTooLarge}, - }, + contract::{Contract, ContractExt}, intents::{Intents, ext_intents}, tokens::{DepositAction, DepositMessage}, }; @@ -51,7 +50,7 @@ impl MultiTokenReceiver for Contract { .iter() .inspect(|token_id| { if token_id.len() > MAX_TOKEN_ID_LEN { - TokenIdTooLarge(token_id.len()).panic_display(); + DefuseError::TokenIdTooLarge(token_id.len()).panic_display(); } }) .cloned() diff --git a/defuse/src/contract/tokens/nep245/enumeration.rs b/defuse/src/contract/tokens/nep245/enumeration.rs index 309ccabfc..faa099721 100644 --- a/defuse/src/contract/tokens/nep245/enumeration.rs +++ b/defuse/src/contract/tokens/nep245/enumeration.rs @@ -53,6 +53,8 @@ impl MultiTokenEnumeration for Contract { owner_id: match TokenIdType::from(token_id) { TokenIdType::Nep171 => Some(account_id.clone()), TokenIdType::Nep141 | TokenIdType::Nep245 => None, + #[cfg(feature = "imt")] + TokenIdType::Imt => None, }, }); diff --git a/tests/Cargo.toml b/tests/Cargo.toml index db1f47ec3..252745ea7 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true workspace = true [dev-dependencies] -defuse = { workspace = true, features = ["contract", "sandbox"] } +defuse = { workspace = true, features = ["contract", "sandbox", "imt"] } defuse-escrow-swap = { workspace = true, features = ["auth_call"] } defuse-near-utils = { workspace = true } defuse-poa-factory = { workspace = true, features = ["contract", "sandbox"] } diff --git a/tests/src/tests/defuse/accounts/account_sync.rs b/tests/src/tests/defuse/accounts/account_sync.rs new file mode 100644 index 000000000..5e2d37a3e --- /dev/null +++ b/tests/src/tests/defuse/accounts/account_sync.rs @@ -0,0 +1,224 @@ +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, +}; + +use defuse::{ + contract::Role, + core::{ + accounts::{AccountEvent, PublicKeyEvent}, + crypto::PublicKey, + events::DefuseEvent, + }, + sandbox_ext::account_manager::AccountViewExt, +}; +use defuse_randomness::Rng; +use defuse_sandbox::{assert_a_contains_b, extensions::acl::AclExt, tx::FnCallBuilder}; +use defuse_test_utils::{asserts::ResultAssertsExt, random::rng}; +use near_sdk::{AsNep297Event, NearToken}; +use rstest::rstest; +use serde_json::json; + +use crate::{tests::defuse::env::Env, utils::fixtures::public_key}; + +#[rstest] +#[trace] +#[tokio::test] +async fn test_force_add_public_keys(#[notrace] mut rng: impl Rng) { + let env = Env::builder().deployer_as_super_admin().build().await; + + let (user1, user2) = futures::join!(env.create_user(), env.create_user()); + + let public_keys = vec![&user1, &user2] + .into_iter() + .map(|u| { + let pubkeys = (0..rng.random_range(0..10)) + .map(|_| public_key(&mut rng)) + .collect::>(); + + (u.account().id(), pubkeys) + }) + .collect::>>(); + + // only DAO or pubkey synchronizer can add public keys to accounts + { + user1 + .tx(env.defuse.id().clone()) + .function_call( + FnCallBuilder::new("force_add_public_keys") + .with_deposit(NearToken::from_yoctonear(1)) + .json_args(json!({ + "public_keys": public_keys + })), + ) + .exec_transaction() + .await + .unwrap() + .into_result() + .assert_err_contains("Insufficient permissions for method"); + } + + // Add public keys + { + env.acl_grant_role( + env.defuse.id(), + Role::UnrestrictedAccountManager, + user1.id(), + ) + .await + .expect("failed to grant role"); + + let result = user1 + .tx(env.defuse.id().clone()) + .function_call( + FnCallBuilder::new("force_add_public_keys") + .with_deposit(NearToken::from_yoctonear(1)) + .json_args(json!({ + "public_keys": public_keys + })), + ) + .exec_transaction() + .await + .unwrap() + .into_result() + .unwrap(); + + for (account_id, keys) in &public_keys { + for public_key in keys { + assert!( + env.defuse + .has_public_key(account_id, public_key) + .await + .unwrap(), + "Public key {public_key:?} not found for account {account_id}", + ); + + assert_a_contains_b!( + a: result.logs().clone(), + b: [DefuseEvent::PublicKeyAdded(AccountEvent::new( + *account_id, + PublicKeyEvent { + public_key: Cow::Borrowed(public_key), + }, + )) + .to_nep297_event() + .to_event_log(),] + ); + } + } + } +} + +#[rstest] +#[trace] +#[tokio::test] +async fn test_force_add_and_remove_public_keys(#[notrace] mut rng: impl Rng) { + let env = Env::builder().deployer_as_super_admin().build().await; + + let (user1, user2) = futures::join!(env.create_user(), env.create_user()); + + let public_keys = vec![&user1, &user2] + .into_iter() + .map(|u| { + let pubkeys = (0..rng.random_range(0..10)) + .map(|_| public_key(&mut rng)) + .collect::>(); + + (u.account().id(), pubkeys) + }) + .collect::>>(); + + // Add public keys + { + env.acl_grant_role( + env.defuse.id(), + Role::UnrestrictedAccountManager, + user1.id(), + ) + .await + .expect("failed to grant role"); + + user1 + .tx(env.defuse.id().clone()) + .function_call( + FnCallBuilder::new("force_add_public_keys") + .with_deposit(NearToken::from_yoctonear(1)) + .json_args(json!({ + "public_keys": public_keys + })), + ) + .exec_transaction() + .await + .unwrap() + .into_result() + .unwrap(); + } + + // only DAO or pubkey synchronizer can remove public keys from accounts + { + user2 + .tx(env.defuse.id().clone()) + .function_call( + FnCallBuilder::new("force_remove_public_keys") + .with_deposit(NearToken::from_yoctonear(1)) + .json_args(json!({ + "public_keys": public_keys + })), + ) + .exec_transaction() + .await + .unwrap() + .into_result() + .assert_err_contains("Insufficient permissions for method"); + } + + // Remove public keys + { + env.acl_grant_role( + env.defuse.id(), + Role::UnrestrictedAccountManager, + user2.id(), + ) + .await + .expect("failed to grant role"); + + let result = user2 + .tx(env.defuse.id().clone()) + .function_call( + FnCallBuilder::new("force_remove_public_keys") + .with_deposit(NearToken::from_yoctonear(1)) + .json_args(json!({ + "public_keys": public_keys + })), + ) + .exec_transaction() + .await + .unwrap() + .into_result() + .unwrap(); + + for (account_id, keys) in &public_keys { + for public_key in keys { + assert!( + !env.defuse + .has_public_key(account_id, public_key) + .await + .unwrap(), + "Public key {public_key:?} found for account {account_id}", + ); + + assert_a_contains_b!( + a: result.logs().clone(), + b: [DefuseEvent::PublicKeyRemoved(AccountEvent::new( + *account_id, + PublicKeyEvent { + public_key: Cow::Borrowed(public_key), + }, + )) + .to_nep297_event() + .to_event_log(),] + ); + } + } + } +} diff --git a/tests/src/tests/defuse/accounts/manage_public_keys.rs b/tests/src/tests/defuse/accounts/manage_public_keys.rs index ebd1b8134..1f7b7f9cd 100644 --- a/tests/src/tests/defuse/accounts/manage_public_keys.rs +++ b/tests/src/tests/defuse/accounts/manage_public_keys.rs @@ -41,6 +41,8 @@ async fn test_add_public_key(public_key: PublicKey) { ) .exec_transaction() .await + .unwrap() + .into_result() .unwrap(); assert_eq_event_logs!( @@ -93,6 +95,8 @@ async fn test_add_and_remove_public_key(public_key: PublicKey) { ) .exec_transaction() .await + .unwrap() + .into_result() .unwrap(); assert_eq_event_logs!( diff --git a/tests/src/tests/defuse/accounts/mod.rs b/tests/src/tests/defuse/accounts/mod.rs index b3b3a8eb3..93b19b5ad 100644 --- a/tests/src/tests/defuse/accounts/mod.rs +++ b/tests/src/tests/defuse/accounts/mod.rs @@ -1,3 +1,4 @@ +mod account_sync; mod auth_by_predecessor_id; mod force; mod manage_public_keys; diff --git a/tests/src/tests/defuse/intents/imt_burn.rs b/tests/src/tests/defuse/intents/imt_burn.rs new file mode 100644 index 000000000..313312615 --- /dev/null +++ b/tests/src/tests/defuse/intents/imt_burn.rs @@ -0,0 +1,132 @@ +use std::borrow::Cow; + +use defuse::core::accounts::AccountEvent; +use defuse::core::amounts::Amounts; +use defuse::core::crypto::Payload; +use defuse::core::events::DefuseEvent; +use defuse::core::intents::IntentEvent; +use defuse::core::intents::tokens::imt::{ImtBurn, ImtMint}; +use defuse::core::token_id::TokenId; +use defuse::nep245::{MtBurnEvent, MtEvent}; +use defuse::sandbox_ext::intents::ExecuteIntentsExt; +use defuse_escrow_swap::token_id::imt::ImtTokenId; +use defuse_escrow_swap::token_id::nep141::Nep141TokenId; +use defuse_sandbox::assert_a_contains_b; +use defuse_sandbox::extensions::mt::MtViewExt; +use near_sdk::json_types::U128; +use rstest::rstest; + +use near_sdk::AsNep297Event; + +use crate::tests::defuse::DefuseSignerExt; +use crate::tests::defuse::env::Env; + +#[rstest] +#[trace] +#[tokio::test] +async fn imt_burn_intent() { + let env = Env::builder().build().await; + + let (user, other_user) = futures::join!(env.create_user(), env.create_user()); + + let token_id = "sometoken.near".to_string(); + let memo = "Some memo"; + let amount = 1000; + + let mt_id = TokenId::from(ImtTokenId::new(user.id().clone(), token_id.to_string())); + + let mint_payload = user + .sign_defuse_payload_default( + &env.defuse, + [ImtMint { + tokens: Amounts::new(std::iter::once((token_id.clone(), amount)).collect()), + memo: Some(memo.to_string()), + receiver_id: other_user.id().clone(), + notification: None, + }], + ) + .await + .unwrap(); + + env.simulate_and_execute_intents(env.defuse.id(), [mint_payload]) + .await + .unwrap(); + + let intent = ImtBurn { + minter_id: user.id().clone(), + tokens: Amounts::new(std::iter::once((token_id.clone(), amount)).collect()), + memo: Some(memo.to_string()), + }; + let burn_payload = other_user + .sign_defuse_payload_default(&env.defuse, [intent.clone()]) + .await + .unwrap(); + + let result = env + .simulate_and_execute_intents(env.defuse.id(), [burn_payload.clone()]) + .await + .unwrap(); + + assert_eq!( + env.defuse + .mt_balance_of(other_user.id(), &mt_id.to_string()) + .await + .unwrap(), + 0 + ); + + assert_a_contains_b!( + a: result.logs().clone(), + b: [ + MtEvent::MtBurn(Cow::Owned(vec![MtBurnEvent { + owner_id: other_user.id().into(), + token_ids: vec![mt_id.to_string()].into(), + amounts: vec![U128::from(amount)].into(), + memo: Some(memo.into()), + authorized_id: None, + }])) + .to_nep297_event() + .to_event_log(), + DefuseEvent::ImtBurn(Cow::Owned(vec![IntentEvent { + intent_hash: burn_payload.hash(), + event: AccountEvent { + account_id: other_user.id().clone().into(), + event: Cow::Owned(intent), + }, + }])) + .to_nep297_event() + .to_event_log(), + ] + ); +} + +#[rstest] +#[trace] +#[tokio::test] +async fn failed_to_burn_tokens() { + let env = Env::builder().build().await; + + let (user, ft) = futures::join!(env.create_user(), env.create_token()); + + let memo = "Some memo"; + let amount = 1000; + + // Only minted imt tokens can be burned + let ft_id = TokenId::from(Nep141TokenId::new(ft)); + + let withdraw_payload = user + .sign_defuse_payload_default( + &env.defuse, + [ImtBurn { + minter_id: user.id().clone(), + tokens: Amounts::new(vec![(ft_id.to_string(), amount)].into_iter().collect()), + memo: Some(memo.to_string()), + }], + ) + .await + .unwrap(); + + env.simulate_and_execute_intents(env.defuse.id(), [withdraw_payload]) + .await + .unwrap_err(); +} diff --git a/tests/src/tests/defuse/intents/imt_mint.rs b/tests/src/tests/defuse/intents/imt_mint.rs new file mode 100644 index 000000000..d2651d4e3 --- /dev/null +++ b/tests/src/tests/defuse/intents/imt_mint.rs @@ -0,0 +1,301 @@ +use std::borrow::Cow; + +use defuse::contract::config::{DefuseConfig, RolesConfig}; +use defuse::core::accounts::AccountEvent; +use defuse::core::amounts::Amounts; +use defuse::core::crypto::Payload; +use defuse::core::events::DefuseEvent; +use defuse::core::fees::FeesConfig; +use defuse::core::intents::IntentEvent; +use defuse::core::intents::tokens::MAX_TOKEN_ID_LEN; +use defuse::core::intents::tokens::{NotifyOnTransfer, imt::ImtMint}; +use defuse::core::token_id::TokenId; +use defuse::nep245::{MtEvent, MtMintEvent}; +use defuse::sandbox_ext::deployer::DefuseExt; +use defuse::sandbox_ext::intents::ExecuteIntentsExt; +use defuse_escrow_swap::Pips; +use defuse_escrow_swap::token_id::imt::ImtTokenId; +use defuse_escrow_swap::token_id::nep245::Nep245TokenId; +use defuse_sandbox::assert_a_contains_b; +use defuse_sandbox::extensions::mt::MtViewExt; +use defuse_test_utils::asserts::ResultAssertsExt; +use multi_token_receiver_stub::MTReceiverMode; +use near_sdk::json_types::U128; +use rstest::rstest; + +use near_sdk::{AccountId, AsNep297Event, Gas}; + +use crate::tests::defuse::DefuseSignerExt; +use crate::tests::defuse::env::{Env, TransferCallExpectation}; + +#[rstest] +#[trace] +#[tokio::test] +async fn mt_mint_intent() { + let env = Env::builder().build().await; + + let user = env.create_user().await; + + let token = "sometoken.near".to_string(); + let memo = "Some memo"; + let amount = 1000; + + let intent = ImtMint { + tokens: Amounts::new(std::iter::once((token.clone(), amount)).collect()), + memo: Some(memo.to_string()), + receiver_id: user.id().clone(), + notification: None, + }; + let mint_payload = user + .sign_defuse_payload_default(&env.defuse, [intent.clone()]) + .await + .unwrap(); + + let result = env + .simulate_and_execute_intents(env.defuse.id(), [mint_payload.clone()]) + .await + .unwrap(); + + let mt_id = TokenId::from(ImtTokenId::new(user.id().clone(), token.to_string())); + + assert_eq!( + env.defuse + .mt_balance_of(user.id(), &mt_id.to_string()) + .await + .unwrap(), + amount + ); + + assert_a_contains_b!( + a: result.logs().clone(), + b: [MtEvent::MtMint(Cow::Owned(vec![MtMintEvent { + owner_id: user.id().into(), + token_ids: vec![mt_id.to_string()].into(), + amounts: vec![U128::from(amount)].into(), + memo: Some(memo.into()), + }])) + .to_nep297_event() + .to_event_log(), + DefuseEvent::ImtMint(Cow::Owned(vec![IntentEvent { + intent_hash: mint_payload.hash(), + event: AccountEvent { + account_id: user.id().clone().into(), + event: Cow::Owned(intent) + }, + }])) + .to_nep297_event() + .to_event_log(), + ] + ); +} + +#[rstest] +#[trace] +#[tokio::test] +async fn failed_imt_mint_intent() { + let env = Env::builder().build().await; + + let user = env.create_user().await; + + let token = ["a"; MAX_TOKEN_ID_LEN + 1].join(""); + let amount = 1000; + + let intent = ImtMint { + tokens: Amounts::new(std::iter::once((token.clone(), amount)).collect()), + memo: None, + receiver_id: user.id().clone(), + notification: None, + }; + let mint_payload = user + .sign_defuse_payload_default(&env.defuse, [intent.clone()]) + .await + .unwrap(); + + env.simulate_and_execute_intents(env.defuse.id(), [mint_payload.clone()]) + .await + .assert_err_contains("token_id is too long"); +} + +#[rstest] +#[trace] +#[tokio::test] +async fn mt_mint_intent_to_defuse() { + let env = Env::builder().build().await; + + let user = env.create_user().await; + let other_user_id: AccountId = "other-user.near".parse().unwrap(); + + let defuse2 = env + .deploy_defuse( + "defuse2", + DefuseConfig { + wnear_id: env.wnear.id().clone(), + fees: FeesConfig { + fee: Pips::ZERO, + fee_collector: env.id().clone(), + }, + roles: RolesConfig::default(), + }, + false, + ) + .await + .unwrap(); + + let ft = "newtoken.near".to_string(); + + // large gas limit + { + let mint_intent = ImtMint { + receiver_id: defuse2.id().clone(), + tokens: Amounts::new(std::iter::once((ft.clone(), 1000)).collect()), + memo: None, + notification: NotifyOnTransfer::new(other_user_id.to_string()) + .with_min_gas(Gas::from_tgas(500)) + .into(), + }; + + let transfer_payload = user + .sign_defuse_payload_default(&env.defuse, [mint_intent]) + .await + .unwrap(); + + env.simulate_and_execute_intents(env.defuse.id(), [transfer_payload]) + .await + .expect_err("Exceeded the prepaid gas"); + } + + // Should pass default gas limit in case of low gas + { + let mint_intent = ImtMint { + receiver_id: defuse2.id().clone(), + tokens: Amounts::new(std::iter::once((ft.clone(), 1000)).collect()), + memo: None, + notification: NotifyOnTransfer::new(other_user_id.to_string()) + .with_min_gas(Gas::from_tgas(1)) + .into(), + }; + + let transfer_payload = user + .sign_defuse_payload_default(&env.defuse, [mint_intent]) + .await + .unwrap(); + + assert!(defuse2.mt_tokens(..).await.unwrap().is_empty()); + + env.simulate_and_execute_intents(env.defuse.id(), [transfer_payload]) + .await + .unwrap(); + + let mt_token = TokenId::from(ImtTokenId::new(user.id().clone(), ft.to_string())); + + assert_eq!( + env.defuse + .mt_balance_of(defuse2.id(), &mt_token.to_string()) + .await + .unwrap(), + 1000 + ); + + assert_eq!(defuse2.mt_tokens(..).await.unwrap().len(), 1); + assert_eq!( + defuse2 + .mt_tokens_for_owner(&other_user_id, ..) + .await + .unwrap() + .len(), + 1 + ); + + let defuse_ft1: TokenId = + Nep245TokenId::new(env.defuse.id().clone(), mt_token.to_string()).into(); + + assert_eq!( + defuse2 + .mt_balance_of(&other_user_id, &defuse_ft1.to_string()) + .await + .unwrap(), + 1000 + ); + } +} + +#[rstest] +#[trace] +#[case::nothing_to_refund(TransferCallExpectation { + mode: MTReceiverMode::AcceptAll, + intent_transfer_amount: Some(1_000), + expected_sender_balance: 0, + expected_receiver_balance: 1_000, +})] +#[case::partial_refund(TransferCallExpectation { + mode: MTReceiverMode::ReturnValue(300.into()), + intent_transfer_amount: Some(1_000), + expected_sender_balance: 300, + expected_receiver_balance: 700, +})] +#[case::malicious_refund(TransferCallExpectation { + mode: MTReceiverMode::ReturnValue(2_000.into()), + intent_transfer_amount: Some(1_000), + expected_sender_balance: 1_000, + expected_receiver_balance: 0, +})] +#[case::receiver_panics(TransferCallExpectation { + mode: MTReceiverMode::Panic, + intent_transfer_amount: Some(1_000), + expected_sender_balance: 1000, + expected_receiver_balance: 0, +})] +#[case::malicious_receiver(TransferCallExpectation { + mode: MTReceiverMode::LargeReturn, + intent_transfer_amount: Some(1_000), + expected_sender_balance: 1000, + expected_receiver_balance: 0, +})] +#[tokio::test] +async fn mt_mint_intent_with_msg_to_receiver_smc(#[case] expectation: TransferCallExpectation) { + let initial_amount = expectation + .intent_transfer_amount + .expect("Transfer amount should be specified"); + + let env = Env::builder().build().await; + + let (user, mt_receiver) = futures::join!(env.create_user(), env.deploy_mt_receiver_stub()); + + let ft1 = "some-mt-token.near".to_string(); + + let msg = serde_json::to_string(&expectation.mode).unwrap(); + + let mint_intent = ImtMint { + receiver_id: mt_receiver.id().clone(), + tokens: Amounts::new(std::iter::once((ft1.clone(), initial_amount)).collect()), + memo: None, + notification: NotifyOnTransfer::new(msg).into(), + }; + + let transfer_payload = user + .sign_defuse_payload_default(&env.defuse, [mint_intent]) + .await + .unwrap(); + + env.simulate_and_execute_intents(env.defuse.id(), [transfer_payload]) + .await + .unwrap(); + + let mt_token = TokenId::from(ImtTokenId::new(user.id().clone(), ft1.to_string())); + + assert_eq!( + env.defuse + .mt_balance_of(user.id(), &mt_token.to_string()) + .await + .unwrap(), + expectation.expected_sender_balance + ); + + assert_eq!( + env.defuse + .mt_balance_of(mt_receiver.id(), &mt_token.to_string()) + .await + .unwrap(), + expectation.expected_receiver_balance + ); +} diff --git a/tests/src/tests/defuse/intents/mod.rs b/tests/src/tests/defuse/intents/mod.rs index 7a114a103..2b8e27c37 100644 --- a/tests/src/tests/defuse/intents/mod.rs +++ b/tests/src/tests/defuse/intents/mod.rs @@ -43,6 +43,8 @@ impl AccountNonceIntentEvent { } mod ft_withdraw; +mod imt_burn; +mod imt_mint; mod legacy_nonce; mod native_withdraw; mod public_key; diff --git a/tests/src/tests/defuse/intents/simulate.rs b/tests/src/tests/defuse/intents/simulate.rs index 64c57f912..f276b5ada 100644 --- a/tests/src/tests/defuse/intents/simulate.rs +++ b/tests/src/tests/defuse/intents/simulate.rs @@ -21,6 +21,7 @@ use defuse::{ token_diff::{TokenDeltas, TokenDiff, TokenDiffEvent}, tokens::{ FtWithdraw, MtWithdraw, NativeWithdraw, NftWithdraw, StorageDeposit, Transfer, + imt::{ImtBurn, ImtMint}, }, }, token_id::{TokenId, nep141::Nep141TokenId, nep171::Nep171TokenId, nep245::Nep245TokenId}, @@ -905,6 +906,125 @@ async fn simulate_auth_call_intent() { ); } +#[rstest] +#[trace] +#[tokio::test] +async fn simulate_mint_intent() { + let env = Env::builder().build().await; + + let user = env.create_user().await; + + let token_id = "sometoken.near".to_string(); + let memo = "Some memo"; + let amount = 1000; + + let mint_intent = ImtMint { + tokens: Amounts::new(std::iter::once((token_id.clone(), amount)).collect()), + memo: Some(memo.to_string()), + receiver_id: user.id().clone(), + notification: None, + }; + + let mint_payload = user + .sign_defuse_payload_default(&env.defuse, [mint_intent.clone()]) + .await + .unwrap(); + + let nonce = mint_payload.extract_nonce().unwrap(); + + let result = env + .defuse + .simulate_intents([mint_payload.clone()]) + .await + .unwrap(); + + assert_eq!( + result.report.logs, + vec![ + DefuseEvent::ImtMint(Cow::Owned(vec![IntentEvent { + intent_hash: mint_payload.hash(), + event: AccountEvent { + account_id: user.id().clone().into(), + event: Cow::Owned(mint_intent) + }, + }])) + .to_nep297_event() + .to_event_log(), + AccountNonceIntentEvent::new(&user.id(), nonce, &mint_payload) + .into_event() + .to_nep297_event() + .to_event_log(), + ] + ); +} + +#[rstest] +#[trace] +#[tokio::test] +async fn simulate_burn_intent() { + let env = Env::builder().build().await; + + let user = env.create_user().await; + + let token_id = "sometoken.near".to_string(); + let memo = "Some memo"; + let amount = 1000; + + let mint_payload = user + .sign_defuse_payload_default( + &env.defuse, + [ImtMint { + tokens: Amounts::new(std::iter::once((token_id.clone(), amount)).collect()), + memo: Some(memo.to_string()), + receiver_id: user.id().clone(), + notification: None, + }], + ) + .await + .unwrap(); + + env.simulate_and_execute_intents(env.defuse.id(), [mint_payload]) + .await + .unwrap(); + + let burn_intent = ImtBurn { + minter_id: user.id().clone(), + tokens: Amounts::new(std::iter::once((token_id.clone(), amount)).collect()), + memo: Some(memo.to_string()), + }; + + let burn_payload = user + .sign_defuse_payload_default(&env.defuse, [burn_intent.clone()]) + .await + .unwrap(); + let nonce = burn_payload.extract_nonce().unwrap(); + + let result = env + .defuse + .simulate_intents([burn_payload.clone()]) + .await + .unwrap(); + + assert_eq!( + result.report.logs, + vec![ + DefuseEvent::ImtBurn(Cow::Owned(vec![IntentEvent { + intent_hash: burn_payload.hash(), + event: AccountEvent { + account_id: user.id().clone().into(), + event: Cow::Owned(burn_intent) + }, + }])) + .to_nep297_event() + .to_event_log(), + AccountNonceIntentEvent::new(&user.id(), nonce, &burn_payload) + .into_event() + .to_nep297_event() + .to_event_log(), + ] + ); +} + #[rstest] #[trace] #[tokio::test] diff --git a/tests/src/tests/defuse/tokens/nep245/mt_transfer_resolve_gas.rs b/tests/src/tests/defuse/tokens/nep245/mt_transfer_resolve_gas.rs index 7533f082e..80745726c 100644 --- a/tests/src/tests/defuse/tokens/nep245/mt_transfer_resolve_gas.rs +++ b/tests/src/tests/defuse/tokens/nep245/mt_transfer_resolve_gas.rs @@ -2,7 +2,10 @@ use crate::tests::defuse::{env::Env, tokens::nep245::letter_gen::LetterCombinati use anyhow::Context; use arbitrary::Arbitrary; use defuse::{ - core::token_id::{TokenId, nep245::Nep245TokenId}, + core::{ + intents::tokens::MAX_TOKEN_ID_LEN, + token_id::{TokenId, nep245::Nep245TokenId}, + }, nep245::{MtEvent, MtTransferEvent}, }; use defuse_randomness::Rng; @@ -51,20 +54,16 @@ async fn make_account(mode: GenerationMode, env: &Env, user: &SigningAccount) -> fn make_token_ids(mode: GenerationMode, rng: &mut impl Rng, token_count: usize) -> Vec { match mode { GenerationMode::ShortestPossible => LetterCombinations::generate_combos(token_count), - GenerationMode::LongestPossible => { - const MAX_TOKEN_ID_LEN: usize = 127; - - (1..=token_count) - .map(|i| { - format!( - "{}_{}", - i, - gen_random_string(rng, MAX_TOKEN_ID_LEN..=MAX_TOKEN_ID_LEN) - )[0..MAX_TOKEN_ID_LEN] - .to_string() - }) - .collect::>() - } + GenerationMode::LongestPossible => (1..=token_count) + .map(|i| { + format!( + "{}_{}", + i, + gen_random_string(rng, MAX_TOKEN_ID_LEN..=MAX_TOKEN_ID_LEN) + )[0..MAX_TOKEN_ID_LEN] + .to_string() + }) + .collect::>(), } } diff --git a/token-id/Cargo.toml b/token-id/Cargo.toml index 637d8bb4f..b4d43037e 100644 --- a/token-id/Cargo.toml +++ b/token-id/Cargo.toml @@ -26,6 +26,7 @@ default = ["nep141", "nep171", "nep245"] nep141 = [] nep171 = [] nep245 = [] +imt = [] abi = [] arbitrary = ["dep:arbitrary", "near-sdk/arbitrary"] diff --git a/token-id/src/imt.rs b/token-id/src/imt.rs new file mode 100644 index 000000000..59ac0d262 --- /dev/null +++ b/token-id/src/imt.rs @@ -0,0 +1,75 @@ +pub use defuse_nep245::TokenId; + +use std::{fmt, str::FromStr}; + +use near_sdk::{AccountId, near}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; + +use crate::{TokenIdType, error::TokenIdError}; + +// Intent mintable token - can be minted only by intents 'ImtMint' +#[cfg_attr(any(feature = "arbitrary", test), derive(::arbitrary::Arbitrary))] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr)] +#[near(serializers = [borsh])] +pub struct ImtTokenId { + pub minter_id: AccountId, + + pub token_id: TokenId, +} + +impl ImtTokenId { + pub fn new(minter_id: impl Into, token_id: impl Into) -> Self { + Self { + minter_id: minter_id.into(), + token_id: token_id.into(), + } + } +} + +impl std::fmt::Debug for ImtTokenId { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", &self.minter_id, &self.token_id) + } +} + +impl std::fmt::Display for ImtTokenId { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self, f) + } +} + +impl FromStr for ImtTokenId { + type Err = TokenIdError; + + fn from_str(data: &str) -> Result { + let (minter_id, token_id) = data + .split_once(':') + .ok_or(strum::ParseError::VariantNotFound)?; + Ok(Self::new(minter_id.parse::()?, token_id)) + } +} + +impl From<&ImtTokenId> for TokenIdType { + #[inline] + fn from(_: &ImtTokenId) -> Self { + Self::Imt + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use defuse_test_utils::random::make_arbitrary; + use rstest::rstest; + + #[rstest] + #[trace] + fn display_from_str_roundtrip(#[from(make_arbitrary)] token_id: ImtTokenId) { + let s = token_id.to_string(); + let got: ImtTokenId = s.parse().unwrap(); + assert_eq!(got, token_id); + } +} diff --git a/token-id/src/lib.rs b/token-id/src/lib.rs index 7995a3ad6..31a49e867 100644 --- a/token-id/src/lib.rs +++ b/token-id/src/lib.rs @@ -1,5 +1,7 @@ mod error; +#[cfg(feature = "imt")] +pub mod imt; #[cfg(feature = "nep141")] pub mod nep141; #[cfg(feature = "nep171")] @@ -7,12 +9,18 @@ pub mod nep171; #[cfg(feature = "nep245")] pub mod nep245; -#[cfg(not(any(feature = "nep141", feature = "nep171", feature = "nep245")))] +#[cfg(not(any( + feature = "nep141", + feature = "nep171", + feature = "nep245", + feature = "imt" +)))] compile_error!( r#"At least one of these features should be enabled: - "nep141" - "nep171" - "nep245" +- "imt" "# ); @@ -66,6 +74,8 @@ pub enum TokenId { Nep171(crate::nep171::Nep171TokenId) = 1, #[cfg(feature = "nep245")] Nep245(crate::nep245::Nep245TokenId) = 2, + #[cfg(feature = "imt")] + Imt(crate::imt::ImtTokenId) = 3, } impl Debug for TokenId { @@ -84,6 +94,10 @@ impl Debug for TokenId { Self::Nep245(token_id) => { write!(f, "{}:{}", TokenIdType::Nep245, token_id) } + #[cfg(feature = "imt")] + Self::Imt(token_id) => { + write!(f, "{}:{}", TokenIdType::Imt, token_id) + } } } } @@ -110,6 +124,8 @@ impl FromStr for TokenId { TokenIdType::Nep171 => data.parse().map(Self::Nep171), #[cfg(feature = "nep245")] TokenIdType::Nep245 => data.parse().map(Self::Nep245), + #[cfg(feature = "imt")] + TokenIdType::Imt => data.parse().map(Self::Imt), } } } @@ -149,6 +165,11 @@ const _: () = { "mt.near".parse::().unwrap(), "token_id1", )), + #[cfg(feature = "imt")] + TokenId::Imt(crate::imt::ImtTokenId::new( + "imt.near".parse::().unwrap(), + "token_id1", + )), ] .map(|s| s.to_string()) .to_vec() @@ -182,7 +203,7 @@ mod tests { feature = "nep245", case("nep245:abc:xyz", "02030000006162630300000078797a") )] - + #[cfg_attr(feature = "imt", case("imt:abc:xyz", "03030000006162630300000078797a"))] fn roundtrip_fixed(#[case] token_id_str: &str, #[case] borsh_expected_hex: &str) { let token_id: TokenId = token_id_str.parse().unwrap(); let borsh_expected = hex::decode(borsh_expected_hex).unwrap();