From 449700b608bdd3582155c850d4edd29461766755 Mon Sep 17 00:00:00 2001 From: Jacob Lindahl Date: Thu, 21 Sep 2023 16:57:41 +0900 Subject: [PATCH] feat: better hooks for nep141 --- macros/src/standard/nep141.rs | 102 ++-- rustfmt.toml | 1 + src/standard/nep141.rs | 538 --------------------- src/standard/nep141/event.rs | 120 +++++ src/standard/nep141/ext.rs | 56 +++ src/standard/nep141/hook.rs | 181 +++++++ src/standard/nep141/mod.rs | 319 ++++++++++++ tests/macros/mod.rs | 22 +- tests/macros/standard/nep141.rs | 25 +- workspaces-tests/src/bin/fungible_token.rs | 2 +- 10 files changed, 761 insertions(+), 605 deletions(-) delete mode 100644 src/standard/nep141.rs create mode 100644 src/standard/nep141/event.rs create mode 100644 src/standard/nep141/ext.rs create mode 100644 src/standard/nep141/hook.rs create mode 100644 src/standard/nep141/mod.rs diff --git a/macros/src/standard/nep141.rs b/macros/src/standard/nep141.rs index 2e6c962..929527f 100644 --- a/macros/src/standard/nep141.rs +++ b/macros/src/standard/nep141.rs @@ -1,5 +1,3 @@ -use std::ops::Not; - use darling::{util::Flag, FromDeriveInput}; use proc_macro2::TokenStream; use quote::quote; @@ -41,20 +39,15 @@ pub fn expand(meta: Nep141Meta) -> Result { } }); - let before_transfer = no_hooks.is_present().not().then(|| { - quote! { - let hook_state = >::before_transfer(self, &transfer); - } - }); - - let after_transfer = no_hooks.is_present().not().then(|| { - quote! { - >::after_transfer(self, &transfer, hook_state); - } - }); + let hook = if no_hooks.is_present() { + quote! { () } + } else { + quote! { Self } + }; Ok(quote! { impl #imp #me::standard::nep141::Nep141ControllerInternal for #ident #ty #wher { + type Hook = #hook; #root } @@ -67,36 +60,22 @@ pub fn expand(meta: Nep141Meta) -> Result { amount: #near_sdk::json_types::U128, memo: Option, ) { - use #me::{ - standard::{ - nep141::{Nep141Controller, event}, - nep297::Event, - }, - }; + use #me::standard::nep141::*; #near_sdk::assert_one_yocto(); let sender_id = #near_sdk::env::predecessor_account_id(); let amount: u128 = amount.into(); - let transfer = #me::standard::nep141::Nep141Transfer { + let transfer = Nep141Transfer { sender_id: sender_id.clone(), receiver_id: receiver_id.clone(), amount, - memo: memo.clone(), + memo, msg: None, + revert: false, }; - #before_transfer - - Nep141Controller::transfer( - self, - sender_id.clone(), - receiver_id.clone(), - amount, - memo, - ); - - #after_transfer + Nep141Controller::transfer(self, &transfer); } #[payable] @@ -107,41 +86,56 @@ pub fn expand(meta: Nep141Meta) -> Result { memo: Option, msg: String, ) -> #near_sdk::Promise { + use #me::standard::nep141::*; + + let prepaid_gas = #near_sdk::env::prepaid_gas(); + + #near_sdk::require!( + prepaid_gas >= GAS_FOR_FT_TRANSFER_CALL, + MORE_GAS_FAIL_MESSAGE, + ); + #near_sdk::assert_one_yocto(); let sender_id = #near_sdk::env::predecessor_account_id(); let amount: u128 = amount.into(); - let transfer = #me::standard::nep141::Nep141Transfer { - sender_id: sender_id.clone(), - receiver_id: receiver_id.clone(), - amount, - memo: memo.clone(), - msg: None, - }; - - #before_transfer - - let r = #me::standard::nep141::Nep141Controller::transfer_call( - self, - sender_id.clone(), - receiver_id.clone(), + let transfer = Nep141Transfer { + sender_id, + receiver_id, amount, memo, - msg.clone(), - #near_sdk::env::prepaid_gas(), - ); - - #after_transfer + msg: Some(msg.clone()), + revert: false, + }; - r + Nep141Controller::transfer(self, &transfer); + + let receiver_gas = prepaid_gas + .0 + .checked_sub(GAS_FOR_FT_TRANSFER_CALL.0) // TODO: Double-check this math. Should this be GAS_FOR_RESOLVE_TRANSFER? If not, this checked_sub call is superfluous given the require!() at the top of this function. + .unwrap_or_else(|| #near_sdk::env::panic_str("Prepaid gas overflow")); + + // Initiating receiver's call and the callback + ext_nep141_receiver::ext(transfer.receiver_id.clone()) + .with_static_gas(receiver_gas.into()) + .ft_on_transfer(transfer.sender_id.clone(), transfer.amount.into(), msg) + .then( + ext_nep141_resolver::ext(#near_sdk::env::current_account_id()) + .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) + .ft_resolve_transfer( + transfer.sender_id.clone(), + transfer.receiver_id.clone(), + transfer.amount.into(), + ), + ) } fn ft_total_supply(&self) -> #near_sdk::json_types::U128 { - ::total_supply().into() + #me::standard::nep141::Nep141Controller::total_supply(self).into() } fn ft_balance_of(&self, account_id: #near_sdk::AccountId) -> #near_sdk::json_types::U128 { - ::balance_of(&account_id).into() + #me::standard::nep141::Nep141Controller::balance_of(self, &account_id).into() } } diff --git a/rustfmt.toml b/rustfmt.toml index b6f799d..7c8c985 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1 +1,2 @@ tab_spaces = 4 +max_width = 100 diff --git a/src/standard/nep141.rs b/src/standard/nep141.rs deleted file mode 100644 index a59f0ce..0000000 --- a/src/standard/nep141.rs +++ /dev/null @@ -1,538 +0,0 @@ -//! NEP-141 fungible token core implementation -//! -#![allow(missing_docs)] // ext_contract doesn't play nice with #![warn(missing_docs)] - -use near_sdk::{ - borsh::{self, BorshDeserialize, BorshSerialize}, - env, ext_contract, - json_types::U128, - require, AccountId, BorshStorageKey, Gas, Promise, PromiseOrValue, PromiseResult, -}; -use near_sdk_contract_tools_macros::event; -use serde::{Deserialize, Serialize}; - -use crate::{slot::Slot, standard::nep297::*, DefaultStorageKey}; - -/// Gas value required for ft_resolve_transfer calls -pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(5_000_000_000_000); -/// Gas value required for ft_transfer_call calls (includes gas for ) -pub const GAS_FOR_FT_TRANSFER_CALL: Gas = Gas(25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER.0); - -const MORE_GAS_FAIL_MESSAGE: &str = "More gas is required"; - -/// NEP-141 standard events for minting, burning, and transferring tokens -#[event( - crate = "crate", - macros = "crate", - serde = "serde", - standard = "nep141", - version = "1.0.0" -)] -#[derive(Debug, Clone)] -pub enum Nep141Event { - /// Token mint event. Emitted when tokens are created and total_supply is - /// increased. - FtMint(Vec), - - /// Token transfer event. Emitted when tokens are transferred between two - /// accounts. No change to total_supply. - FtTransfer(Vec), - - /// Token burn event. Emitted when tokens are burned (removed from supply). - /// Decrease in total_supply. - FtBurn(Vec), -} - -pub mod event { - use near_sdk::{json_types::U128, AccountId}; - use serde::Serialize; - - /// Individual mint metadata - #[derive(Serialize, Debug, Clone)] - pub struct FtMintData { - /// Address to which new tokens were minted - pub owner_id: AccountId, - /// Amount of minted tokens - pub amount: U128, - /// Optional note - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - } - - /// Individual transfer metadata - #[derive(Serialize, Debug, Clone)] - pub struct FtTransferData { - /// Account ID of the sender - pub old_owner_id: AccountId, - /// Account ID of the receiver - pub new_owner_id: AccountId, - /// Amount of transferred tokens - pub amount: U128, - /// Optional note - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - } - - /// Individual burn metadata - #[derive(Serialize, Debug, Clone)] - pub struct FtBurnData { - /// Account ID from which tokens were burned - pub owner_id: AccountId, - /// Amount of burned tokens - pub amount: U128, - /// Optional note - #[serde(skip_serializing_if = "Option::is_none")] - pub memo: Option, - } - - #[cfg(test)] - mod tests { - - use super::{super::Nep141Event, *}; - use crate::standard::nep297::Event; - - #[test] - fn mint() { - assert_eq!( - Nep141Event::FtMint(vec![FtMintData { - owner_id: "foundation.near".parse().unwrap(), - amount: 500u128.into(), - memo: None, - }]) - .to_event_string(), - r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_mint","data":[{"owner_id":"foundation.near","amount":"500"}]}"#, - ); - } - - #[test] - fn transfer() { - assert_eq!( - Nep141Event::FtTransfer(vec![ - FtTransferData { - old_owner_id: "from.near".parse().unwrap(), - new_owner_id: "to.near".parse().unwrap(), - amount: 42u128.into(), - memo: Some("hi hello bonjour".to_string()), - }, - FtTransferData { - old_owner_id: "user1.near".parse().unwrap(), - new_owner_id: "user2.near".parse().unwrap(), - amount: 7500u128.into(), - memo: None - }, - ]) - .to_event_string(), - r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"from.near","new_owner_id":"to.near","amount":"42","memo":"hi hello bonjour"},{"old_owner_id":"user1.near","new_owner_id":"user2.near","amount":"7500"}]}"#, - ); - } - - #[test] - fn burn() { - assert_eq!( - Nep141Event::FtBurn(vec![FtBurnData { - owner_id: "foundation.near".parse().unwrap(), - amount: 100u128.into(), - memo: None, - }]) - .to_event_string(), - r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_burn","data":[{"owner_id":"foundation.near","amount":"100"}]}"#, - ); - } - } -} - -#[derive(BorshSerialize, BorshStorageKey)] -enum StorageKey { - TotalSupply, - Account(AccountId), -} - -/// Contracts may implement this trait to inject code into NEP-141 functions. -/// -/// `T` is an optional value for passing state between different lifecycle -/// hooks. This may be useful for charging callers for storage usage, for -/// example. -pub trait Nep141Hook { - /// Executed before a token transfer is conducted - /// - /// May return an optional state value which will be passed along to the - /// following `after_transfer`. - fn before_transfer(&mut self, _transfer: &Nep141Transfer) -> T { - Default::default() - } - - /// Executed after a token transfer is conducted - /// - /// Receives the state value returned by `before_transfer`. - fn after_transfer(&mut self, _transfer: &Nep141Transfer, _state: T) {} -} - -/// Transfer metadata generic over both types of transfer (`ft_transfer` and -/// `ft_transfer_call`). -#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize, PartialEq, Eq, Clone, Debug)] -pub struct Nep141Transfer { - /// Sender's account ID - pub sender_id: AccountId, - /// Receiver's account ID - pub receiver_id: AccountId, - /// Transferred amount - pub amount: u128, - /// Optional memo string - pub memo: Option, - /// Message passed to contract located at `receiver_id` - pub msg: Option, -} - -impl Nep141Transfer { - /// Returns `true` if this transfer comes from a `ft_transfer_call` - /// call, `false` otherwise - pub fn is_transfer_call(&self) -> bool { - self.msg.is_some() - } -} - -/// Internal functions for [`Nep141Controller`]. Using these methods may result in unexpected behavior. -pub trait Nep141ControllerInternal { - /// Root storage slot - fn root() -> Slot<()> { - Slot::new(DefaultStorageKey::Nep141) - } - - /// Slot for account data - fn slot_account(account_id: &AccountId) -> Slot { - Self::root().field(StorageKey::Account(account_id.clone())) - } - - /// Slot for storing total supply - fn slot_total_supply() -> Slot { - Self::root().field(StorageKey::TotalSupply) - } -} - -/// Non-public implementations of functions for managing a fungible token. -pub trait Nep141Controller { - /// Get the balance of an account. Returns 0 if the account does not exist. - fn balance_of(account_id: &AccountId) -> u128; - - /// Get the total circulating supply of the token. - fn total_supply() -> u128; - - /// Removes tokens from an account and decreases total supply. No event - /// emission. - /// - /// # Panics - /// - /// Panics if the current balance of `account_id` is less than `amount` or - /// if `total_supply` is less than `amount`. - fn withdraw_unchecked(&mut self, account_id: &AccountId, amount: u128); - - /// Increases the token balance of an account. Updates total supply. No - /// event emission, - /// - /// # Panics - /// - /// Panics if the balance of `account_id` plus `amount` >= `u128::MAX`, or - /// if the total supply plus `amount` >= `u128::MAX`. - fn deposit_unchecked(&mut self, account_id: &AccountId, amount: u128); - - /// Decreases the balance of `sender_account_id` by `amount` and increases - /// the balance of `receiver_account_id` by the same. No change to total - /// supply. No event emission. - /// - /// # Panics - /// - /// Panics if the balance of `sender_account_id` < `amount` or if the - /// balance of `receiver_account_id` plus `amount` >= `u128::MAX`. - fn transfer_unchecked( - &mut self, - sender_account_id: &AccountId, - receiver_account_id: &AccountId, - amount: u128, - ); - - /// Performs an NEP-141 token transfer, with event emission. - /// - /// # Panics - /// - /// See: `Nep141Controller::transfer_unchecked` - fn transfer( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - ); - - /// Performs an NEP-141 token mint, with event emission. - /// - /// # Panics - /// - /// See: `Nep141Controller::deposit_unchecked` - fn mint(&mut self, account_id: AccountId, amount: u128, memo: Option); - - /// Performs an NEP-141 token burn, with event emission. - /// - /// # Panics - /// - /// See: `Nep141Controller::withdraw_unchecked` - fn burn(&mut self, account_id: AccountId, amount: u128, memo: Option); - - /// Performs an NEP-141 token transfer call, with event emission. - /// - /// # Panics - /// - /// Panics if `gas_allowance` < `GAS_FOR_FT_TRANSFER_CALL`. - /// - /// See also: `Nep141Controller::transfer` - fn transfer_call( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - msg: String, - gas_allowance: Gas, - ) -> Promise; - - /// Resolves an NEP-141 `ft_transfer_call` promise chain. - fn resolve_transfer( - &mut self, - sender_id: AccountId, - receiver_id: AccountId, - amount: u128, - ) -> u128; -} - -impl Nep141Controller for T { - fn balance_of(account_id: &AccountId) -> u128 { - Self::slot_account(account_id).read().unwrap_or(0) - } - - fn total_supply() -> u128 { - Self::slot_total_supply().read().unwrap_or(0) - } - - fn withdraw_unchecked(&mut self, account_id: &AccountId, amount: u128) { - if amount != 0 { - let balance = Self::balance_of(account_id); - if let Some(balance) = balance.checked_sub(amount) { - Self::slot_account(account_id).write(&balance); - } else { - env::panic_str("Balance underflow"); - } - - let total_supply = Self::total_supply(); - if let Some(total_supply) = total_supply.checked_sub(amount) { - Self::slot_total_supply().write(&total_supply); - } else { - env::panic_str("Total supply underflow"); - } - } - } - - fn deposit_unchecked(&mut self, account_id: &AccountId, amount: u128) { - if amount != 0 { - let balance = Self::balance_of(account_id); - if let Some(balance) = balance.checked_add(amount) { - Self::slot_account(account_id).write(&balance); - } else { - env::panic_str("Balance overflow"); - } - - let total_supply = Self::total_supply(); - if let Some(total_supply) = total_supply.checked_add(amount) { - Self::slot_total_supply().write(&total_supply); - } else { - env::panic_str("Total supply overflow"); - } - } - } - - fn transfer_unchecked( - &mut self, - sender_account_id: &AccountId, - receiver_account_id: &AccountId, - amount: u128, - ) { - let sender_balance = Self::balance_of(sender_account_id); - - if let Some(sender_balance) = sender_balance.checked_sub(amount) { - let receiver_balance = Self::balance_of(receiver_account_id); - if let Some(receiver_balance) = receiver_balance.checked_add(amount) { - Self::slot_account(sender_account_id).write(&sender_balance); - Self::slot_account(receiver_account_id).write(&receiver_balance); - } else { - env::panic_str("Receiver balance overflow"); - } - } else { - env::panic_str("Sender balance underflow"); - } - } - - fn transfer( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - ) { - self.transfer_unchecked(&sender_account_id, &receiver_account_id, amount); - - Nep141Event::FtTransfer(vec![event::FtTransferData { - old_owner_id: sender_account_id, - new_owner_id: receiver_account_id, - amount: amount.into(), - memo, - }]) - .emit(); - } - - fn mint(&mut self, account_id: AccountId, amount: u128, memo: Option) { - self.deposit_unchecked(&account_id, amount); - - Nep141Event::FtMint(vec![event::FtMintData { - owner_id: account_id, - amount: amount.into(), - memo, - }]) - .emit(); - } - - fn burn(&mut self, account_id: AccountId, amount: u128, memo: Option) { - self.withdraw_unchecked(&account_id, amount); - - Nep141Event::FtBurn(vec![event::FtBurnData { - owner_id: account_id, - amount: amount.into(), - memo, - }]) - .emit(); - } - - fn transfer_call( - &mut self, - sender_account_id: AccountId, - receiver_account_id: AccountId, - amount: u128, - memo: Option, - msg: String, - gas_allowance: Gas, - ) -> Promise { - require!( - gas_allowance >= GAS_FOR_FT_TRANSFER_CALL, - MORE_GAS_FAIL_MESSAGE, - ); - - self.transfer( - sender_account_id.clone(), - receiver_account_id.clone(), - amount, - memo, - ); - - let receiver_gas = gas_allowance - .0 - .checked_sub(GAS_FOR_FT_TRANSFER_CALL.0) // TODO: Double-check this math. Should this be GAS_FOR_RESOLVE_TRANSFER? If not, this checked_sub call is superfluous given the require!() at the top of this function. - .unwrap_or_else(|| env::panic_str("Prepaid gas overflow")); - - // Initiating receiver's call and the callback - ext_nep141_receiver::ext(receiver_account_id.clone()) - .with_static_gas(receiver_gas.into()) - .ft_on_transfer(sender_account_id.clone(), amount.into(), msg) - .then( - ext_nep141_resolver::ext(env::current_account_id()) - .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) - .ft_resolve_transfer(sender_account_id, receiver_account_id, amount.into()), - ) - } - - fn resolve_transfer( - &mut self, - sender_id: AccountId, - receiver_id: AccountId, - amount: u128, - ) -> u128 { - let ft_on_transfer_promise_result = env::promise_result(0); - - let unused_amount = match ft_on_transfer_promise_result { - PromiseResult::NotReady => env::abort(), - PromiseResult::Successful(value) => { - if let Ok(U128(unused_amount)) = serde_json::from_slice::(&value) { - std::cmp::min(amount, unused_amount) - } else { - amount - } - } - PromiseResult::Failed => amount, - }; - - let refunded_amount = if unused_amount > 0 { - let receiver_balance = Self::balance_of(&receiver_id); - if receiver_balance > 0 { - let refund_amount = std::cmp::min(receiver_balance, unused_amount); - self.transfer(receiver_id, sender_id, refund_amount, None); - refund_amount - } else { - 0 - } - } else { - 0 - }; - - // Used amount - amount - refunded_amount - } -} - -/// A contract that may be the recipient of an `ft_transfer_call` function -/// call. -#[ext_contract(ext_nep141_receiver)] -pub trait Nep141Receiver { - /// Function that is called in an `ft_transfer_call` promise chain. - /// Returns the number of tokens "used", that is, those that will be kept - /// in the receiving contract's account. (The contract will attempt to - /// refund the difference from `amount` to the original sender.) - fn ft_on_transfer( - &mut self, - sender_id: AccountId, - amount: U128, - msg: String, - ) -> PromiseOrValue; -} - -/// Fungible token contract callback after `ft_transfer_call` execution. -#[ext_contract(ext_nep141_resolver)] -pub trait Nep141Resolver { - /// Callback, last in `ft_transfer_call` promise chain. Returns the amount - /// of tokens refunded to the original sender. - fn ft_resolve_transfer( - &mut self, - sender_id: AccountId, - receiver_id: AccountId, - amount: U128, - ) -> U128; -} - -/// Externally-accessible NEP-141-compatible fungible token interface. -#[ext_contract(ext_nep141)] -pub trait Nep141 { - /// Performs a token transfer - fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option); - - /// Performs a token transfer, then initiates a promise chain that calls - /// `ft_on_transfer` on the receiving account, followed by - /// `ft_resolve_transfer` on the original token contract (this contract). - fn ft_transfer_call( - &mut self, - receiver_id: AccountId, - amount: U128, - memo: Option, - msg: String, - ) -> Promise; - - /// Returns the current total amount of tokens tracked by the contract - fn ft_total_supply(&self) -> U128; - - /// Returns the amount of tokens controlled by `account_id` - fn ft_balance_of(&self, account_id: AccountId) -> U128; -} diff --git a/src/standard/nep141/event.rs b/src/standard/nep141/event.rs new file mode 100644 index 0000000..53de6c1 --- /dev/null +++ b/src/standard/nep141/event.rs @@ -0,0 +1,120 @@ +//! NEP-141 standard events for minting, burning, and transferring tokens. + +use near_sdk_contract_tools_macros::event; + +/// NEP-141 standard events for minting, burning, and transferring tokens. +#[event( + crate = "crate", + macros = "crate", + serde = "serde", + standard = "nep141", + version = "1.0.0" +)] +#[derive(Debug, Clone)] +pub enum Nep141Event { + /// Token mint event. Emitted when tokens are created and total_supply is + /// increased. + FtMint(Vec), + + /// Token transfer event. Emitted when tokens are transferred between two + /// accounts. No change to total_supply. + FtTransfer(Vec), + + /// Token burn event. Emitted when tokens are burned (removed from supply). + /// Decrease in total_supply. + FtBurn(Vec), +} +use near_sdk::{json_types::U128, AccountId}; +use serde::Serialize; + +/// Individual mint metadata +#[derive(Serialize, Debug, Clone)] +pub struct FtMintData { + /// Address to which new tokens were minted + pub owner_id: AccountId, + /// Amount of minted tokens + pub amount: U128, + /// Optional note + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Individual transfer metadata +#[derive(Serialize, Debug, Clone)] +pub struct FtTransferData { + /// Account ID of the sender + pub old_owner_id: AccountId, + /// Account ID of the receiver + pub new_owner_id: AccountId, + /// Amount of transferred tokens + pub amount: U128, + /// Optional note + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Individual burn metadata +#[derive(Serialize, Debug, Clone)] +pub struct FtBurnData { + /// Account ID from which tokens were burned + pub owner_id: AccountId, + /// Amount of burned tokens + pub amount: U128, + /// Optional note + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::standard::nep297::Event; + + #[test] + fn mint() { + assert_eq!( + Nep141Event::FtMint(vec![FtMintData { + owner_id: "foundation.near".parse().unwrap(), + amount: 500u128.into(), + memo: None, + }]) + .to_event_string(), + r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_mint","data":[{"owner_id":"foundation.near","amount":"500"}]}"#, + ); + } + + #[test] + fn transfer() { + assert_eq!( + Nep141Event::FtTransfer(vec![ + FtTransferData { + old_owner_id: "from.near".parse().unwrap(), + new_owner_id: "to.near".parse().unwrap(), + amount: 42u128.into(), + memo: Some("hi hello bonjour".to_string()), + }, + FtTransferData { + old_owner_id: "user1.near".parse().unwrap(), + new_owner_id: "user2.near".parse().unwrap(), + amount: 7500u128.into(), + memo: None + }, + ]) + .to_event_string(), + r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_transfer","data":[{"old_owner_id":"from.near","new_owner_id":"to.near","amount":"42","memo":"hi hello bonjour"},{"old_owner_id":"user1.near","new_owner_id":"user2.near","amount":"7500"}]}"#, + ); + } + + #[test] + fn burn() { + assert_eq!( + Nep141Event::FtBurn(vec![FtBurnData { + owner_id: "foundation.near".parse().unwrap(), + amount: 100u128.into(), + memo: None, + }]) + .to_event_string(), + r#"EVENT_JSON:{"standard":"nep141","version":"1.0.0","event":"ft_burn","data":[{"owner_id":"foundation.near","amount":"100"}]}"#, + ); + } +} diff --git a/src/standard/nep141/ext.rs b/src/standard/nep141/ext.rs new file mode 100644 index 0000000..329b232 --- /dev/null +++ b/src/standard/nep141/ext.rs @@ -0,0 +1,56 @@ +#![allow(missing_docs)] + +use near_sdk::{ext_contract, json_types::U128, AccountId, Promise, PromiseOrValue}; + +/// A contract that may be the recipient of an `ft_transfer_call` function +/// call. +#[ext_contract(ext_nep141_receiver)] +pub trait Nep141Receiver { + /// Function that is called in an `ft_transfer_call` promise chain. + /// Returns the number of tokens "used", that is, those that will be kept + /// in the receiving contract's account. (The contract will attempt to + /// refund the difference from `amount` to the original sender.) + fn ft_on_transfer( + &mut self, + sender_id: AccountId, + amount: U128, + msg: String, + ) -> PromiseOrValue; +} + +/// Fungible token contract callback after `ft_transfer_call` execution. +#[ext_contract(ext_nep141_resolver)] +pub trait Nep141Resolver { + /// Callback, last in `ft_transfer_call` promise chain. Returns the amount + /// of tokens refunded to the original sender. + fn ft_resolve_transfer( + &mut self, + sender_id: AccountId, + receiver_id: AccountId, + amount: U128, + ) -> U128; +} + +/// Externally-accessible NEP-141-compatible fungible token interface. +#[ext_contract(ext_nep141)] +pub trait Nep141 { + /// Performs a token transfer + fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option); + + /// Performs a token transfer, then initiates a promise chain that calls + /// `ft_on_transfer` on the receiving account, followed by + /// `ft_resolve_transfer` on the original token contract (this contract). + fn ft_transfer_call( + &mut self, + receiver_id: AccountId, + amount: U128, + memo: Option, + msg: String, + ) -> Promise; + + /// Returns the current total amount of tokens tracked by the contract + fn ft_total_supply(&self) -> U128; + + /// Returns the amount of tokens controlled by `account_id` + fn ft_balance_of(&self, account_id: AccountId) -> U128; +} diff --git a/src/standard/nep141/hook.rs b/src/standard/nep141/hook.rs new file mode 100644 index 0000000..f37f717 --- /dev/null +++ b/src/standard/nep141/hook.rs @@ -0,0 +1,181 @@ +//! NEP-141 lifecycle hooks. + +use near_sdk::AccountId; + +use super::Nep141Transfer; + +/// Contracts may implement this trait to inject code into NEP-141 functions. +/// +/// `T` is an optional value for passing state between different lifecycle +/// hooks. This may be useful for charging callers for storage usage, for +/// example. +pub trait Nep141Hook { + /// State value returned by [`Nep141Hook::before_mint`]. + type MintState; + /// State value returned by [`Nep141Hook::before_transfer`]. + type TransferState; + /// State value returned by [`Nep141Hook::before_burn`]. + type BurnState; + + /// Executed before a token mint is conducted. + /// + /// May return an optional state value which will be passed along to the + /// following [`Nep141Hook::after_mint`]. + fn before_mint(contract: &C, amount: u128, account_id: &AccountId) -> Self::MintState; + + /// Executed after a token mint is conducted. + /// + /// Receives the state value returned by [`Nep141Hook::before_mint`]. + fn after_mint(contract: &mut C, amount: u128, account_id: &AccountId, state: Self::MintState); + + /// Executed before a token transfer is conducted. + /// + /// May return an optional state value which will be passed along to the + /// following [`Nep141Hook::after_transfer`]. + fn before_transfer(contract: &C, transfer: &Nep141Transfer) -> Self::TransferState; + + /// Executed after a token transfer is conducted. + /// + /// Receives the state value returned by [`Nep141Hook::before_transfer`]. + fn after_transfer(contract: &mut C, transfer: &Nep141Transfer, state: Self::TransferState); + + /// Executed before a token burn is conducted. + /// + /// May return an optional state value which will be passed along to the + /// following [`Nep141Hook::after_burn`]. + fn before_burn(contract: &C, amount: u128, account_id: &AccountId) -> Self::BurnState; + + /// Executed after a token burn is conducted. + /// + /// Receives the state value returned by [`Nep141Hook::before_burn`]. + fn after_burn(contract: &mut C, amount: u128, account_id: &AccountId, state: Self::BurnState); +} + +impl Nep141Hook for () { + type MintState = (); + type TransferState = (); + type BurnState = (); + + fn before_mint(_contract: &C, _amount: u128, _account_id: &AccountId) {} + + fn after_mint(_contract: &mut C, _amount: u128, _account_id: &AccountId, _: ()) {} + + fn before_transfer(_contract: &C, _transfer: &Nep141Transfer) {} + + fn after_transfer(_contract: &mut C, _transfer: &Nep141Transfer, _: ()) {} + + fn before_burn(_contract: &C, _amount: u128, _account_id: &AccountId) {} + + fn after_burn(_contract: &mut C, _amount: u128, _account_id: &AccountId, _: ()) {} +} + +impl Nep141Hook for (T, U) +where + T: Nep141Hook, + U: Nep141Hook, +{ + type MintState = (T::MintState, U::MintState); + type TransferState = (T::TransferState, U::TransferState); + + type BurnState = (T::BurnState, U::BurnState); + + fn before_mint(contract: &C, amount: u128, account_id: &AccountId) -> Self::MintState { + ( + T::before_mint(contract, amount, account_id), + U::before_mint(contract, amount, account_id), + ) + } + + fn after_mint( + contract: &mut C, + amount: u128, + account_id: &AccountId, + (t_state, u_state): Self::MintState, + ) { + T::after_mint(contract, amount, account_id, t_state); + U::after_mint(contract, amount, account_id, u_state); + } + + fn before_transfer(contract: &C, transfer: &Nep141Transfer) -> Self::TransferState { + ( + T::before_transfer(contract, transfer), + U::before_transfer(contract, transfer), + ) + } + + fn after_transfer( + contract: &mut C, + transfer: &Nep141Transfer, + (t_state, u_state): Self::TransferState, + ) { + T::after_transfer(contract, transfer, t_state); + U::after_transfer(contract, transfer, u_state); + } + + fn before_burn(contract: &C, amount: u128, account_id: &AccountId) -> Self::BurnState { + ( + T::before_burn(contract, amount, account_id), + U::before_burn(contract, amount, account_id), + ) + } + + fn after_burn( + contract: &mut C, + amount: u128, + account_id: &AccountId, + (t_state, u_state): Self::BurnState, + ) { + T::after_burn(contract, amount, account_id, t_state); + U::after_burn(contract, amount, account_id, u_state); + } +} + +/// Alternative to [`Nep141Hook`] that allows for simpler hook implementations. +pub trait SimpleNep141Hook { + /// Executed before a token mint is conducted. + fn before_mint(&self, _amount: u128, _account_id: &AccountId) {} + /// Executed after a token mint is conducted. + fn after_mint(&mut self, _amount: u128, _account_id: &AccountId) {} + + /// Executed before a token transfer is conducted. + fn before_transfer(&self, _transfer: &Nep141Transfer) {} + /// Executed after a token transfer is conducted. + fn after_transfer(&mut self, _transfer: &Nep141Transfer) {} + + /// Executed before a token burn is conducted. + fn before_burn(&self, _amount: u128, _account_id: &AccountId) {} + /// Executed after a token burn is conducted. + fn after_burn(&mut self, _amount: u128, _account_id: &AccountId) {} +} + +impl Nep141Hook for C { + type MintState = (); + + type TransferState = (); + + type BurnState = (); + + fn before_mint(contract: &C, amount: u128, account_id: &AccountId) { + SimpleNep141Hook::before_mint(contract, amount, account_id); + } + + fn after_mint(contract: &mut C, amount: u128, account_id: &AccountId, _: ()) { + SimpleNep141Hook::after_mint(contract, amount, account_id); + } + + fn before_transfer(contract: &C, transfer: &Nep141Transfer) { + SimpleNep141Hook::before_transfer(contract, transfer); + } + + fn after_transfer(contract: &mut C, transfer: &Nep141Transfer, _: ()) { + SimpleNep141Hook::after_transfer(contract, transfer); + } + + fn before_burn(contract: &C, amount: u128, account_id: &AccountId) { + SimpleNep141Hook::before_burn(contract, amount, account_id); + } + + fn after_burn(contract: &mut C, amount: u128, account_id: &AccountId, _: ()) { + SimpleNep141Hook::after_burn(contract, amount, account_id); + } +} diff --git a/src/standard/nep141/mod.rs b/src/standard/nep141/mod.rs new file mode 100644 index 0000000..5cc1cc1 --- /dev/null +++ b/src/standard/nep141/mod.rs @@ -0,0 +1,319 @@ +//! NEP-141 fungible token core implementation +//! + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, + json_types::U128, + AccountId, BorshStorageKey, Gas, PromiseResult, +}; +use serde::{Deserialize, Serialize}; + +use crate::{slot::Slot, standard::nep297::*, DefaultStorageKey}; + +pub mod event; +pub use event::*; +pub mod ext; +pub use ext::*; +pub mod hook; +pub use hook::*; + +/// Gas value required for ft_resolve_transfer calls +pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(5_000_000_000_000); +/// Gas value required for ft_transfer_call calls (includes gas for ) +pub const GAS_FOR_FT_TRANSFER_CALL: Gas = Gas(25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER.0); +/// Error message for insufficient gas. +pub const MORE_GAS_FAIL_MESSAGE: &str = "More gas is required"; + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey { + TotalSupply, + Account(AccountId), +} + +/// Transfer metadata generic over both types of transfer (`ft_transfer` and +/// `ft_transfer_call`). +#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize, PartialEq, Eq, Clone, Debug)] +pub struct Nep141Transfer { + /// Sender's account ID. + pub sender_id: AccountId, + /// Receiver's account ID. + pub receiver_id: AccountId, + /// Transferred amount. + pub amount: u128, + /// Optional memo string. + pub memo: Option, + /// Message passed to contract located at `receiver_id`. + pub msg: Option, + /// Is this transfer a revert as a result of a [`Nep141::ft_transfer_call`] -> [`Nep141Receiver::ft_on_transfer`] call? + pub revert: bool, +} + +impl Nep141Transfer { + /// Returns `true` if this transfer comes from a `ft_transfer_call` + /// call, `false` otherwise. + pub fn is_transfer_call(&self) -> bool { + self.msg.is_some() + } +} + +/// Internal functions for [`Nep141Controller`]. Using these methods may result in unexpected behavior. +pub trait Nep141ControllerInternal { + /// Fungible token lifecycle hooks. + type Hook: Nep141Hook + where + Self: Sized; + + /// Root storage slot. + fn root() -> Slot<()> { + Slot::new(DefaultStorageKey::Nep141) + } + + /// Slot for account data. + fn slot_account(account_id: &AccountId) -> Slot { + Self::root().field(StorageKey::Account(account_id.clone())) + } + + /// Slot for storing total supply. + fn slot_total_supply() -> Slot { + Self::root().field(StorageKey::TotalSupply) + } +} + +/// Non-public implementations of functions for managing a fungible token. +pub trait Nep141Controller { + /// Fungible token lifecycle hooks. + type Hook: Nep141Hook + where + Self: Sized; + + /// Get the balance of an account. Returns 0 if the account does not exist. + fn balance_of(&self, account_id: &AccountId) -> u128; + + /// Get the total circulating supply of the token. + fn total_supply(&self) -> u128; + + /// Removes tokens from an account and decreases total supply. No event + /// emission. + /// + /// # Panics + /// + /// Panics if the current balance of `account_id` is less than `amount` or + /// if `total_supply` is less than `amount`. + fn withdraw_unchecked(&mut self, account_id: &AccountId, amount: u128); + + /// Increases the token balance of an account. Updates total supply. No + /// event emission. + /// + /// # Panics + /// + /// Panics if the balance of `account_id` plus `amount` >= `u128::MAX`, or + /// if the total supply plus `amount` >= `u128::MAX`. + fn deposit_unchecked(&mut self, account_id: &AccountId, amount: u128); + + /// Decreases the balance of `sender_account_id` by `amount` and increases + /// the balance of `receiver_account_id` by the same. No change to total + /// supply. No event emission. + /// + /// # Panics + /// + /// Panics if the balance of `sender_account_id` < `amount` or if the + /// balance of `receiver_account_id` plus `amount` >= `u128::MAX`. + fn transfer_unchecked( + &mut self, + sender_account_id: &AccountId, + receiver_account_id: &AccountId, + amount: u128, + ); + + /// Performs an NEP-141 token transfer, with event emission. + /// + /// # Panics + /// + /// See: [`Nep141Controller::transfer_unchecked`] + fn transfer(&mut self, transfer: &Nep141Transfer); + + /// Performs an NEP-141 token mint, with event emission. + /// + /// # Panics + /// + /// See: `Nep141Controller::deposit_unchecked` + fn mint(&mut self, account_id: AccountId, amount: u128, memo: Option); + + /// Performs an NEP-141 token burn, with event emission. + /// + /// # Panics + /// + /// See: `Nep141Controller::withdraw_unchecked` + fn burn(&mut self, account_id: AccountId, amount: u128, memo: Option); + + /// Resolves an NEP-141 `ft_transfer_call` promise chain. + fn resolve_transfer( + &mut self, + sender_id: AccountId, + receiver_id: AccountId, + amount: u128, + ) -> u128; +} + +impl Nep141Controller for T { + type Hook = T::Hook; + + fn balance_of(&self, account_id: &AccountId) -> u128 { + Self::slot_account(account_id).read().unwrap_or(0) + } + + fn total_supply(&self) -> u128 { + Self::slot_total_supply().read().unwrap_or(0) + } + + fn withdraw_unchecked(&mut self, account_id: &AccountId, amount: u128) { + if amount != 0 { + let balance = self.balance_of(account_id); + if let Some(balance) = balance.checked_sub(amount) { + Self::slot_account(account_id).write(&balance); + } else { + env::panic_str("Balance underflow"); + } + + let total_supply = self.total_supply(); + if let Some(total_supply) = total_supply.checked_sub(amount) { + Self::slot_total_supply().write(&total_supply); + } else { + env::panic_str("Total supply underflow"); + } + } + } + + fn deposit_unchecked(&mut self, account_id: &AccountId, amount: u128) { + if amount != 0 { + let balance = self.balance_of(account_id); + if let Some(balance) = balance.checked_add(amount) { + Self::slot_account(account_id).write(&balance); + } else { + env::panic_str("Balance overflow"); + } + + let total_supply = self.total_supply(); + if let Some(total_supply) = total_supply.checked_add(amount) { + Self::slot_total_supply().write(&total_supply); + } else { + env::panic_str("Total supply overflow"); + } + } + } + + fn transfer_unchecked( + &mut self, + sender_account_id: &AccountId, + receiver_account_id: &AccountId, + amount: u128, + ) { + let sender_balance = self.balance_of(sender_account_id); + + if let Some(sender_balance) = sender_balance.checked_sub(amount) { + let receiver_balance = self.balance_of(receiver_account_id); + if let Some(receiver_balance) = receiver_balance.checked_add(amount) { + Self::slot_account(sender_account_id).write(&sender_balance); + Self::slot_account(receiver_account_id).write(&receiver_balance); + } else { + env::panic_str("Receiver balance overflow"); + } + } else { + env::panic_str("Sender balance underflow"); + } + } + + fn transfer(&mut self, transfer: &Nep141Transfer) { + let state = Self::Hook::before_transfer(self, transfer); + + self.transfer_unchecked(&transfer.sender_id, &transfer.receiver_id, transfer.amount); + + Nep141Event::FtTransfer(vec![FtTransferData { + old_owner_id: transfer.sender_id.clone(), + new_owner_id: transfer.receiver_id.clone(), + amount: transfer.amount.into(), + memo: transfer.memo.clone(), + }]) + .emit(); + + Self::Hook::after_transfer(self, transfer, state); + } + + fn mint(&mut self, account_id: AccountId, amount: u128, memo: Option) { + let state = Self::Hook::before_mint(self, amount, &account_id); + + self.deposit_unchecked(&account_id, amount); + + Self::Hook::after_mint(self, amount, &account_id, state); + + Nep141Event::FtMint(vec![FtMintData { + owner_id: account_id, + amount: amount.into(), + memo, + }]) + .emit(); + } + + fn burn(&mut self, account_id: AccountId, amount: u128, memo: Option) { + let state = Self::Hook::before_burn(self, amount, &account_id); + + self.withdraw_unchecked(&account_id, amount); + + Self::Hook::after_burn(self, amount, &account_id, state); + + Nep141Event::FtBurn(vec![FtBurnData { + owner_id: account_id, + amount: amount.into(), + memo, + }]) + .emit(); + } + + fn resolve_transfer( + &mut self, + sender_id: AccountId, + receiver_id: AccountId, + amount: u128, + ) -> u128 { + let ft_on_transfer_promise_result = env::promise_result(0); + + let unused_amount = match ft_on_transfer_promise_result { + PromiseResult::NotReady => env::abort(), + PromiseResult::Successful(value) => { + if let Ok(U128(unused_amount)) = serde_json::from_slice::(&value) { + std::cmp::min(amount, unused_amount) + } else { + amount + } + } + PromiseResult::Failed => amount, + }; + + let refunded_amount = if unused_amount > 0 { + let receiver_balance = self.balance_of(&receiver_id); + if receiver_balance > 0 { + let refund_amount = std::cmp::min(receiver_balance, unused_amount); + let transfer = Nep141Transfer { + sender_id: receiver_id, + receiver_id: sender_id, + amount: refund_amount, + memo: None, + msg: None, + revert: true, + }; + + self.transfer(&transfer); + + refund_amount + } else { + 0 + } + } else { + 0 + }; + + // Used amount + amount - refunded_amount + } +} diff --git a/tests/macros/mod.rs b/tests/macros/mod.rs index 5efe265..5694201 100644 --- a/tests/macros/mod.rs +++ b/tests/macros/mod.rs @@ -412,19 +412,31 @@ mod pausable_fungible_token { pub storage_usage_start: u64, } - impl Nep141Hook for Contract { - fn before_transfer(&mut self, _transfer: &Nep141Transfer) -> HookState { + impl Nep141Hook for Contract { + type MintState = (); + type TransferState = HookState; + type BurnState = (); + + fn before_mint(_contract: &Self, _amount: u128, _account_id: &AccountId) {} + + fn after_mint(_contract: &mut Self, _amount: u128, _account_id: &AccountId, _: ()) {} + + fn before_burn(_contract: &Self, _amount: u128, _account_id: &AccountId) {} + + fn after_burn(_contract: &mut Self, _amount: u128, _account_id: &AccountId, _: ()) {} + + fn before_transfer(_contract: &Self, _transfer: &Nep141Transfer) -> HookState { Contract::require_unpaused(); HookState { storage_usage_start: env::storage_usage(), } } - fn after_transfer(&mut self, _transfer: &Nep141Transfer, state: HookState) { + fn after_transfer(contract: &mut Self, _transfer: &Nep141Transfer, state: HookState) { let storage_delta = env::storage_usage() - state.storage_usage_start; - println!("Storage delta: {storage_delta}",); + println!("Storage delta: {storage_delta}"); - self.storage_usage = storage_delta; + contract.storage_usage = storage_delta; } } diff --git a/tests/macros/standard/nep141.rs b/tests/macros/standard/nep141.rs index 9c8ccd8..7b29b7e 100644 --- a/tests/macros/standard/nep141.rs +++ b/tests/macros/standard/nep141.rs @@ -21,18 +21,28 @@ struct HookState { pub storage_usage_start: u64, } -impl Nep141Hook for FungibleToken { - fn before_transfer(&mut self, transfer: &Nep141Transfer) -> HookState { - self.transfers.push(transfer); - self.hooks.push(&"before_transfer".to_string()); +impl Nep141Hook for FungibleToken { + type MintState = (); + type TransferState = HookState; + type BurnState = (); + fn before_mint(_contract: &Self, _amount: u128, _account_id: &AccountId) {} + + fn after_mint(_contract: &mut Self, _amount: u128, _account_id: &AccountId, _: ()) {} + + fn before_burn(_contract: &Self, _amount: u128, _account_id: &AccountId) {} + + fn after_burn(_contract: &mut Self, _amount: u128, _account_id: &AccountId, _: ()) {} + + fn before_transfer(_: &Self, _transfer: &Nep141Transfer) -> HookState { HookState { storage_usage_start: env::storage_usage(), } } - fn after_transfer(&mut self, _transfer: &Nep141Transfer, state: HookState) { - self.hooks.push(&"after_transfer".to_string()); + fn after_transfer(contract: &mut Self, transfer: &Nep141Transfer, state: HookState) { + contract.hooks.push(&"after_transfer".to_string()); + contract.transfers.push(transfer); println!( "Storage delta: {}", env::storage_usage() - state.storage_usage_start @@ -105,10 +115,11 @@ fn nep141_transfer() { amount: 50, memo: None, msg: None, + revert: false, }) ); - let expected_hook_execution_order = vec!["before_transfer", "after_transfer"]; + let expected_hook_execution_order = vec!["after_transfer"]; let actual_hook_execution_order = ft.hooks.to_vec(); assert_eq!(expected_hook_execution_order, actual_hook_execution_order); diff --git a/workspaces-tests/src/bin/fungible_token.rs b/workspaces-tests/src/bin/fungible_token.rs index e7f655d..188d193 100644 --- a/workspaces-tests/src/bin/fungible_token.rs +++ b/workspaces-tests/src/bin/fungible_token.rs @@ -12,7 +12,7 @@ use near_sdk::{ use near_sdk_contract_tools::{standard::nep141::*, FungibleToken}; #[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, FungibleToken)] -#[fungible_token(name = "My Fungible Token", symbol = "MYFT", decimals = 18, no_hooks)] +#[fungible_token(name = "My Fungible Token", symbol = "MYFT", decimals = 24, no_hooks)] #[near_bindgen] pub struct Contract {}