Skip to content

Commit 1f2f541

Browse files
committed
feat: tests for deposit & transfer corner cases due
1 parent d38a46a commit 1f2f541

File tree

12 files changed

+756
-62
lines changed

12 files changed

+756
-62
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

defuse/src/contract/tokens/nep245/core.rs

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use defuse_core::{
33
DefuseError, Result, engine::StateView, intents::tokens::NotifyOnTransfer, token_id::TokenId,
44
};
55
use defuse_near_utils::{UnwrapOrPanic, UnwrapOrPanicError};
6-
use defuse_nep245::{MtEvent, MtTransferEvent, MultiTokenCore, receiver::ext_mt_receiver};
6+
use defuse_nep245::{
7+
MtEvent, MtTransferEvent, MultiTokenCore, TOTAL_LOG_LENGTH_LIMIT, receiver::ext_mt_receiver,
8+
};
79
use near_plugins::{Pausable, pause};
810
use near_sdk::{
911
AccountId, AccountIdRef, Gas, NearToken, Promise, PromiseOrValue, assert_one_yocto, env,
@@ -197,19 +199,19 @@ impl Contract {
197199
.ok_or(DefuseError::BalanceOverflow)?;
198200
}
199201

200-
MtEvent::MtTransfer(
201-
[MtTransferEvent {
202-
authorized_id: None,
203-
old_owner_id: sender_id.into(),
204-
new_owner_id: Cow::Borrowed(receiver_id),
205-
token_ids: token_ids.into(),
206-
amounts: amounts.into(),
207-
memo: memo.map(Into::into),
208-
}]
209-
.as_slice()
210-
.into(),
211-
)
212-
.emit();
202+
let transfer_event = MtTransferEvent {
203+
authorized_id: None,
204+
old_owner_id: sender_id.into(),
205+
new_owner_id: Cow::Borrowed(receiver_id),
206+
token_ids: token_ids.into(),
207+
amounts: amounts.into(),
208+
memo: memo.map(Into::into),
209+
};
210+
require!(
211+
transfer_event.refund_log_size() <= TOTAL_LOG_LENGTH_LIMIT,
212+
"too many tokens: refund log would exceed protocol limit"
213+
);
214+
MtEvent::MtTransfer([transfer_event].as_slice().into()).emit();
213215

214216
Ok(())
215217
}

nep245/src/events.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
use super::TokenId;
22
use derive_more::derive::From;
3-
use near_sdk::{AccountIdRef, json_types::U128, near, serde::Deserialize};
3+
use near_sdk::{AccountIdRef, AsNep297Event, json_types::U128, near, serde::Deserialize};
44
use std::borrow::Cow;
55

6+
/// NEAR protocol limit for log messages (16 KiB)
7+
pub const TOTAL_LOG_LENGTH_LIMIT: usize = 16384;
8+
69
#[must_use = "make sure to `.emit()` this event"]
710
#[near(event_json(standard = "nep245"))]
811
#[derive(Debug, Clone, Deserialize, From)]
@@ -53,6 +56,29 @@ pub struct MtTransferEvent<'a> {
5356
pub memo: Option<Cow<'a, str>>,
5457
}
5558

59+
impl MtTransferEvent<'_> {
60+
/// Calculate the size of a refund event log for this transfer.
61+
/// Creates a new event with swapped owner IDs and "refund" memo.
62+
#[must_use]
63+
pub fn refund_log_size(&self) -> usize {
64+
MtEvent::MtTransfer(
65+
[MtTransferEvent {
66+
authorized_id: None,
67+
old_owner_id: self.new_owner_id.clone(),
68+
new_owner_id: self.old_owner_id.clone(),
69+
token_ids: self.token_ids.clone(),
70+
amounts: self.amounts.clone(),
71+
memo: Some("refund".into()),
72+
}]
73+
.as_slice()
74+
.into(),
75+
)
76+
.to_nep297_event()
77+
.to_event_log()
78+
.len()
79+
}
80+
}
81+
5682
/// A trait that's used to make it possible to call `emit()` on the enum
5783
/// arms' contents without having to explicitly construct the enum `MtEvent` itself
5884
pub trait MtEventEmit<'a>: Into<MtEvent<'a>> {

sandbox/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ rstest.workspace = true
1818

1919
anyhow.workspace = true
2020
futures.workspace = true
21+
libc = "0.2"
2122
impl-tools.workspace = true
2223
near-api.workspace = true
2324
near-openapi-types.workspace = true

sandbox/src/extensions/mt.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ pub trait MtExt {
4343
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
4444
msg: impl AsRef<str>,
4545
) -> anyhow::Result<Vec<u128>>;
46+
47+
/// Same as `mt_on_transfer` but returns the full execution result for receipt inspection
48+
async fn mt_on_transfer_raw(
49+
&self,
50+
sender_id: impl AsRef<AccountIdRef>,
51+
receiver_id: impl Into<AccountId>,
52+
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
53+
msg: impl AsRef<str>,
54+
) -> anyhow::Result<ExecutionFinalResult>;
4655
}
4756

4857
pub trait MtViewExt {
@@ -153,6 +162,21 @@ impl MtExt for SigningAccount {
153162
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
154163
msg: impl AsRef<str>,
155164
) -> anyhow::Result<Vec<u128>> {
165+
self.mt_on_transfer_raw(sender_id, receiver_id, token_ids, msg)
166+
.await?
167+
.into_result()?
168+
.json::<Vec<U128>>()
169+
.map(|refunds| refunds.into_iter().map(|a| a.0).collect())
170+
.map_err(Into::into)
171+
}
172+
173+
async fn mt_on_transfer_raw(
174+
&self,
175+
sender_id: impl AsRef<AccountIdRef>,
176+
receiver_id: impl Into<AccountId>,
177+
token_ids: impl IntoIterator<Item = (impl Into<String>, u128)>,
178+
msg: impl AsRef<str>,
179+
) -> anyhow::Result<ExecutionFinalResult> {
156180
let (token_ids, amounts): (Vec<_>, Vec<_>) = token_ids
157181
.into_iter()
158182
.map(|(token_id, amount)| (token_id.into(), U128(amount)))
@@ -166,10 +190,8 @@ impl MtExt for SigningAccount {
166190
"amounts": amounts,
167191
"msg": msg.as_ref(),
168192
})))
169-
.await?
170-
.json::<Vec<U128>>()
171-
.map(|refunds| refunds.into_iter().map(|a| a.0).collect())
172-
.map_err(Into::into)
193+
.exec_transaction()
194+
.await
173195
}
174196
}
175197

sandbox/src/lib.rs

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@ pub mod helpers;
44
pub mod tx;
55

66
use std::sync::{
7-
Arc,
7+
Arc, Mutex,
88
atomic::{AtomicUsize, Ordering},
99
};
10+
use tokio::sync::OnceCell;
1011

1112
pub use account::{Account, SigningAccount};
13+
pub use extensions::{
14+
ft::{FtExt, FtViewExt},
15+
mt::{MtExt, MtViewExt},
16+
storage_management::{StorageManagementExt, StorageViewExt},
17+
wnear::{WNearDeployerExt, WNearExt},
18+
};
1219
pub use helpers::*;
20+
pub use tx::{FnCallBuilder, TxBuilder};
1321

1422
pub use anyhow;
1523
use impl_tools::autoimpl;
@@ -22,7 +30,6 @@ use near_api::{NetworkConfig, RPCEndpoint, Signer, signer::generate_secret_key};
2230
use near_sandbox::{GenesisAccount, SandboxConfig};
2331
use near_sdk::{AccountId, AccountIdRef, NearToken};
2432
use rstest::fixture;
25-
use tokio::sync::OnceCell;
2633
use tracing::instrument;
2734

2835
#[autoimpl(Deref using self.root)]
@@ -81,28 +88,54 @@ impl Sandbox {
8188
pub fn sandbox(&self) -> &near_sandbox::Sandbox {
8289
self.sandbox.as_ref()
8390
}
91+
92+
pub async fn fast_forward(&self, blocks: u64) {
93+
self.sandbox.fast_forward(blocks).await.unwrap();
94+
}
95+
}
96+
97+
/// Shared sandbox instance for test fixtures.
98+
/// Using `OnceCell<Mutex<Option<...>>>` allows async init and taking ownership in atexit.
99+
static SHARED_SANDBOX: OnceCell<Mutex<Option<Sandbox>>> = OnceCell::const_new();
100+
101+
extern "C" fn cleanup_sandbox() {
102+
if let Some(mutex) = SHARED_SANDBOX.get() {
103+
if let Ok(mut guard) = mutex.lock() {
104+
drop(guard.take());
105+
}
106+
}
84107
}
85108

86109
#[fixture]
87110
#[instrument]
88111
pub async fn sandbox(#[default(NearToken::from_near(100_000))] amount: NearToken) -> Sandbox {
89112
const SHARED_ROOT: &AccountIdRef = AccountIdRef::new_or_panic("test");
90-
91-
static SHARED_SANDBOX: OnceCell<Sandbox> = OnceCell::const_new();
92113
static SUB_COUNTER: AtomicUsize = AtomicUsize::new(0);
93114

94-
let shared = SHARED_SANDBOX
95-
.get_or_init(|| Sandbox::new(SHARED_ROOT))
115+
let mutex = SHARED_SANDBOX
116+
.get_or_init(|| async {
117+
unsafe {
118+
libc::atexit(cleanup_sandbox);
119+
}
120+
Mutex::new(Some(Sandbox::new(SHARED_ROOT).await))
121+
})
96122
.await;
97123

124+
let (sandbox_arc, root_account) = mutex
125+
.lock()
126+
.unwrap()
127+
.as_ref()
128+
.map(|shared| (shared.sandbox.clone(), shared.root.clone()))
129+
.unwrap();
130+
98131
Sandbox {
99-
root: shared
132+
root: root_account
100133
.generate_subaccount(
101134
SUB_COUNTER.fetch_add(1, Ordering::Relaxed).to_string(),
102135
amount,
103136
)
104137
.await
105138
.unwrap(),
106-
sandbox: shared.sandbox.clone(),
139+
sandbox: sandbox_arc,
107140
}
108141
}

sandbox/src/tx/wrappers.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,16 @@ impl Debug for TestExecutionOutcome<'_> {
4646
if let ValueOrReceiptId::Value(value) = v {
4747
let bytes = value.raw_bytes().unwrap();
4848
if !bytes.is_empty() {
49-
write!(f, ", OK: {bytes:?}")?;
49+
if bytes.len() <= 32 {
50+
write!(f, ", OK: {bytes:?}")?;
51+
} else {
52+
write!(
53+
f,
54+
", OK: {:?}..{:?}",
55+
&bytes[..16],
56+
&bytes[bytes.len() - 16..]
57+
)?;
58+
}
5059
}
5160
}
5261
Ok(())

tests/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ serde_json.workspace = true
3737
strum.workspace = true
3838
tokio = { workspace = true, features = ["macros"] }
3939
tlb-ton = { workspace = true, features = ["arbitrary"] }
40+
41+
[features]
42+
long = []

tests/contracts/multi-token-receiver-stub/src/lib.rs

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,40 @@
11
use defuse::core::payload::multi::MultiPayload;
22
use defuse::intents::ext_intents;
33
use defuse_nep245::{TokenId, receiver::MultiTokenReceiver};
4-
use near_sdk::{AccountId, PromiseOrValue, env, json_types::U128, near, serde_json};
4+
use near_sdk::{
5+
AccountId, Gas, GasWeight, NearToken, Promise, PromiseOrValue, env, json_types::U128, near,
6+
serde_json,
7+
};
8+
9+
// Raw extern function to generate and return bytes of specified length
10+
// Input: 8-byte little-endian u64 specifying the length
11+
#[cfg(target_arch = "wasm32")]
12+
#[unsafe(no_mangle)]
13+
pub extern "C" fn stub_return_bytes() {
14+
if let Some(input) = near_sdk::env::input() {
15+
if input.len() >= 8 {
16+
let len = u64::from_le_bytes(input[..8].try_into().unwrap()) as usize;
17+
let bytes = vec![0xf0u8; len];
18+
near_sdk::env::value_return(&bytes);
19+
}
20+
}
21+
}
22+
23+
trait ReturnValueExt: Sized {
24+
fn stub_return_bytes(self, len: u64) -> Self;
25+
}
26+
27+
impl ReturnValueExt for Promise {
28+
fn stub_return_bytes(self, len: u64) -> Self {
29+
self.function_call_weight(
30+
"stub_return_bytes",
31+
len.to_le_bytes().to_vec(),
32+
NearToken::ZERO,
33+
Gas::from_ggas(0),
34+
GasWeight(1),
35+
)
36+
}
37+
}
538

639
/// Minimal stub contract used for integration tests.
740
#[derive(Default)]
@@ -14,6 +47,10 @@ pub struct Contract;
1447
pub enum MTReceiverMode {
1548
#[default]
1649
AcceptAll,
50+
/// Refund all deposited amounts
51+
RefundAll,
52+
/// Return u128::MAX for each token (malicious refund attempt)
53+
MaliciousRefund,
1754
ReturnValue(U128),
1855
ReturnValues(Vec<U128>),
1956
Panic,
@@ -22,6 +59,8 @@ pub enum MTReceiverMode {
2259
multipayload: MultiPayload,
2360
refund_amounts: Vec<U128>,
2461
},
62+
/// Return raw bytes of specified length (for testing large return values)
63+
ReturnBytes(U128),
2564
}
2665

2766
#[near]
@@ -34,15 +73,19 @@ impl MultiTokenReceiver for Contract {
3473
amounts: Vec<U128>,
3574
msg: String,
3675
) -> PromiseOrValue<Vec<U128>> {
37-
near_sdk::env::log_str(&format!(
38-
"STUB::mt_on_transfer: sender_id={sender_id}, previous_owner_ids={previous_owner_ids:?}, token_ids={token_ids:?}, amounts={amounts:?}, msg={msg}"
39-
));
76+
let _ = sender_id;
77+
let _ = previous_owner_ids;
78+
let _ = token_ids;
4079
let mode = serde_json::from_str(&msg).unwrap_or_default();
4180

4281
match mode {
82+
MTReceiverMode::AcceptAll => PromiseOrValue::Value(vec![U128(0); amounts.len()]),
83+
MTReceiverMode::RefundAll => PromiseOrValue::Value(amounts),
84+
MTReceiverMode::MaliciousRefund => {
85+
PromiseOrValue::Value(vec![U128(u128::MAX); amounts.len()])
86+
}
4387
MTReceiverMode::ReturnValue(value) => PromiseOrValue::Value(vec![value; amounts.len()]),
4488
MTReceiverMode::ReturnValues(values) => PromiseOrValue::Value(values),
45-
MTReceiverMode::AcceptAll => PromiseOrValue::Value(vec![U128(0); amounts.len()]),
4689
MTReceiverMode::Panic => env::panic_str("MTReceiverMode::Panic"),
4790
// 16 * 250_000 = 4 MB, which is the limit for a contract return value
4891
MTReceiverMode::LargeReturn => PromiseOrValue::Value(vec![U128(u128::MAX); 250_000]),
@@ -53,6 +96,9 @@ impl MultiTokenReceiver for Contract {
5396
.execute_intents(vec![multipayload])
5497
.then(Self::ext(env::current_account_id()).return_refunds(refund_amounts))
5598
.into(),
99+
MTReceiverMode::ReturnBytes(len) => Promise::new(env::current_account_id())
100+
.stub_return_bytes(len.0 as u64)
101+
.into(),
56102
}
57103
}
58104
}

0 commit comments

Comments
 (0)