Skip to main content

defuse_nep245/
checked.rs

1use super::ErrorLogTooLong;
2use crate::MtEvent;
3use defuse_near_utils::REFUND_MEMO;
4use near_sdk::events::AsNep297Event;
5
6const REFUND_STR_LEN: usize = REFUND_MEMO.len();
7pub const REFUND_EXTRA_BYTES: usize = r#","memo":"""#.len() + REFUND_STR_LEN;
8
9/// A validated event log that has been checked for refund overhead.
10/// Use [`CheckedMtEvent::emit`] to emit the event.
11#[derive(Debug)]
12#[must_use = "call `.emit()` to emit the event"]
13pub struct CheckedMtEvent(pub(crate) String);
14
15impl CheckedMtEvent {
16    pub fn emit(self) {
17        near_sdk::env::log_str(&self.0);
18    }
19}
20
21#[derive(Default, Clone, Copy)]
22#[must_use]
23struct RefundLogDelta {
24    overhead: usize,
25    savings: usize,
26}
27
28impl RefundLogDelta {
29    const fn new(overhead: usize, savings: usize) -> Self {
30        Self {
31            overhead: overhead.saturating_sub(savings),
32            savings: savings.saturating_sub(overhead),
33        }
34    }
35
36    const fn overhead(self) -> usize {
37        self.overhead
38    }
39
40    const fn savings(self) -> usize {
41        self.savings
42    }
43
44    const fn saturating_add(self, other: Self) -> Self {
45        Self::new(
46            self.overhead.saturating_add(other.overhead),
47            self.savings.saturating_add(other.savings),
48        )
49    }
50}
51
52const fn refund_log_delta(memo: Option<&str>) -> RefundLogDelta {
53    let Some(m) = memo else {
54        return RefundLogDelta {
55            overhead: REFUND_EXTRA_BYTES,
56            savings: 0,
57        };
58    };
59    RefundLogDelta::new(REFUND_STR_LEN, m.len())
60}
61
62impl MtEvent<'_> {
63    /// Validates that the event log (including potential refund overhead) fits within limits.
64    /// Returns a [`CheckedMtEvent`] that can be emitted.
65    pub fn check_refund(self) -> Result<CheckedMtEvent, ErrorLogTooLong> {
66        let log = self.to_nep297_event().to_event_log();
67        let delta = self.compute_refund_delta();
68        let refund_len = log
69            .len()
70            .saturating_add(delta.overhead())
71            .saturating_sub(delta.savings());
72
73        if refund_len > defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT {
74            return Err(ErrorLogTooLong);
75        }
76        Ok(CheckedMtEvent(log))
77    }
78
79    fn compute_refund_delta(&self) -> RefundLogDelta {
80        match self {
81            MtEvent::MtMint(events) => events
82                .iter()
83                .map(|e| refund_log_delta(e.memo.as_deref()))
84                .fold(RefundLogDelta::default(), RefundLogDelta::saturating_add),
85            MtEvent::MtBurn(events) => events
86                .iter()
87                .map(|e| refund_log_delta(e.memo.as_deref()))
88                .fold(RefundLogDelta::default(), RefundLogDelta::saturating_add),
89            MtEvent::MtTransfer(events) => events
90                .iter()
91                .map(|e| refund_log_delta(e.memo.as_deref()))
92                .fold(RefundLogDelta::default(), RefundLogDelta::saturating_add),
93        }
94    }
95}
96
97#[cfg(test)]
98mod test {
99    use std::borrow::Cow;
100
101    use near_account_id::AccountId;
102    use near_sdk::AsNep297Event;
103
104    use super::refund_log_delta;
105    use crate::MtTransferEvent;
106    use crate::checked::{REFUND_EXTRA_BYTES, REFUND_STR_LEN};
107    use crate::{ErrorLogTooLong, MtEvent};
108
109    #[test]
110    fn test_refund_log_delta_shorter_memo() {
111        let delta = refund_log_delta(Some("r"));
112        assert_eq!(delta.savings(), 0);
113        assert_eq!(delta.overhead(), 5);
114    }
115
116    #[test]
117    fn test_refund_log_delta_longer_memo() {
118        let delta = refund_log_delta(Some("refund123"));
119        assert_eq!(delta.savings(), 3);
120        assert_eq!(delta.overhead(), 0);
121    }
122
123    #[test]
124    fn test_refund_log_delta_equal_memo() {
125        let delta = refund_log_delta(Some("123456"));
126        assert_eq!(delta.savings(), 0);
127        assert_eq!(delta.overhead(), 0);
128    }
129
130    #[test]
131    fn test_refund_log_delta_empty_memo() {
132        let delta = refund_log_delta(None);
133        assert_eq!(delta.savings(), 0);
134        assert_eq!(delta.overhead(), REFUND_EXTRA_BYTES);
135    }
136
137    /// Create a single-event `MtTransfer` with exact log length.
138    /// Pads `token_id` to achieve the desired length.
139    fn create_single_event_mt(length: usize, memo: Option<&str>) -> MtEvent<'static> {
140        let old_owner: AccountId = "aa".parse().unwrap();
141        let new_owner: AccountId = "bb".parse().unwrap();
142        let base_token_id = "t";
143
144        // Measure base log length
145        let base_event = MtTransferEvent {
146            authorized_id: None,
147            old_owner_id: Cow::Owned(old_owner.clone()),
148            new_owner_id: Cow::Owned(new_owner.clone()),
149            token_ids: Cow::Owned(vec![base_token_id.to_string()]),
150            amounts: Cow::Owned(vec![1]),
151            memo: memo.map(|m| Cow::Owned(m.to_string())),
152        };
153        let base_mt_event = MtEvent::MtTransfer(Cow::Owned(vec![base_event]));
154        let base_length = base_mt_event.to_nep297_event().to_event_log().len();
155
156        // Calculate padding needed for token_id
157        let padding_needed = length.saturating_sub(base_length);
158        let padded_token_id = format!("{}{}", base_token_id, "x".repeat(padding_needed));
159
160        let event = MtTransferEvent {
161            authorized_id: None,
162            old_owner_id: Cow::Owned(old_owner),
163            new_owner_id: Cow::Owned(new_owner),
164            token_ids: Cow::Owned(vec![padded_token_id]),
165            amounts: Cow::Owned(vec![1]),
166            memo: memo.map(|m| Cow::Owned(m.to_string())),
167        };
168
169        let mt_event = MtEvent::MtTransfer(Cow::Owned(vec![event]));
170        let log_len = mt_event.to_nep297_event().to_event_log().len();
171        assert_eq!(
172            log_len, length,
173            "Expected log length {length}, got {log_len}"
174        );
175
176        mt_event
177    }
178
179    /// Create a triple-event `MtTransfer` with exact log length.
180    /// Each event has its own memo. Pads first event's `token_id` to achieve the desired length.
181    fn create_triple_event_mt(length: usize, memos: [Option<&str>; 3]) -> MtEvent<'static> {
182        let old_owner: AccountId = "aa".parse().unwrap();
183        let new_owner: AccountId = "bb".parse().unwrap();
184        let base_token_id = "t";
185
186        // Measure base log length with 3 events
187        let base_events: Vec<MtTransferEvent<'static>> = memos
188            .iter()
189            .enumerate()
190            .map(|(i, memo)| MtTransferEvent {
191                authorized_id: None,
192                old_owner_id: Cow::Owned(old_owner.clone()),
193                new_owner_id: Cow::Owned(new_owner.clone()),
194                token_ids: Cow::Owned(vec![format!("{base_token_id}{i}")]),
195                amounts: Cow::Owned(vec![1]),
196                memo: memo.map(|m| Cow::Owned(m.to_string())),
197            })
198            .collect();
199        let base_mt_event = MtEvent::MtTransfer(Cow::Owned(base_events));
200        let base_length = base_mt_event.to_nep297_event().to_event_log().len();
201
202        // Calculate padding needed (only pad the first event's token_id)
203        let padding_needed = length.saturating_sub(base_length);
204        let padded_token_id = format!("{base_token_id}0{}", "x".repeat(padding_needed));
205
206        // Create final events: first one with padded token_id, rest with base token_ids
207        let events: Vec<MtTransferEvent<'static>> = memos
208            .iter()
209            .enumerate()
210            .map(|(i, memo)| {
211                let token_id = if i == 0 {
212                    padded_token_id.clone()
213                } else {
214                    format!("{base_token_id}{i}")
215                };
216                MtTransferEvent {
217                    authorized_id: None,
218                    old_owner_id: Cow::Owned(old_owner.clone()),
219                    new_owner_id: Cow::Owned(new_owner.clone()),
220                    token_ids: Cow::Owned(vec![token_id]),
221                    amounts: Cow::Owned(vec![1]),
222                    memo: memo.map(|m| Cow::Owned(m.to_string())),
223                }
224            })
225            .collect();
226
227        let mt_event = MtEvent::MtTransfer(Cow::Owned(events));
228        let log_len = mt_event.to_nep297_event().to_event_log().len();
229        assert_eq!(
230            log_len, length,
231            "Expected log length {length}, got {log_len}"
232        );
233
234        mt_event
235    }
236
237    #[test]
238    fn single_event_no_memo_at_limit_minus_overhead_passes() {
239        let mt = create_single_event_mt(
240            defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT - REFUND_EXTRA_BYTES,
241            None,
242        );
243        assert!(mt.check_refund().is_ok());
244    }
245
246    #[test]
247    fn single_event_short_memo_at_limit_fails() {
248        let memo = "refu";
249        let mt = create_single_event_mt(defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT, Some(memo));
250        assert!(matches!(mt.check_refund().unwrap_err(), ErrorLogTooLong));
251    }
252
253    #[test]
254    fn triple_event_no_memo_at_limit_minus_overhead_passes() {
255        let mt = create_triple_event_mt(
256            defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT - 3 * REFUND_EXTRA_BYTES,
257            [None; 3],
258        );
259        assert!(mt.check_refund().is_ok());
260    }
261
262    #[test]
263    fn triple_event_short_memo_at_limit_fails() {
264        let mt =
265            create_triple_event_mt(defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT, [Some("refu"); 3]);
266        assert!(matches!(mt.check_refund().unwrap_err(), ErrorLogTooLong));
267    }
268
269    #[test]
270    fn triple_event_mixed_memos_overhead_equals_savings_at_limit_passes() {
271        // there are 3 events
272        // 1 without memo
273        // 2 with "refund" memo
274        // 3 with really long memo
275        // total log length is exactly TOTAL_LOG_LENGTH_LIMIT, but since really long memo will be
276        // replaced with just refund there will be enough buffer to set memo "refund" also for
277        // first event and still fit into TOTAL_LOG_LENGTH_LIMIT on refund
278        let long_memo = "x".repeat(REFUND_EXTRA_BYTES + REFUND_STR_LEN);
279        assert_eq!(long_memo.len() - REFUND_STR_LEN, REFUND_EXTRA_BYTES);
280
281        let mt = create_triple_event_mt(
282            defuse_near_utils::TOTAL_LOG_LENGTH_LIMIT,
283            [None, Some("refund"), Some(&long_memo)],
284        );
285        assert!(mt.check_refund().is_ok());
286    }
287}