Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -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"]
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ jobs:
with:
cache: false
- name: Install cargo-audit
run: cargo install cargo-audit --version "^0.21" --locked
# 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/[email protected]
with:
token: ${{ secrets.GITHUB_TOKEN }}
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 16 additions & 14 deletions defuse/src/contract/tokens/nep245/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use defuse_core::{
DefuseError, Result, engine::StateView, intents::tokens::NotifyOnTransfer, token_id::TokenId,
};
use defuse_near_utils::{UnwrapOrPanic, UnwrapOrPanicError};
use defuse_nep245::{MtEvent, MtTransferEvent, MultiTokenCore, receiver::ext_mt_receiver};
use defuse_nep245::{
MtEvent, MtTransferEvent, MultiTokenCore, TOTAL_LOG_LENGTH_LIMIT, receiver::ext_mt_receiver,
};
use near_plugins::{Pausable, pause};
use near_sdk::{
AccountId, AccountIdRef, Gas, NearToken, Promise, PromiseOrValue, assert_one_yocto, env,
Expand Down Expand Up @@ -197,19 +199,19 @@ impl Contract {
.ok_or(DefuseError::BalanceOverflow)?;
}

MtEvent::MtTransfer(
[MtTransferEvent {
authorized_id: None,
old_owner_id: sender_id.into(),
new_owner_id: Cow::Borrowed(receiver_id),
token_ids: token_ids.into(),
amounts: amounts.into(),
memo: memo.map(Into::into),
}]
.as_slice()
.into(),
)
.emit();
let transfer_event = MtTransferEvent {
authorized_id: None,
old_owner_id: sender_id.into(),
new_owner_id: Cow::Borrowed(receiver_id),
token_ids: token_ids.into(),
amounts: amounts.into(),
memo: memo.map(Into::into),
};
require!(
transfer_event.refund_log_size() <= TOTAL_LOG_LENGTH_LIMIT,
"too many tokens: refund log would exceed protocol limit"
);
MtEvent::MtTransfer([transfer_event].as_slice().into()).emit();
Comment on lines +202 to +214
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe avoid spending gas on serializing twice?

let event_str = MtEvent::MtTransfer(/* ... */)
    .to_nep297_event()
    .to_event_log();
// check that refund event, if happens, would not exceed log limits
require!(event_str.len() <= TOTAL_LOG_LENGTH_LIMIT - r#","memo":"refund""#.len());
env::log_str(event_str);


Ok(())
}
Expand Down
28 changes: 27 additions & 1 deletion nep245/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use super::TokenId;
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;

/// NEAR protocol limit for log messages (16 KiB)
pub const TOTAL_LOG_LENGTH_LIMIT: usize = 16384;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe move it to defuse-near-utils crate?


#[must_use = "make sure to `.emit()` this event"]
#[near(event_json(standard = "nep245"))]
#[derive(Debug, Clone, Deserialize, From)]
Expand Down Expand Up @@ -53,6 +56,29 @@ pub struct MtTransferEvent<'a> {
pub memo: Option<Cow<'a, str>>,
}

impl MtTransferEvent<'_> {
/// Calculate the size of a refund event log for this transfer.
/// Creates a new event with swapped owner IDs and "refund" memo.
#[must_use]
pub fn refund_log_size(&self) -> usize {
MtEvent::MtTransfer(
[MtTransferEvent {
authorized_id: None,
old_owner_id: self.new_owner_id.clone(),
new_owner_id: self.old_owner_id.clone(),
token_ids: self.token_ids.clone(),
amounts: self.amounts.clone(),
memo: Some("refund".into()),
}]
.as_slice()
.into(),
)
.to_nep297_event()
.to_event_log()
.len()
}
}

/// A trait that's used to make it possible to call `emit()` on the enum
/// arms' contents without having to explicitly construct the enum `MtEvent` itself
pub trait MtEventEmit<'a>: Into<MtEvent<'a>> {
Expand Down
1 change: 1 addition & 0 deletions sandbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 26 additions & 4 deletions sandbox/src/extensions/mt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ pub trait MtExt {
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
msg: impl AsRef<str>,
) -> anyhow::Result<Vec<u128>>;

/// 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<AccountIdRef>,
receiver_id: impl Into<AccountId>,
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
msg: impl AsRef<str>,
) -> anyhow::Result<ExecutionFinalResult>;
}

pub trait MtViewExt {
Expand Down Expand Up @@ -153,6 +162,21 @@ impl MtExt for SigningAccount {
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
msg: impl AsRef<str>,
) -> anyhow::Result<Vec<u128>> {
self.mt_on_transfer_raw(sender_id, receiver_id, token_ids, msg)
.await?
.into_result()?
.json::<Vec<U128>>()
.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<AccountIdRef>,
receiver_id: impl Into<AccountId>,
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
msg: impl AsRef<str>,
) -> anyhow::Result<ExecutionFinalResult> {
let (token_ids, amounts): (Vec<_>, Vec<_>) = token_ids
.into_iter()
.map(|(token_id, amount)| (token_id.into(), U128(amount)))
Expand All @@ -166,10 +190,8 @@ impl MtExt for SigningAccount {
"amounts": amounts,
"msg": msg.as_ref(),
})))
.await?
.json::<Vec<U128>>()
.map(|refunds| refunds.into_iter().map(|a| a.0).collect())
.map_err(Into::into)
.exec_transaction()
.await
}
}

Expand Down
49 changes: 41 additions & 8 deletions sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -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<Mutex<Option<...>>>` allows async init and taking ownership in atexit.
static SHARED_SANDBOX: OnceCell<Mutex<Option<Sandbox>>> = 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());
}
}
Comment on lines +97 to +106
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool! Though, I thought there is a second reason for it not to be killed: #[tokio::test] creates a separate tokio runtime for each test, while
tokio::process::Command::kill_on_drop() (added here) seems to work within a single tokio runtime instance, but I might be wrong here.

PS: wrapping in Option might be redundant due to existence of OnceCell::take()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tried, but

E0596: cannot borrow immutable static item `SHARED_SANDBOX` as mutable

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tokio::process::Command::kill_on_drop()

could help but since its kept in static variable, the value is never dropped ...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E0596: cannot borrow immutable static item `SHARED_SANDBOX` as mutable

Okay, then maybe get rid of OnceCell entirely? it seems to be redundant, since Mutex<Option<...>> should work, too.

As for kill_on_drop: since you're touching this part of code, can you also evaluate if tokio_shared_rt would help here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oncecell guaranteeds that it will initialize sandbox only once, but yeah since we are only setting it bac to none after tests are finished it should be fine to live without it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi: confirmed that near-sandbox is killed in case of successful execution and on ctrl+c

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually oncecell is quite helpful here due to get_or_init functionatliy. otherwise you need to manually lock the mutex, then there is async call to create sandbox and you dont want to keep mutex locked between await points so the implementation becomes ugly and complicated IMO. so i decided to keep oncecell since its semantically comaptible with what we want => initialize sanbox only once per test run (binary call)

}

#[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<Sandbox> = 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,
}
}
11 changes: 10 additions & 1 deletion sandbox/src/tx/wrappers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
3 changes: 3 additions & 0 deletions tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ serde_json.workspace = true
strum.workspace = true
tokio = { workspace = true, features = ["macros"] }
tlb-ton = { workspace = true, features = ["arbitrary"] }

[features]
long = []
56 changes: 51 additions & 5 deletions tests/contracts/multi-token-receiver-stub/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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<U128>),
Panic,
Expand All @@ -22,6 +59,8 @@ pub enum MTReceiverMode {
multipayload: MultiPayload,
refund_amounts: Vec<U128>,
},
/// Return raw bytes of specified length (for testing large return values)
ReturnBytes(U128),
}

#[near]
Expand All @@ -34,15 +73,19 @@ impl MultiTokenReceiver for Contract {
amounts: Vec<U128>,
msg: String,
) -> PromiseOrValue<Vec<U128>> {
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]),
Expand All @@ -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 as u64)
.into(),
}
}
}
Expand Down
Loading
Loading