diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 000000000..32212aab1 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,10 @@ +# cargo-audit configuration +# https://docs.rs/cargo-audit/latest/cargo_audit/config/ + +[advisories] +# Ignore the lru unsound advisory - it comes from near-vm-runner which is +# locked to lru ^0.12.3 and cannot be updated to the fixed 0.16.3 version. +# The advisory relates to IterMut's Stacked Borrows violation, which does +# not affect our usage as we don't use IterMut directly. +# Tracked: https://github.com/near/nearcore/issues/XXXXX (upstream) +ignore = ["RUSTSEC-2026-0002"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6966a73f2..3cc4cf6ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,7 +147,7 @@ jobs: - name: Run Tests env: DEFUSE_MIGRATE_FROM_LEGACY: "true" - run: cargo make run-tests -- --show-output + run: cargo make run-tests-long -- --show-output security_audit_report: name: Security Audit - report @@ -162,6 +162,7 @@ jobs: with: cache: false - name: Install cargo-audit + # Require 0.22+ for CVSS 4.0 support (advisory-db now contains CVSS 4.0 entries) run: cargo install cargo-audit --version "^0.22" --locked - uses: rustsec/audit-check@v2.0.0 with: diff --git a/Cargo.lock b/Cargo.lock index 20fed84eb..ba86ec9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,6 +1017,7 @@ dependencies = [ "defuse-near-utils", "derive_more", "near-sdk", + "thiserror 2.0.17", ] [[package]] @@ -1087,6 +1088,7 @@ dependencies = [ "defuse-randomness", "futures", "impl-tools", + "libc", "near-api", "near-contract-standards", "near-openapi-types", diff --git a/Makefile.toml b/Makefile.toml index a267e598e..5941b4ede 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -208,6 +208,10 @@ run_task = "run-tests" command = "cargo" args = ["test", "--workspace", "--all-targets", "${@}"] +[tasks.run-tests-long] +command = "cargo" +args = ["test", "--workspace", "--all-targets", "--features=long", "${@}"] + # ============================================================================ # Cleanup tasks # ============================================================================ diff --git a/defuse/src/contract/tokens/mod.rs b/defuse/src/contract/tokens/mod.rs index d969fe173..4b6af1b55 100644 --- a/defuse/src/contract/tokens/mod.rs +++ b/defuse/src/contract/tokens/mod.rs @@ -4,7 +4,7 @@ mod nep245; use super::Contract; use defuse_core::{DefuseError, Result, token_id::TokenId}; -use defuse_near_utils::{Lock, UnwrapOrPanic}; +use defuse_near_utils::{Lock, REFUND_MEMO, UnwrapOrPanic}; use defuse_nep245::{MtBurnEvent, MtEvent, MtMintEvent}; use itertools::{Either, Itertools}; use near_sdk::{AccountId, AccountIdRef, Gas, env, json_types::U128, serde_json}; @@ -164,7 +164,7 @@ impl Contract { authorized_id: None, token_ids: Vec::with_capacity(tokens_count).into(), amounts: Vec::with_capacity(tokens_count).into(), - memo: Some("refund".into()), + memo: Some(REFUND_MEMO.into()), }; let Some(receiver) = self diff --git a/defuse/src/contract/tokens/nep141/withdraw.rs b/defuse/src/contract/tokens/nep141/withdraw.rs index 9ffaf45bc..fc2b752a2 100644 --- a/defuse/src/contract/tokens/nep141/withdraw.rs +++ b/defuse/src/contract/tokens/nep141/withdraw.rs @@ -9,7 +9,7 @@ use defuse_core::{ DefuseError, Result, engine::StateView, intents::tokens::FtWithdraw, token_id::nep141::Nep141TokenId, }; -use defuse_near_utils::UnwrapOrPanic; +use defuse_near_utils::{REFUND_MEMO, UnwrapOrPanic}; use defuse_wnear::{NEAR_WITHDRAW_GAS, ext_wnear}; use near_contract_standards::{ fungible_token::core::ext_ft_core, storage_management::ext_storage_management, @@ -188,7 +188,7 @@ impl FungibleTokenWithdrawResolver for Contract { self.deposit( sender_id, [(Nep141TokenId::new(token).into(), refund)], - Some("refund"), + Some(REFUND_MEMO), ) .unwrap_or_panic(); } diff --git a/defuse/src/contract/tokens/nep171/withdraw.rs b/defuse/src/contract/tokens/nep171/withdraw.rs index 02f0ce70d..38a5f771d 100644 --- a/defuse/src/contract/tokens/nep171/withdraw.rs +++ b/defuse/src/contract/tokens/nep171/withdraw.rs @@ -11,7 +11,7 @@ use defuse_core::{ intents::tokens::NftWithdraw, token_id::{nep141::Nep141TokenId, nep171::Nep171TokenId}, }; -use defuse_near_utils::UnwrapOrPanic; +use defuse_near_utils::{REFUND_MEMO, UnwrapOrPanic}; use defuse_wnear::{NEAR_WITHDRAW_GAS, ext_wnear}; use near_contract_standards::{ non_fungible_token::{self, core::ext_nft_core}, @@ -187,7 +187,7 @@ impl NonFungibleTokenWithdrawResolver for Contract { self.deposit( sender_id, [(Nep171TokenId::new(token, token_id).into(), 1)], - Some("refund"), + Some(REFUND_MEMO), ) .unwrap_or_panic(); } diff --git a/defuse/src/contract/tokens/nep245/core.rs b/defuse/src/contract/tokens/nep245/core.rs index bf49a10eb..1538c26d7 100644 --- a/defuse/src/contract/tokens/nep245/core.rs +++ b/defuse/src/contract/tokens/nep245/core.rs @@ -209,6 +209,8 @@ impl Contract { .as_slice() .into(), ) + .check_refund() + .unwrap_or_panic() .emit(); Ok(()) diff --git a/defuse/src/contract/tokens/nep245/resolver.rs b/defuse/src/contract/tokens/nep245/resolver.rs index 9ad8f72d0..70f16a0d9 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::{Lock, UnwrapOrPanic, UnwrapOrPanicError}; +use defuse_near_utils::{Lock, REFUND_MEMO, UnwrapOrPanic, UnwrapOrPanicError}; use defuse_nep245::{ ClearedApproval, MtEventEmit, MtTransferEvent, TokenId, resolver::MultiTokenResolver, }; @@ -109,7 +109,7 @@ impl MultiTokenResolver for Contract { new_owner_id: Cow::Borrowed(&sender_id), token_ids: refunded_token_ids.into(), amounts: refunded_amounts.into(), - memo: Some("refund".into()), + memo: Some(REFUND_MEMO.into()), }] .as_slice(), ) diff --git a/defuse/src/contract/tokens/nep245/withdraw.rs b/defuse/src/contract/tokens/nep245/withdraw.rs index 634a6fbb5..4bf7df41e 100644 --- a/defuse/src/contract/tokens/nep245/withdraw.rs +++ b/defuse/src/contract/tokens/nep245/withdraw.rs @@ -12,7 +12,7 @@ use defuse_core::{ intents::tokens::MtWithdraw, token_id::{nep141::Nep141TokenId, nep245::Nep245TokenId}, }; -use defuse_near_utils::{UnwrapOrPanic, UnwrapOrPanicError}; +use defuse_near_utils::{REFUND_MEMO, UnwrapOrPanic, UnwrapOrPanicError}; use defuse_nep245::ext_mt_core; use defuse_wnear::{NEAR_WITHDRAW_GAS, ext_wnear}; use near_contract_standards::storage_management::ext_storage_management; @@ -253,7 +253,7 @@ impl MultiTokenWithdrawResolver for Contract { None } }), - Some("refund"), + Some(REFUND_MEMO), ) .unwrap_or_panic(); diff --git a/near-utils/src/event.rs b/near-utils/src/event.rs index 7d8bfe50a..60615cd0a 100644 --- a/near-utils/src/event.rs +++ b/near-utils/src/event.rs @@ -1,3 +1,10 @@ +/// Maximum length of a single log entry in NEAR runtime. +/// See: +pub const TOTAL_LOG_LENGTH_LIMIT: usize = 16384; + +/// Memo used for refund events. +pub const REFUND_MEMO: &str = "refund"; + pub trait NearSdkLog { fn to_near_sdk_log(&self) -> String; } diff --git a/near-utils/src/lib.rs b/near-utils/src/lib.rs index adeb6925b..dac89f868 100644 --- a/near-utils/src/lib.rs +++ b/near-utils/src/lib.rs @@ -1,7 +1,7 @@ #[cfg(feature = "digest")] pub mod digest; mod event; -pub use event::NearSdkLog; +pub use event::{NearSdkLog, REFUND_MEMO, TOTAL_LOG_LENGTH_LIMIT}; mod gas; mod lock; mod panic; diff --git a/nep245/Cargo.toml b/nep245/Cargo.toml index 4b5ae511e..94516151a 100644 --- a/nep245/Cargo.toml +++ b/nep245/Cargo.toml @@ -9,6 +9,10 @@ repository.workspace = true derive_more = { workspace = true, features = ["from"] } near-sdk.workspace = true defuse-near-utils.workspace = true +thiserror.workspace = true + +[dev-dependencies] +near-sdk = { workspace = true, features = ["unit-testing"] } [lints] workspace = true diff --git a/nep245/src/checked.rs b/nep245/src/checked.rs new file mode 100644 index 000000000..c2006de40 --- /dev/null +++ b/nep245/src/checked.rs @@ -0,0 +1,85 @@ +use crate::MtEvent; +use defuse_near_utils::REFUND_MEMO; +use near_sdk::FunctionError; + +#[derive(Debug, Clone, PartialEq, Eq, FunctionError, thiserror::Error)] +#[error("refund event log would be too long")] +pub struct ErrorLogTooLong; + +const REFUND_STR_LEN: usize = REFUND_MEMO.len(); +pub const REFUND_EXTRA_BYTES: usize = r#","memo":""#.len() + REFUND_STR_LEN; + +#[derive(Default, Clone, Copy)] +#[must_use] +pub struct RefundLogDelta { + overhead: usize, + savings: usize, +} + +impl RefundLogDelta { + pub const fn new(overhead: usize, savings: usize) -> Self { + Self { + overhead: overhead.saturating_sub(savings), + savings: savings.saturating_sub(overhead), + } + } + + pub const fn overhead(&self) -> usize { + self.overhead + } + + pub const fn savings(&self) -> usize { + self.savings + } + + pub const fn saturating_add(self, other: Self) -> Self { + Self::new( + self.overhead.saturating_add(other.overhead), + self.savings.saturating_add(other.savings), + ) + } +} + +const fn refund_log_delta(memo: Option<&str>) -> RefundLogDelta { + let Some(m) = memo else { + return RefundLogDelta { + overhead: REFUND_EXTRA_BYTES, + savings: 0, + }; + }; + RefundLogDelta::new( + REFUND_STR_LEN.saturating_sub(m.len()), + m.len().saturating_sub(REFUND_STR_LEN), + ) +} + +impl MtEvent<'_> { + pub(crate) fn compute_refund_delta(&self) -> RefundLogDelta { + match self { + MtEvent::MtMint(events) => events + .iter() + .map(|e| refund_log_delta(e.memo.as_deref())) + .fold(RefundLogDelta::default(), RefundLogDelta::saturating_add), + MtEvent::MtBurn(events) => events + .iter() + .map(|e| refund_log_delta(e.memo.as_deref())) + .fold(RefundLogDelta::default(), RefundLogDelta::saturating_add), + MtEvent::MtTransfer(events) => events + .iter() + .map(|e| refund_log_delta(e.memo.as_deref())) + .fold(RefundLogDelta::default(), RefundLogDelta::saturating_add), + } + } +} + +/// A validated event log that has been checked for refund overhead. +/// Use [`RefundCheckedMtEvent::emit`] to emit the event. +#[derive(Debug)] +#[must_use = "call `.emit()` to emit the event"] +pub struct RefundCheckedMtEvent(pub String); + +impl RefundCheckedMtEvent { + pub fn emit(self) { + near_sdk::env::log_str(&self.0); + } +} diff --git a/nep245/src/events.rs b/nep245/src/events.rs index ee58af621..1fcd28468 100644 --- a/nep245/src/events.rs +++ b/nep245/src/events.rs @@ -1,6 +1,8 @@ use super::TokenId; +use crate::checked::{ErrorLogTooLong, RefundCheckedMtEvent}; +use defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT; use derive_more::derive::From; -use near_sdk::{AccountIdRef, json_types::U128, near, serde::Deserialize}; +use near_sdk::{AccountIdRef, AsNep297Event, json_types::U128, near, serde::Deserialize}; use std::borrow::Cow; #[must_use = "make sure to `.emit()` this event"] @@ -15,6 +17,24 @@ pub enum MtEvent<'a> { MtTransfer(Cow<'a, [MtTransferEvent<'a>]>), } +impl MtEvent<'_> { + /// Validates that the event log (including potential refund overhead) fits within limits. + /// Returns a [`RefundCheckedMtEvent`] that can be emitted. + pub fn check_refund(self) -> Result { + let log = self.to_nep297_event().to_event_log(); + let delta = self.compute_refund_delta(); + let refund_len = log + .len() + .saturating_add(delta.overhead()) + .saturating_sub(delta.savings()); + + if refund_len > TOTAL_LOG_LENGTH_LIMIT { + return Err(ErrorLogTooLong); + } + Ok(RefundCheckedMtEvent(log)) + } +} + #[must_use = "make sure to `.emit()` this event"] #[near(serializers = [json])] #[derive(Debug, Clone)] @@ -62,3 +82,158 @@ pub trait MtEventEmit<'a>: Into> { } } impl<'a, T> MtEventEmit<'a> for T where T: Into> {} + +#[cfg(test)] +mod tests { + use crate::checked::REFUND_EXTRA_BYTES; + use defuse_near_utils::REFUND_MEMO; + + use super::*; + use near_sdk::json_types::U128; + + const REFUND_STR_LEN: usize = REFUND_MEMO.len(); + + /// Create a single-event `MtTransfer` with exact log length. + /// Pads `token_id` to achieve the desired length. + fn create_single_event_mt(length: usize, memo: Option<&str>) -> MtEvent<'static> { + let old_owner: near_sdk::AccountId = "aa".parse().unwrap(); + let new_owner: near_sdk::AccountId = "bb".parse().unwrap(); + let base_token_id = "t"; + + // Measure base log length + let base_event = MtTransferEvent { + authorized_id: None, + old_owner_id: Cow::Owned(old_owner.clone()), + new_owner_id: Cow::Owned(new_owner.clone()), + token_ids: Cow::Owned(vec![base_token_id.to_string()]), + amounts: Cow::Owned(vec![U128(1)]), + memo: memo.map(|m| Cow::Owned(m.to_string())), + }; + let base_mt_event = MtEvent::MtTransfer(Cow::Owned(vec![base_event])); + let base_length = base_mt_event.to_nep297_event().to_event_log().len(); + + // Calculate padding needed for token_id + let padding_needed = length.saturating_sub(base_length); + let padded_token_id = format!("{}{}", base_token_id, "x".repeat(padding_needed)); + + let event = MtTransferEvent { + authorized_id: None, + old_owner_id: Cow::Owned(old_owner), + new_owner_id: Cow::Owned(new_owner), + token_ids: Cow::Owned(vec![padded_token_id]), + amounts: Cow::Owned(vec![U128(1)]), + memo: memo.map(|m| Cow::Owned(m.to_string())), + }; + + let mt_event = MtEvent::MtTransfer(Cow::Owned(vec![event])); + let log_len = mt_event.to_nep297_event().to_event_log().len(); + assert_eq!( + log_len, length, + "Expected log length {length}, got {log_len}" + ); + + mt_event + } + + /// Create a triple-event `MtTransfer` with exact log length. + /// Each event has its own memo. Pads first event's `token_id` to achieve the desired length. + fn create_triple_event_mt(length: usize, memos: [Option<&str>; 3]) -> MtEvent<'static> { + let old_owner: near_sdk::AccountId = "aa".parse().unwrap(); + let new_owner: near_sdk::AccountId = "bb".parse().unwrap(); + let base_token_id = "t"; + + // Measure base log length with 3 events + let base_events: Vec> = memos + .iter() + .enumerate() + .map(|(i, memo)| MtTransferEvent { + authorized_id: None, + old_owner_id: Cow::Owned(old_owner.clone()), + new_owner_id: Cow::Owned(new_owner.clone()), + token_ids: Cow::Owned(vec![format!("{base_token_id}{i}")]), + amounts: Cow::Owned(vec![U128(1)]), + memo: memo.map(|m| Cow::Owned(m.to_string())), + }) + .collect(); + let base_mt_event = MtEvent::MtTransfer(Cow::Owned(base_events)); + let base_length = base_mt_event.to_nep297_event().to_event_log().len(); + + // Calculate padding needed (only pad the first event's token_id) + let padding_needed = length.saturating_sub(base_length); + let padded_token_id = format!("{base_token_id}0{}", "x".repeat(padding_needed)); + + // Create final events: first one with padded token_id, rest with base token_ids + let events: Vec> = memos + .iter() + .enumerate() + .map(|(i, memo)| { + let token_id = if i == 0 { + padded_token_id.clone() + } else { + format!("{base_token_id}{i}") + }; + MtTransferEvent { + authorized_id: None, + old_owner_id: Cow::Owned(old_owner.clone()), + new_owner_id: Cow::Owned(new_owner.clone()), + token_ids: Cow::Owned(vec![token_id]), + amounts: Cow::Owned(vec![U128(1)]), + memo: memo.map(|m| Cow::Owned(m.to_string())), + } + }) + .collect(); + + let mt_event = MtEvent::MtTransfer(Cow::Owned(events)); + let log_len = mt_event.to_nep297_event().to_event_log().len(); + assert_eq!( + log_len, length, + "Expected log length {length}, got {log_len}" + ); + + mt_event + } + + #[test] + fn single_event_no_memo_at_limit_minus_overhead_passes() { + let mt = create_single_event_mt(TOTAL_LOG_LENGTH_LIMIT - REFUND_EXTRA_BYTES, None); + assert!(mt.check_refund().is_ok()); + } + + #[test] + fn single_event_short_memo_at_limit_fails() { + let memo = "refu"; + let mt = create_single_event_mt(TOTAL_LOG_LENGTH_LIMIT, Some(memo)); + assert!(matches!(mt.check_refund().unwrap_err(), ErrorLogTooLong)); + } + + #[test] + fn triple_event_no_memo_at_limit_minus_overhead_passes() { + let mt = create_triple_event_mt(TOTAL_LOG_LENGTH_LIMIT - 3 * REFUND_EXTRA_BYTES, [None; 3]); + assert!(mt.check_refund().is_ok()); + } + + #[test] + fn triple_event_short_memo_at_limit_fails() { + let mt = create_triple_event_mt(TOTAL_LOG_LENGTH_LIMIT, [Some("refu"); 3]); + assert!(matches!(mt.check_refund().unwrap_err(), ErrorLogTooLong)); + } + + #[test] + fn triple_event_mixed_memos_overhead_equals_savings_at_limit_passes() { + // there are 3 events + // 1 without memo + // 2 with "refund" memo + // 3 with really long memo + // total log length is exactly TOTAL_LOG_LENGTH_LIMIT, but since really long memo will be + // replaced with just refund there will be enough buffer to set memo "refund" also for + // first event and still fit into TOTAL_LOG_LENGTH_LIMIT on refund + let long_memo = "x".repeat(REFUND_EXTRA_BYTES + REFUND_STR_LEN); + assert_eq!(long_memo.len() - REFUND_STR_LEN, REFUND_EXTRA_BYTES); + + let mt = create_triple_event_mt( + TOTAL_LOG_LENGTH_LIMIT, + [None, Some("refund"), Some(&long_memo)], + ); + assert!(mt.check_refund().is_ok()); + } +} diff --git a/nep245/src/lib.rs b/nep245/src/lib.rs index 457d8bdd8..fb48cb39c 100644 --- a/nep245/src/lib.rs +++ b/nep245/src/lib.rs @@ -1,3 +1,4 @@ +mod checked; mod core; pub mod enumeration; mod events; @@ -7,6 +8,11 @@ mod token; use near_sdk::{AccountId, json_types::U128}; -pub use self::{core::*, events::*, token::*}; +pub use self::{ + checked::{ErrorLogTooLong, RefundCheckedMtEvent}, + core::*, + events::*, + token::*, +}; pub type ClearedApproval = (AccountId, u64, U128); diff --git a/sandbox/Cargo.toml b/sandbox/Cargo.toml index 180bb0a05..60e798392 100644 --- a/sandbox/Cargo.toml +++ b/sandbox/Cargo.toml @@ -18,6 +18,7 @@ rstest.workspace = true anyhow.workspace = true futures.workspace = true +libc = "0.2" impl-tools.workspace = true near-api.workspace = true near-openapi-types.workspace = true diff --git a/sandbox/src/extensions/mt.rs b/sandbox/src/extensions/mt.rs index 605122693..23885471e 100644 --- a/sandbox/src/extensions/mt.rs +++ b/sandbox/src/extensions/mt.rs @@ -43,6 +43,15 @@ pub trait MtExt { token_ids: impl IntoIterator, u128)>, msg: impl AsRef, ) -> anyhow::Result>; + + /// Same as `mt_on_transfer` but returns the full execution result for receipt inspection + async fn mt_on_transfer_raw( + &self, + sender_id: impl AsRef, + receiver_id: impl Into, + token_ids: impl IntoIterator, u128)>, + msg: impl AsRef, + ) -> anyhow::Result; } pub trait MtViewExt { @@ -153,6 +162,21 @@ impl MtExt for SigningAccount { token_ids: impl IntoIterator, u128)>, msg: impl AsRef, ) -> anyhow::Result> { + self.mt_on_transfer_raw(sender_id, receiver_id, token_ids, msg) + .await? + .into_result()? + .json::>() + .map(|refunds| refunds.into_iter().map(|a| a.0).collect()) + .map_err(Into::into) + } + + async fn mt_on_transfer_raw( + &self, + sender_id: impl AsRef, + receiver_id: impl Into, + token_ids: impl IntoIterator, u128)>, + msg: impl AsRef, + ) -> anyhow::Result { let (token_ids, amounts): (Vec<_>, Vec<_>) = token_ids .into_iter() .map(|(token_id, amount)| (token_id.into(), U128(amount))) @@ -166,10 +190,8 @@ impl MtExt for SigningAccount { "amounts": amounts, "msg": msg.as_ref(), }))) - .await? - .json::>() - .map(|refunds| refunds.into_iter().map(|a| a.0).collect()) - .map_err(Into::into) + .exec_transaction() + .await } } diff --git a/sandbox/src/lib.rs b/sandbox/src/lib.rs index 845c17bd3..f07d24532 100644 --- a/sandbox/src/lib.rs +++ b/sandbox/src/lib.rs @@ -4,12 +4,20 @@ pub mod helpers; pub mod tx; use std::sync::{ - Arc, + Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; +use tokio::sync::OnceCell; pub use account::{Account, SigningAccount}; +pub use extensions::{ + ft::{FtExt, FtViewExt}, + mt::{MtExt, MtViewExt}, + storage_management::{StorageManagementExt, StorageViewExt}, + wnear::{WNearDeployerExt, WNearExt}, +}; pub use helpers::*; +pub use tx::{FnCallBuilder, TxBuilder}; pub use anyhow; use impl_tools::autoimpl; @@ -22,7 +30,6 @@ use near_api::{NetworkConfig, RPCEndpoint, Signer, signer::generate_secret_key}; use near_sandbox::{GenesisAccount, SandboxConfig}; use near_sdk::{AccountId, AccountIdRef, NearToken}; use rstest::fixture; -use tokio::sync::OnceCell; use tracing::instrument; #[autoimpl(Deref using self.root)] @@ -81,28 +88,54 @@ impl Sandbox { pub fn sandbox(&self) -> &near_sandbox::Sandbox { self.sandbox.as_ref() } + + pub async fn fast_forward(&self, blocks: u64) { + self.sandbox.fast_forward(blocks).await.unwrap(); + } +} + +/// Shared sandbox instance for test fixtures. +/// Using `OnceCell>>` allows async init and taking ownership in atexit. +static SHARED_SANDBOX: OnceCell>> = OnceCell::const_new(); + +extern "C" fn cleanup_sandbox() { + if let Some(mutex) = SHARED_SANDBOX.get() { + if let Ok(mut guard) = mutex.lock() { + drop(guard.take()); + } + } } #[fixture] #[instrument] pub async fn sandbox(#[default(NearToken::from_near(100_000))] amount: NearToken) -> Sandbox { const SHARED_ROOT: &AccountIdRef = AccountIdRef::new_or_panic("test"); - - static SHARED_SANDBOX: OnceCell = OnceCell::const_new(); static SUB_COUNTER: AtomicUsize = AtomicUsize::new(0); - let shared = SHARED_SANDBOX - .get_or_init(|| Sandbox::new(SHARED_ROOT)) + let mutex = SHARED_SANDBOX + .get_or_init(|| async { + unsafe { + libc::atexit(cleanup_sandbox); + } + Mutex::new(Some(Sandbox::new(SHARED_ROOT).await)) + }) .await; + let (sandbox_arc, root_account) = mutex + .lock() + .unwrap() + .as_ref() + .map(|shared| (shared.sandbox.clone(), shared.root.clone())) + .unwrap(); + Sandbox { - root: shared + root: root_account .generate_subaccount( SUB_COUNTER.fetch_add(1, Ordering::Relaxed).to_string(), amount, ) .await .unwrap(), - sandbox: shared.sandbox.clone(), + sandbox: sandbox_arc, } } diff --git a/sandbox/src/tx/wrappers.rs b/sandbox/src/tx/wrappers.rs index 7d003155d..26ecb809a 100644 --- a/sandbox/src/tx/wrappers.rs +++ b/sandbox/src/tx/wrappers.rs @@ -46,7 +46,16 @@ impl Debug for TestExecutionOutcome<'_> { if let ValueOrReceiptId::Value(value) = v { let bytes = value.raw_bytes().unwrap(); if !bytes.is_empty() { - write!(f, ", OK: {bytes:?}")?; + if bytes.len() <= 32 { + write!(f, ", OK: {bytes:?}")?; + } else { + write!( + f, + ", OK: {:?}..{:?}", + &bytes[..16], + &bytes[bytes.len() - 16..] + )?; + } } } Ok(()) diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 252745ea7..ea8a8bc9f 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -37,3 +37,6 @@ serde_json.workspace = true strum.workspace = true tokio = { workspace = true, features = ["macros"] } tlb-ton = { workspace = true, features = ["arbitrary"] } + +[features] +long = [] diff --git a/tests/contracts/multi-token-receiver-stub/src/lib.rs b/tests/contracts/multi-token-receiver-stub/src/lib.rs index 8b841dcfd..117567c16 100644 --- a/tests/contracts/multi-token-receiver-stub/src/lib.rs +++ b/tests/contracts/multi-token-receiver-stub/src/lib.rs @@ -1,7 +1,40 @@ use defuse::core::payload::multi::MultiPayload; use defuse::intents::ext_intents; use defuse_nep245::{TokenId, receiver::MultiTokenReceiver}; -use near_sdk::{AccountId, PromiseOrValue, env, json_types::U128, near, serde_json}; +use near_sdk::{ + AccountId, Gas, GasWeight, NearToken, Promise, PromiseOrValue, env, json_types::U128, near, + serde_json, +}; + +// Raw extern function to generate and return bytes of specified length +// Input: 8-byte little-endian u64 specifying the length +#[cfg(target_arch = "wasm32")] +#[unsafe(no_mangle)] +pub extern "C" fn stub_return_bytes() { + if let Some(input) = near_sdk::env::input() { + if input.len() >= 8 { + let len = u64::from_le_bytes(input[..8].try_into().unwrap()) as usize; + let bytes = vec![0xf0u8; len]; + near_sdk::env::value_return(&bytes); + } + } +} + +trait ReturnValueExt: Sized { + fn stub_return_bytes(self, len: u64) -> Self; +} + +impl ReturnValueExt for Promise { + fn stub_return_bytes(self, len: u64) -> Self { + self.function_call_weight( + "stub_return_bytes", + len.to_le_bytes().to_vec(), + NearToken::ZERO, + Gas::from_ggas(0), + GasWeight(1), + ) + } +} /// Minimal stub contract used for integration tests. #[derive(Default)] @@ -14,6 +47,10 @@ pub struct Contract; pub enum MTReceiverMode { #[default] AcceptAll, + /// Refund all deposited amounts + RefundAll, + /// Return u128::MAX for each token (malicious refund attempt) + MaliciousRefund, ReturnValue(U128), ReturnValues(Vec), Panic, @@ -22,6 +59,8 @@ pub enum MTReceiverMode { multipayload: MultiPayload, refund_amounts: Vec, }, + /// Return raw bytes of specified length (for testing large return values) + ReturnBytes(U128), } #[near] @@ -34,15 +73,19 @@ impl MultiTokenReceiver for Contract { amounts: Vec, msg: String, ) -> PromiseOrValue> { - near_sdk::env::log_str(&format!( - "STUB::mt_on_transfer: sender_id={sender_id}, previous_owner_ids={previous_owner_ids:?}, token_ids={token_ids:?}, amounts={amounts:?}, msg={msg}" - )); + let _ = sender_id; + let _ = previous_owner_ids; + let _ = token_ids; let mode = serde_json::from_str(&msg).unwrap_or_default(); match mode { + MTReceiverMode::AcceptAll => PromiseOrValue::Value(vec![U128(0); amounts.len()]), + MTReceiverMode::RefundAll => PromiseOrValue::Value(amounts), + MTReceiverMode::MaliciousRefund => { + PromiseOrValue::Value(vec![U128(u128::MAX); amounts.len()]) + } MTReceiverMode::ReturnValue(value) => PromiseOrValue::Value(vec![value; amounts.len()]), MTReceiverMode::ReturnValues(values) => PromiseOrValue::Value(values), - MTReceiverMode::AcceptAll => PromiseOrValue::Value(vec![U128(0); amounts.len()]), MTReceiverMode::Panic => env::panic_str("MTReceiverMode::Panic"), // 16 * 250_000 = 4 MB, which is the limit for a contract return value MTReceiverMode::LargeReturn => PromiseOrValue::Value(vec![U128(u128::MAX); 250_000]), @@ -53,6 +96,9 @@ impl MultiTokenReceiver for Contract { .execute_intents(vec![multipayload]) .then(Self::ext(env::current_account_id()).return_refunds(refund_amounts)) .into(), + MTReceiverMode::ReturnBytes(len) => Promise::new(env::current_account_id()) + .stub_return_bytes(len.0.try_into().unwrap()) + .into(), } } } diff --git a/tests/src/tests/defuse/tokens/nep245/mod.rs b/tests/src/tests/defuse/tokens/nep245/mod.rs index 2797bbccd..410054824 100644 --- a/tests/src/tests/defuse/tokens/nep245/mod.rs +++ b/tests/src/tests/defuse/tokens/nep245/mod.rs @@ -1,6 +1,35 @@ mod letter_gen; +mod mt_deposit_resolve_gas; mod mt_transfer_resolve_gas; +use std::future::Future; + +/// Binary search to find the maximum value for which `test` succeeds. +pub(super) async fn binary_search_max(low: usize, high: usize, test: F) -> Option +where + F: Fn(usize) -> Fut, + Fut: Future>, +{ + let mut lo = low; + let mut hi = high; + let mut best = None; + + while lo <= hi { + let mid = lo + (hi - lo) / 2; + match test(mid).await { + Ok(()) => { + best = Some(mid); + lo = mid + 1; // success -> try higher + } + Err(_) => { + hi = mid - 1; // failure -> try lower + } + } + } + + best +} + use crate::tests::defuse::DefuseSignerExt; use crate::tests::defuse::env::{Env, MT_RECEIVER_STUB_WASM}; use defuse::contract::config::{DefuseConfig, RolesConfig}; @@ -17,6 +46,7 @@ use defuse::sandbox_ext::deployer::DefuseExt; use defuse::sandbox_ext::tokens::{nep141::DefuseFtWithdrawer, nep245::DefuseMtWithdrawer}; use defuse::tokens::DepositMessage; use defuse::tokens::{DepositAction, ExecuteIntents}; +use defuse_near_utils::REFUND_MEMO; use defuse_sandbox::assert_a_contains_b; use defuse_sandbox::extensions::mt::{MtExt, MtViewExt}; use defuse_sandbox::tx::FnCallBuilder; @@ -1628,7 +1658,7 @@ async fn mt_transfer_call_duplicate_tokens_with_stub_execute_and_refund() { authorized_id: None, token_ids: Cow::Borrowed(&mt_token_ids), amounts: Cow::Borrowed(&refund_amounts), - memo: Some(Cow::Borrowed("refund")), + memo: Some(Cow::Borrowed(REFUND_MEMO)), }]; let expected_mt_burn = MtEvent::MtBurn(Cow::Borrowed(&burn_events)); @@ -1638,7 +1668,7 @@ async fn mt_transfer_call_duplicate_tokens_with_stub_execute_and_refund() { new_owner_id: Cow::Borrowed(user.id().as_ref()), token_ids: Cow::Borrowed(&ft_token_ids), amounts: Cow::Borrowed(&refund_amounts), // Use capped refund amounts - memo: Some(Cow::Borrowed("refund")), + memo: Some(Cow::Borrowed(REFUND_MEMO)), }]; let expected_mt_transfer = MtEvent::MtTransfer(Cow::Borrowed(&transfer_events)); diff --git a/tests/src/tests/defuse/tokens/nep245/mt_deposit_resolve_gas.rs b/tests/src/tests/defuse/tokens/nep245/mt_deposit_resolve_gas.rs new file mode 100644 index 000000000..6259e3ea9 --- /dev/null +++ b/tests/src/tests/defuse/tokens/nep245/mt_deposit_resolve_gas.rs @@ -0,0 +1,430 @@ +use super::binary_search_max; +use crate::tests::defuse::{ + env::{Env, MT_RECEIVER_STUB_WASM}, + tokens::nep245::letter_gen::LetterCombinations, +}; +use anyhow::Context; +use defuse::{ + core::intents::tokens::NotifyOnTransfer, + nep245::{MtBurnEvent, MtEvent, MtMintEvent}, + tokens::{DepositAction, DepositMessage}, +}; +use defuse_near_utils::REFUND_MEMO; +use defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT; +use defuse_randomness::Rng; +use defuse_sandbox::{SigningAccount, extensions::mt::MtExt}; +use defuse_test_utils::random::{gen_random_string, rng}; +use multi_token_receiver_stub::MTReceiverMode; +use near_sdk::{AccountId, AsNep297Event, Gas, json_types::U128}; +use rstest::rstest; +use std::borrow::Cow; +use std::sync::Arc; + +/// Token ID generation modes to test different serialization/storage costs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +enum TokenIdGenerationMode { + /// Short: nep141 format with short account name + Short, + /// Medium token IDs: ~64 chars + Medium, + /// Long: nep245 format with implicit account (64 chars) and long token IDs (127 chars) + Long, +} + +async fn make_author_account(mode: TokenIdGenerationMode, env: &Env) -> SigningAccount { + use near_sdk::NearToken; + match mode { + TokenIdGenerationMode::Short => { + // Use root account directly: 0.test + env.root().clone() + } + TokenIdGenerationMode::Medium => { + // Use a 64-char named account: {name}.{root_id} = 64 chars total + const TARGET_LEN: usize = 64; + let root_id_len = env.root().id().as_str().len(); + // name_len + 1 (dot) + root_id_len = TARGET_LEN + let name_len = TARGET_LEN - 1 - root_id_len; + let name = "a".repeat(name_len); + env.root() + .generate_subaccount(name, NearToken::from_near(1000)) + .await + .unwrap() + } + TokenIdGenerationMode::Long => { + // Use implicit account (64 hex chars) for longest account ID + env.fund_implicit(NearToken::from_near(1000)).await.unwrap() + } + } +} + +fn make_defuse_token_ids( + mode: TokenIdGenerationMode, + author_account: &SigningAccount, + token_ids: &[String], +) -> Vec { + match mode { + // Short mode uses nep141 format: nep141:{token_id} + // where token_id serves as a short contract identifier + TokenIdGenerationMode::Short => token_ids + .iter() + .map(|token_id| format!("nep141:{token_id}")) + .collect(), + // Medium/Long modes use nep245 format: nep245:{contract_id}:{token_id} + TokenIdGenerationMode::Medium | TokenIdGenerationMode::Long => token_ids + .iter() + .map(|token_id| format!("nep245:{}:{}", author_account.id(), token_id)) + .collect(), + } +} + +fn make_token_ids( + mode: TokenIdGenerationMode, + rng: &mut impl Rng, + token_count: usize, +) -> Vec { + match mode { + TokenIdGenerationMode::Short => LetterCombinations::generate_combos(token_count), + TokenIdGenerationMode::Medium => { + const MEDIUM_TOKEN_ID_LEN: usize = 64; + + (1..=token_count) + .map(|i| { + format!( + "{}_{}", + i, + gen_random_string(rng, MEDIUM_TOKEN_ID_LEN..=MEDIUM_TOKEN_ID_LEN) + )[0..MEDIUM_TOKEN_ID_LEN] + .to_string() + }) + .collect::>() + } + TokenIdGenerationMode::Long => { + 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::>() + } + } +} + +fn make_amounts(mode: TokenIdGenerationMode, token_count: usize) -> Vec { + match mode { + // Short: minimal serialization cost + TokenIdGenerationMode::Short => (0..token_count).map(|_| 1u128).collect(), + // Medium: ~19 digit value + TokenIdGenerationMode::Medium => { + (0..token_count).map(|_| 1234567890123456789u128).collect() + } + // Long: ~39 digit value to maximize serialization cost and complicate refund logic + TokenIdGenerationMode::Long => (0..token_count) + .map(|_| 123456789123456789123456789123456789123u128) + .collect(), + } +} + +fn validate_mt_event_log_size( + owner_id: &AccountId, + token_ids: &[String], + amounts: &[u128], +) -> anyhow::Result<()> { + let mt_mint_event = MtEvent::MtMint(Cow::Owned(vec![MtMintEvent { + owner_id: Cow::Borrowed(owner_id), + token_ids: Cow::Owned(token_ids.to_vec()), + amounts: Cow::Owned(amounts.iter().copied().map(U128).collect()), + memo: None, + }])); + + let mt_burn_event = MtEvent::MtBurn(Cow::Owned(vec![MtBurnEvent { + owner_id: Cow::Borrowed(owner_id), + authorized_id: None, + token_ids: Cow::Owned(token_ids.to_vec()), + amounts: Cow::Owned(amounts.iter().copied().map(U128).collect()), + memo: Some(Cow::Borrowed(REFUND_MEMO)), + }])); + + let mint_log = mt_mint_event.to_nep297_event().to_event_log(); + let burn_log = mt_burn_event.to_nep297_event().to_event_log(); + + anyhow::ensure!( + mint_log.len() <= TOTAL_LOG_LENGTH_LIMIT, + "mint log will exceed maximum log limit" + ); + anyhow::ensure!( + burn_log.len() <= TOTAL_LOG_LENGTH_LIMIT, + "burn log will exceed maximum log limit" + ); + Ok(()) +} + +async fn run_deposit_resolve_gas_test( + gen_mode: TokenIdGenerationMode, + token_count: usize, + env: Arc, + author_account: SigningAccount, + receiver_id: AccountId, + rng: Arc>, +) -> anyhow::Result<()> { + println!("token count: {token_count}"); + let mut rng = rng.lock().await; + + let token_ids = make_token_ids(gen_mode, &mut rng, token_count); + let amounts = make_amounts(gen_mode, token_count); + + drop(rng); + + let deposit_message = DepositMessage { + receiver_id: receiver_id.clone(), + action: Some(DepositAction::Notify( + NotifyOnTransfer::new(serde_json::to_string(&MTReceiverMode::MaliciousRefund).unwrap()) + .with_min_gas(Gas::from_tgas(5)), + )), + }; + + let defuse_token_ids = make_defuse_token_ids(gen_mode, &author_account, &token_ids); + validate_mt_event_log_size(&receiver_id, &defuse_token_ids, &amounts)?; + let execution_result = author_account + .mt_on_transfer_raw( + author_account.id(), // sender_id (who the tokens are being deposited for) + env.defuse.id(), // defuse contract receives the deposit + token_ids.iter().cloned().zip(amounts.clone()), + serde_json::to_string(&deposit_message).unwrap(), + ) + .await + .context("Failed at mt_on_transfer (RPC error)")?; + + let defuse_outcomes: Vec<_> = execution_result + .outcomes() + .into_iter() + .filter(|o| o.executor_id == *env.defuse.id()) + .collect(); + + // NOTE: + // 1st receipt on defuse is the deposit + // 2nd receipt is resolve notification callback + // notification callback should panic/fail + if defuse_outcomes.len() == 2 { + let resolve_outcome = defuse_outcomes[1].clone(); + let resolve_result = resolve_outcome.into_result(); + assert!( + resolve_result.is_ok(), + "CRITICAL: mt_resolve_deposit callback failed for token_count={token_count}! \ + This indicates insufficient gas allocation in the contract. Error: {:?}", + resolve_result.err() + ); + } + // Capture total gas before consuming execution_result + let total_gas_tgas = execution_result.total_gas_burnt.as_tgas(); + + // Extract refund amounts from the final result + let refund_amounts = execution_result + .into_result() + .context("Transaction failed")? + .json::>() + .context("Failed to parse refund amounts")? + .into_iter() + .map(|a| a.0) + .collect::>(); + + // Verify all amounts were refunded (since stub returns full amounts) + assert_eq!( + refund_amounts, amounts, + "Expected full refund of all amounts" + ); + + println!( + "{{token_count: {token_count}, mode: {gen_mode}, gas: {total_gas_tgas} TGas}} - SUCCESS" + ); + + Ok(()) +} + +#[rstest] +#[tokio::test] +async fn mt_deposit_resolve_gas( + #[values( + TokenIdGenerationMode::Short, + TokenIdGenerationMode::Medium, + TokenIdGenerationMode::Long + )] + gen_mode: TokenIdGenerationMode, + #[values(true, false)] full_coverage: bool, + rng: impl Rng, +) { + use defuse_sandbox::tx::FnCallBuilder; + use near_sdk::NearToken; + + // Skip full_coverage=true when 'long' feature is disabled + #[cfg(not(feature = "long"))] + if full_coverage { + return; + } + // Skip full_coverage=false when 'long' feature is enabled + #[cfg(feature = "long")] + if !full_coverage { + return; + } + + let rng = Arc::new(tokio::sync::Mutex::new(rng)); + let env = Arc::new(Env::new().await); + + env.tx(env.defuse.id()) + .transfer(NearToken::from_near(1000)) + .await + .unwrap(); + + let receiver_stub = env + .deploy_sub_contract( + "receiver", + NearToken::from_near(100), + MT_RECEIVER_STUB_WASM.to_vec(), + None::, + ) + .await + .unwrap(); + + let author_account = make_author_account(gen_mode, &env).await; + let min_token_count = 1; + let max_token_count = 200; + + let max_deposited_count = binary_search_max(min_token_count, max_token_count, { + let rng = rng.clone(); + let env = env.clone(); + let author_account = author_account.clone(); + let receiver_id = receiver_stub.id().clone(); + move |token_count| { + run_deposit_resolve_gas_test( + gen_mode, + token_count, + env.clone(), + author_account.clone(), + receiver_id.clone(), + rng.clone(), + ) + } + }) + .await; + + let max_deposited_count = max_deposited_count.unwrap(); + + println!("Max token deposit per call for gen_mode={gen_mode} is: {max_deposited_count:?}"); + + let min_deposited_desired = 50; + assert!(max_deposited_count >= min_deposited_desired); + + run_deposit_resolve_gas_test( + gen_mode, + max_deposited_count, + env.clone(), + author_account.clone(), + receiver_stub.id().clone(), + rng.clone(), + ) + .await + .unwrap(); + + // When using full coverage mode, run the test for all token counts from 1 to max + // to ensure the invariant holds for every count, not just the maximum. + if full_coverage { + println!("Running exhaustive test for all token counts from 1 to {max_deposited_count}:"); + for token_count in 1..=max_deposited_count { + run_deposit_resolve_gas_test( + gen_mode, + token_count, + env.clone(), + author_account.clone(), + receiver_stub.id().clone(), + rng.clone(), + ) + .await + .unwrap(); + } + } +} + +#[tokio::test] +async fn mt_desposit_resolve_can_handle_large_blob_value_returned_from_notification() { + use defuse_sandbox::tx::FnCallBuilder; + use near_sdk::NearToken; + + let env = Arc::new(Env::new().await); + let amount = 1u128; + env.tx(env.defuse.id()) + .transfer(NearToken::from_near(1000)) + .await + .unwrap(); + + let receiver_stub = env + .deploy_sub_contract( + "receiver", + NearToken::from_near(100), + MT_RECEIVER_STUB_WASM.to_vec(), + None::, + ) + .await + .unwrap(); + + let author_account = env.fund_implicit(NearToken::from_near(1000)).await.unwrap(); + let deposit_message = DepositMessage { + receiver_id: receiver_stub.id().clone(), + action: Some(DepositAction::Notify( + NotifyOnTransfer::new( + serde_json::to_string(&MTReceiverMode::ReturnBytes(U128(3 * 1024 * 1024))).unwrap(), + ) + // NOTE: 300TGas - (10*2+4) + .with_min_gas(Gas::from_tgas(250)), + )), + }; + + let execution_result = author_account + .mt_on_transfer_raw( + author_account.id(), + env.defuse.id(), + [("testtoken1".to_string(), amount)], + serde_json::to_string(&deposit_message).unwrap(), + ) + .await + .expect("Failed at mt_on_transfer (RPC error)"); + + let defuse_outcomes: Vec<_> = execution_result + .outcomes() + .into_iter() + .filter(|o| o.executor_id == *env.defuse.id()) + .collect(); + + assert!( + defuse_outcomes.len() >= 2, + "Expected at least 2 defuse receipts, got {}", + defuse_outcomes.len() + ); + + let resolve_outcome = defuse_outcomes[1].clone(); + let resolve_result = resolve_outcome.into_result(); + assert!( + resolve_result.is_ok(), + "CRITICAL: mt_resolve_deposit callback failed! Error: {:?}", + resolve_result.err() + ); + + let refund_amounts = execution_result + .into_result() + .expect("Transaction failed") + .json::>() + .expect("Failed to parse refund amounts") + .into_iter() + .map(|a| a.0) + .collect::>(); + + assert_eq!( + refund_amounts, + vec![amount], + "Expected full refund of all amounts" + ); +} 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 80745726c..3e56f7c7f 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 @@ -1,4 +1,8 @@ -use crate::tests::defuse::{env::Env, tokens::nep245::letter_gen::LetterCombinations}; +use super::binary_search_max; +use crate::tests::defuse::{ + env::{Env, MT_RECEIVER_STUB_WASM}, + tokens::nep245::letter_gen::LetterCombinations, +}; use anyhow::Context; use arbitrary::Arbitrary; use defuse::{ @@ -8,17 +12,19 @@ use defuse::{ }, nep245::{MtEvent, MtTransferEvent}, }; +use defuse_near_utils::REFUND_MEMO; use defuse_randomness::Rng; use defuse_sandbox::{ SigningAccount, extensions::mt::{MtExt, MtViewExt}, + tx::FnCallBuilder, }; use defuse_test_utils::random::{gen_random_string, random_bytes, rng}; -use near_sdk::{AccountId, AsNep297Event}; -use near_sdk::{NearToken, json_types::U128}; +use multi_token_receiver_stub::MTReceiverMode; +use near_sdk::{AccountId, AsNep297Event, NearToken, json_types::U128}; use rstest::rstest; +use std::borrow::Cow; use std::sync::Arc; -use std::{borrow::Cow, future::Future}; use strum::IntoEnumIterator; const TOTAL_LOG_LENGTH_LIMIT: usize = 16384; @@ -86,7 +92,7 @@ fn validate_mt_batch_transfer_log_size( new_owner_id: Cow::Borrowed(sender_id), token_ids: Cow::Owned(token_ids.to_vec()), amounts: Cow::Owned(amounts.iter().copied().map(U128).collect()), - memo: Some(Cow::Borrowed("refund")), + memo: Some(Cow::Borrowed(REFUND_MEMO)), }])); let longest_transfer_log = mt_transfer_event.to_nep297_event().to_event_log(); @@ -225,31 +231,6 @@ async fn run_resolve_gas_test( Ok(()) } -async fn binary_search_max(low: usize, high: usize, test: F) -> Option -where - F: Fn(usize) -> Fut, - Fut: Future>, -{ - let mut lo = low; - let mut hi = high; - let mut best = None; - - while lo <= hi { - let mid = lo + (hi - lo) / 2; - match test(mid).await { - Ok(()) => { - best = Some(mid); - lo = mid + 1; // success -> try higher - } - Err(_) => { - hi = mid - 1; // failure -> try lower - } - } - } - - best -} - #[rstest] #[tokio::test] async fn mt_transfer_resolve_gas(rng: impl Rng) { @@ -313,3 +294,131 @@ async fn binary_search() { assert_eq!(binary_search_max(0, max, test).await, Some(limit)); } } + +#[tokio::test] +async fn mt_batch_transfer_call_rejects_transfer_when_refund_log_exceeds_limit() { + let env = Env::new().await; + let user = env.create_named_user("user").await; + + env.tx(env.defuse.id()) + .transfer(NearToken::from_near(1000)) + .await + .unwrap(); + + let author_account = env.fund_implicit(NearToken::from_near(1000)).await.unwrap(); + + let receiver_stub = env + .deploy_sub_contract( + "receiver", + NearToken::from_near(100), + MT_RECEIVER_STUB_WASM.to_vec(), + None::, + ) + .await + .unwrap(); + + let gen_max_len_token_id = |i: usize| format!("{i}{}", "a".repeat(127 - i.to_string().len())); + let token_ids: Vec = (1..=65) + .map(gen_max_len_token_id) + .chain([ + "1thiswilltriggertoolonglogerrorthiswilltriggertoolonglo".to_string(), + "2thiswilltriggertoolonglogerrorthiswilltriggertoolonglo".to_string(), + ]) + .collect(); + + let amounts: Vec = vec![u128::MAX; token_ids.len()]; + let defuse_token_ids: Vec = token_ids + .iter() + .map(|token_id| { + TokenId::Nep245(Nep245TokenId::new( + author_account.id().clone(), + token_id.clone(), + )) + .to_string() + }) + .collect(); + + let (transfer_log_size, refund_log_size) = + calculate_log_sizes(user.id(), receiver_stub.id(), &defuse_token_ids, &amounts); + + assert!(transfer_log_size <= TOTAL_LOG_LENGTH_LIMIT,); + assert!(refund_log_size > TOTAL_LOG_LENGTH_LIMIT,); + + author_account + .mt_on_transfer( + user.id(), + env.defuse.id(), + token_ids.iter().cloned().zip(amounts.clone()), + "", + ) + .await + .unwrap(); + + let balance_before = env + .defuse + .mt_balance_of(user.id(), &defuse_token_ids[0]) + .await + .unwrap(); + + let result = user + .mt_batch_transfer_call( + env.defuse.id(), + receiver_stub.id(), + defuse_token_ids.clone(), + amounts.clone(), + None, + serde_json::to_string(&MTReceiverMode::RefundAll).unwrap(), + ) + .await + .unwrap(); + + assert!( + result.is_failure(), + "transfer should fail early due to refund log size limit" + ); + + let result_str = format!("{result:?}"); + assert!( + result_str.contains("refund event log would be too long"), + "expected error about refund log limit, got: {result_str}" + ); + + let balance_after = env + .defuse + .mt_balance_of(user.id(), &defuse_token_ids[0]) + .await + .unwrap(); + + assert_eq!(balance_after, balance_before,); +} + +/// Calculate log sizes for transfer (no memo) and refund (with "refund" memo). +fn calculate_log_sizes( + sender_id: &AccountId, + receiver_id: &AccountId, + token_ids: &[String], + amounts: &[u128], +) -> (usize, usize) { + let transfer_event = MtEvent::MtTransfer(Cow::Owned(vec![MtTransferEvent { + authorized_id: None, + old_owner_id: Cow::Borrowed(sender_id), + new_owner_id: Cow::Borrowed(receiver_id), + token_ids: Cow::Owned(token_ids.to_vec()), + amounts: Cow::Owned(amounts.iter().copied().map(U128).collect()), + memo: None, // Transfer has no memo + }])); + + let refund_event = MtEvent::MtTransfer(Cow::Owned(vec![MtTransferEvent { + authorized_id: None, + old_owner_id: Cow::Borrowed(receiver_id), + new_owner_id: Cow::Borrowed(sender_id), + token_ids: Cow::Owned(token_ids.to_vec()), + amounts: Cow::Owned(amounts.iter().copied().map(U128).collect()), + memo: Some(Cow::Borrowed(REFUND_MEMO)), // Refund has "refund" memo + }])); + + let transfer_log_size = transfer_event.to_nep297_event().to_event_log().len(); + let refund_log_size = refund_event.to_nep297_event().to_event_log().len(); + + (transfer_log_size, refund_log_size) +}