defuse_core/intents/
tokens.rs

1use std::{borrow::Cow, collections::BTreeMap};
2
3use near_contract_standards::non_fungible_token;
4use near_sdk::{AccountId, AccountIdRef, CryptoHash, Gas, NearToken, json_types::U128, near};
5use serde_with::{DisplayFromStr, serde_as};
6
7use crate::{
8    DefuseError, Result,
9    accounts::AccountEvent,
10    amounts::Amounts,
11    engine::{Engine, Inspector, State},
12    events::DefuseEvent,
13};
14
15use super::{ExecutableIntent, IntentEvent};
16
17#[cfg_attr(
18    all(feature = "abi", not(target_arch = "wasm32")),
19    serde_as(schemars = true)
20)]
21#[cfg_attr(
22    not(all(feature = "abi", not(target_arch = "wasm32"))),
23    serde_as(schemars = false)
24)]
25#[near(serializers = [borsh, json])]
26#[derive(Debug, Clone)]
27/// Transfer a set of tokens from the signer to a specified account id, within the intents contract.
28pub struct Transfer {
29    pub receiver_id: AccountId,
30
31    #[serde_as(as = "Amounts<BTreeMap<_, DisplayFromStr>>")]
32    pub tokens: Amounts,
33
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub memo: Option<String>,
36}
37
38impl ExecutableIntent for Transfer {
39    fn execute_intent<S, I>(
40        self,
41        sender_id: &AccountIdRef,
42        engine: &mut Engine<S, I>,
43        intent_hash: CryptoHash,
44    ) -> Result<()>
45    where
46        S: State,
47        I: Inspector,
48    {
49        if sender_id == self.receiver_id || self.tokens.is_empty() {
50            return Err(DefuseError::InvalidIntent);
51        }
52
53        engine
54            .inspector
55            .on_event(DefuseEvent::Transfer(Cow::Borrowed(
56                [IntentEvent::new(
57                    AccountEvent::new(sender_id, Cow::Borrowed(&self)),
58                    intent_hash,
59                )]
60                .as_slice(),
61            )));
62
63        engine
64            .state
65            .internal_sub_balance(sender_id, self.tokens.clone())?;
66        engine
67            .state
68            .internal_add_balance(self.receiver_id, self.tokens)?;
69        Ok(())
70    }
71}
72
73#[near(serializers = [borsh, json])]
74#[derive(Debug, Clone)]
75/// Withdraw given FT tokens from the intents contract to a given external account id (external being outside of intents).
76pub struct FtWithdraw {
77    pub token: AccountId,
78    pub receiver_id: AccountId,
79    pub amount: U128,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub memo: Option<String>,
82
83    /// Message to pass to `ft_transfer_call`. Otherwise, `ft_transfer` will be used.
84    /// NOTE: No refund will be made in case of insufficient `storage_deposit`
85    /// on `token` for `receiver_id`
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub msg: Option<String>,
88
89    /// Optionally make `storage_deposit` for `receiver_id` on `token`.
90    /// The amount will be subtracted from user's NEP-141 `wNEAR` balance.
91    /// NOTE: the `wNEAR` will not be refunded in case of fail
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub storage_deposit: Option<NearToken>,
94
95    /// Optional minimum required Near gas for created Promise to succeed:
96    /// * `ft_transfer`:      minimum: 15TGas, default: 15TGas
97    /// * `ft_transfer_call`: minimum: 30TGas, default: 50TGas
98    ///
99    /// Remaining gas will be distributed evenly across all Function Call
100    /// Promises created during execution of current receipt.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub min_gas: Option<Gas>,
103}
104
105impl FtWithdraw {
106    const FT_TRANSFER_GAS_MIN: Gas = Gas::from_tgas(15);
107    const FT_TRANSFER_GAS_DEFAULT: Gas = Gas::from_tgas(15);
108
109    /// Taken from [near-contract-standards](https://github.com/near/near-sdk-rs/blob/985c16b8fffc623096d0b7e60b26746842a2d712/near-contract-standards/src/fungible_token/core_impl.rs#L137)
110    const FT_TRANSFER_CALL_GAS_MIN: Gas = Gas::from_tgas(30);
111    const FT_TRANSFER_CALL_GAS_DEFAULT: Gas = Gas::from_tgas(50);
112
113    /// Returns whether it's `ft_transfer_call()`
114    #[inline]
115    pub const fn is_call(&self) -> bool {
116        self.msg.is_some()
117    }
118
119    /// Returns minimum required gas
120    #[inline]
121    pub fn min_gas(&self) -> Gas {
122        let (min, default) = if self.is_call() {
123            (
124                Self::FT_TRANSFER_CALL_GAS_MIN,
125                Self::FT_TRANSFER_CALL_GAS_DEFAULT,
126            )
127        } else {
128            (Self::FT_TRANSFER_GAS_MIN, Self::FT_TRANSFER_GAS_DEFAULT)
129        };
130
131        self.min_gas
132            .unwrap_or(default)
133            // We need to set hard minimum for gas to prevent loss of funds
134            // due to insufficient gas:
135            // 1. We don't refund wNEAR taken for `storage_deposit()`,
136            //    which is executed in the same receipt as `ft_transfer[_call]()`
137            // 2. We don't refund if `ft_transfer_call()` Promise fails
138            .max(min)
139    }
140}
141
142impl ExecutableIntent for FtWithdraw {
143    #[inline]
144    fn execute_intent<S, I>(
145        self,
146        owner_id: &AccountIdRef,
147        engine: &mut Engine<S, I>,
148        intent_hash: CryptoHash,
149    ) -> Result<()>
150    where
151        S: State,
152        I: Inspector,
153    {
154        engine
155            .inspector
156            .on_event(DefuseEvent::FtWithdraw(Cow::Borrowed(
157                [IntentEvent::new(
158                    AccountEvent::new(owner_id, Cow::Borrowed(&self)),
159                    intent_hash,
160                )]
161                .as_slice(),
162            )));
163
164        engine.state.ft_withdraw(owner_id, self)
165    }
166}
167
168#[near(serializers = [borsh, json])]
169#[derive(Debug, Clone)]
170/// Withdraw given NFT tokens from the intents contract to a given external account id (external being outside of intents).
171pub struct NftWithdraw {
172    pub token: AccountId,
173    pub receiver_id: AccountId,
174    pub token_id: non_fungible_token::TokenId,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub memo: Option<String>,
177
178    /// Message to pass to `nft_transfer_call`. Otherwise, `nft_transfer` will be used.
179    /// NOTE: No refund will be made in case of insufficient `storage_deposit`
180    /// on `token` for `receiver_id`
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub msg: Option<String>,
183
184    /// Optionally make `storage_deposit` for `receiver_id` on `token`.
185    /// The amount will be subtracted from user's NEP-141 `wNEAR` balance.
186    /// NOTE: the `wNEAR` will not be refunded in case of fail
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub storage_deposit: Option<NearToken>,
189
190    /// Optional minimum required Near gas for created Promise to succeed:
191    /// * `nft_transfer`:      minimum: 15TGas, default: 15TGas
192    /// * `nft_transfer_call`: minimum: 30TGas, default: 50TGas
193    ///
194    /// Remaining gas will be distributed evenly across all Function Call
195    /// Promises created during execution of current receipt.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub min_gas: Option<Gas>,
198}
199
200impl NftWithdraw {
201    const NFT_TRANSFER_GAS_MIN: Gas = Gas::from_tgas(15);
202    const NFT_TRANSFER_GAS_DEFAULT: Gas = Gas::from_tgas(15);
203
204    /// Taken from [near-contract-standards](https://github.com/near/near-sdk-rs/blob/985c16b8fffc623096d0b7e60b26746842a2d712/near-contract-standards/src/non_fungible_token/core/core_impl.rs#L396)
205    const NFT_TRANSFER_CALL_GAS_MIN: Gas = Gas::from_tgas(30);
206    const NFT_TRANSFER_CALL_GAS_DEFAULT: Gas = Gas::from_tgas(50);
207
208    /// Returns whether it's `nft_transfer_call()`
209    #[inline]
210    pub const fn is_call(&self) -> bool {
211        self.msg.is_some()
212    }
213
214    /// Returns minimum required gas
215    #[inline]
216    pub fn min_gas(&self) -> Gas {
217        let (min, default) = if self.is_call() {
218            (
219                Self::NFT_TRANSFER_CALL_GAS_MIN,
220                Self::NFT_TRANSFER_CALL_GAS_DEFAULT,
221            )
222        } else {
223            (Self::NFT_TRANSFER_GAS_MIN, Self::NFT_TRANSFER_GAS_DEFAULT)
224        };
225
226        self.min_gas
227            .unwrap_or(default)
228            // We need to set hard minimum for gas to prevent loss of funds
229            // due to insufficient gas:
230            // 1. We don't refund wNEAR taken for `storage_deposit()`,
231            //    which is executed in the same receipt as `nft_transfer[_call]()`
232            // 2. We don't refund if `nft_transfer_call()` Promise fails
233            .max(min)
234    }
235}
236
237impl ExecutableIntent for NftWithdraw {
238    #[inline]
239    fn execute_intent<S, I>(
240        self,
241        owner_id: &AccountIdRef,
242        engine: &mut Engine<S, I>,
243        intent_hash: CryptoHash,
244    ) -> Result<()>
245    where
246        S: State,
247        I: Inspector,
248    {
249        engine
250            .inspector
251            .on_event(DefuseEvent::NftWithdraw(Cow::Borrowed(
252                [IntentEvent::new(
253                    AccountEvent::new(owner_id, Cow::Borrowed(&self)),
254                    intent_hash,
255                )]
256                .as_slice(),
257            )));
258
259        engine.state.nft_withdraw(owner_id, self)
260    }
261}
262
263#[near(serializers = [borsh, json])]
264#[derive(Debug, Clone)]
265/// Withdraw given MT tokens (i.e. [NEP-245](https://github.com/near/NEPs/blob/master/neps/nep-0245.md)) from the intents contract
266/// to a given to an external account id (external being outside of intents).
267///
268/// If `msg` is given, `mt_batch_transfer_call()` will be used to transfer to the `receiver_id`. Otherwise, `mt_batch_transfer()` will be used.
269pub struct MtWithdraw {
270    pub token: AccountId,
271    pub receiver_id: AccountId,
272    pub token_ids: Vec<defuse_nep245::TokenId>,
273    pub amounts: Vec<U128>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub memo: Option<String>,
276
277    /// Message to pass to `mt_batch_transfer_call`. Otherwise, `mt_batch_transfer` will be used.
278    /// NOTE: No refund will be made in case of insufficient `storage_deposit`
279    /// on `token` for `receiver_id`
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub msg: Option<String>,
282
283    /// Optionally make `storage_deposit` for `receiver_id` on `token`.
284    /// The amount will be subtracted from user's NEP-141 `wNEAR` balance.
285    /// NOTE: the `wNEAR` will not be refunded in case of fail
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub storage_deposit: Option<NearToken>,
288
289    /// Optional minimum required Near gas for created Promise to succeed:
290    /// * `mt_batch_transfer`:      minimum: 15TGas, default: 15TGas
291    /// * `mt_batch_transfer_call`: minimum: 35TGas, default: 50TGas
292    ///
293    /// Remaining gas will be distributed evenly across all Function Call
294    /// Promises created during execution of current receipt.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub min_gas: Option<Gas>,
297}
298
299impl MtWithdraw {
300    // TODO: gas_base + gas_per_token * token_ids.len()
301    const MT_BATCH_TRANSFER_GAS_MIN: Gas = Gas::from_tgas(20);
302    const MT_BATCH_TRANSFER_GAS_DEFAULT: Gas = Gas::from_tgas(20);
303
304    const MT_BATCH_TRANSFER_CALL_GAS_MIN: Gas = Gas::from_tgas(35);
305    const MT_BATCH_TRANSFER_CALL_GAS_DEFAULT: Gas = Gas::from_tgas(50);
306
307    /// Returns whether it's `mt_batch_transfer_call()`
308    #[inline]
309    pub const fn is_call(&self) -> bool {
310        self.msg.is_some()
311    }
312
313    /// Returns minimum required gas
314    #[inline]
315    pub fn min_gas(&self) -> Gas {
316        let (min, default) = if self.is_call() {
317            (
318                Self::MT_BATCH_TRANSFER_CALL_GAS_MIN,
319                Self::MT_BATCH_TRANSFER_CALL_GAS_DEFAULT,
320            )
321        } else {
322            (
323                Self::MT_BATCH_TRANSFER_GAS_MIN,
324                Self::MT_BATCH_TRANSFER_GAS_DEFAULT,
325            )
326        };
327
328        self.min_gas
329            .unwrap_or(default)
330            // We need to set hard minimum for gas to prevent loss of funds
331            // due to insufficient gas:
332            // 1. We don't refund wNEAR taken for `storage_deposit()`,
333            //    which is executed in the same receipt as `mt_batch_transfer[_call]()`
334            // 2. We don't refund if `mt_batch_transfer_call()` Promise fails
335            .max(min)
336    }
337}
338
339impl ExecutableIntent for MtWithdraw {
340    #[inline]
341    fn execute_intent<S, I>(
342        self,
343        owner_id: &AccountIdRef,
344        engine: &mut Engine<S, I>,
345        intent_hash: CryptoHash,
346    ) -> Result<()>
347    where
348        S: State,
349        I: Inspector,
350    {
351        engine
352            .inspector
353            .on_event(DefuseEvent::MtWithdraw(Cow::Borrowed(
354                [IntentEvent::new(
355                    AccountEvent::new(owner_id, Cow::Borrowed(&self)),
356                    intent_hash,
357                )]
358                .as_slice(),
359            )));
360
361        engine.state.mt_withdraw(owner_id, self)
362    }
363}
364
365#[near(serializers = [borsh, json])]
366#[derive(Debug, Clone)]
367/// Withdraw native tokens (NEAR) from the intents contract to a given external account id (external being outside of intents).
368/// This will subtract from the account's wNEAR balance, and will be sent to the account specified as native NEAR.
369/// NOTE: the `wNEAR` will not be refunded in case of fail (e.g. `receiver_id`
370/// account does not exist).
371pub struct NativeWithdraw {
372    pub receiver_id: AccountId,
373    pub amount: NearToken,
374}
375
376impl ExecutableIntent for NativeWithdraw {
377    #[inline]
378    fn execute_intent<S, I>(
379        self,
380        owner_id: &AccountIdRef,
381        engine: &mut Engine<S, I>,
382        intent_hash: CryptoHash,
383    ) -> Result<()>
384    where
385        S: State,
386        I: Inspector,
387    {
388        engine
389            .inspector
390            .on_event(DefuseEvent::NativeWithdraw(Cow::Borrowed(
391                [IntentEvent::new(
392                    AccountEvent::new(owner_id, Cow::Borrowed(&self)),
393                    intent_hash,
394                )]
395                .as_slice(),
396            )));
397
398        engine.state.native_withdraw(owner_id, self)
399    }
400}
401
402/// Make [NEP-145](https://nomicon.io/Standards/StorageManagement#nep-145)
403/// `storage_deposit` for an `account_id` on `contract_id`.
404/// The `amount` will be subtracted from user's NEP-141 `wNEAR` balance.
405/// NOTE: the `wNEAR` will not be refunded in any case.
406///
407/// WARNING: use this intent only if paying storage_deposit is not a prerequisite
408/// for other intents to succeed. If some intent (e.g. ft_withdraw) requires storage_deposit,
409/// then use storage_deposit field of corresponding intent instead of adding a separate
410/// `StorageDeposit` intent. This is due to the fact that intents that fire `Promise`s
411/// are not guaranteed to be executed sequentially, in the order of the provided intents in
412/// `DefuseIntents`.
413#[near(serializers = [borsh, json])]
414#[derive(Debug, Clone)]
415pub struct StorageDeposit {
416    pub contract_id: AccountId,
417    #[serde(
418        // There was field collision for `account_id` in `AccountEvent`,
419        // but we keep it for backwards-compatibility
420        alias = "account_id",
421    )]
422    pub deposit_for_account_id: AccountId,
423    pub amount: NearToken,
424}
425
426impl ExecutableIntent for StorageDeposit {
427    #[inline]
428    fn execute_intent<S, I>(
429        self,
430        owner_id: &AccountIdRef,
431        engine: &mut Engine<S, I>,
432        intent_hash: CryptoHash,
433    ) -> Result<()>
434    where
435        S: State,
436        I: Inspector,
437    {
438        engine
439            .inspector
440            .on_event(DefuseEvent::StorageDeposit(Cow::Borrowed(
441                [IntentEvent::new(
442                    AccountEvent::new(owner_id, Cow::Borrowed(&self)),
443                    intent_hash,
444                )]
445                .as_slice(),
446            )));
447
448        engine.state.storage_deposit(owner_id, self)
449    }
450}