From 03d98d86d30c47e2f86c68988aa3cd6204241db3 Mon Sep 17 00:00:00 2001 From: Jacob Lindahl Date: Thu, 12 Oct 2023 09:12:45 -0500 Subject: [PATCH] BREAKING: Fungible Token Improvements (better hooks + dynamic metadata) (#127) * feat: better hooks for nep141 * chore: move resolve code to macro * chore: use Result instead of panicking in nep141 functions * chore: update nep148 to use dynamic metadata * feat: extension hooks * chore: upgrade to near-workspaces@0.8.0 * chore: use pretty_assertions and fix gas issue * chore: clean tests * fix: update ghactions rust version to 1.72 --------- Co-authored-by: Jacob Lindahl --- .github/workflows/rust.yml | 8 +- README.md | 38 +- macros/src/standard/fungible_token.rs | 33 +- macros/src/standard/nep141.rs | 161 ++++-- macros/src/standard/nep148.rs | 61 +-- rust-toolchain.toml | 2 +- rustfmt.toml | 1 + src/lib.rs | 11 + src/standard/nep141.rs | 538 ------------------- src/standard/nep141/error.rs | 81 +++ src/standard/nep141/event.rs | 120 +++++ src/standard/nep141/ext.rs | 56 ++ src/standard/nep141/hook.rs | 181 +++++++ src/standard/nep141/mod.rs | 330 ++++++++++++ src/standard/nep148.rs | 160 ++++-- tests/macros/mod.rs | 51 +- tests/macros/standard/fungible_token.rs | 119 ++-- tests/macros/standard/nep141.rs | 31 +- tests/macros/standard/nep148.rs | 29 +- workspaces-tests-utils/Cargo.toml | 2 +- workspaces-tests-utils/src/lib.rs | 4 +- workspaces-tests/Cargo.toml | 3 +- workspaces-tests/src/bin/fungible_token.rs | 19 +- workspaces-tests/tests/counter_multisig.rs | 5 +- workspaces-tests/tests/escrow.rs | 5 +- workspaces-tests/tests/fungible_token.rs | 42 +- workspaces-tests/tests/native_multisig.rs | 34 +- workspaces-tests/tests/non_fungible_token.rs | 13 +- workspaces-tests/tests/rbac.rs | 5 +- workspaces-tests/tests/simple_multisig.rs | 5 +- workspaces-tests/tests/storage_fee.rs | 6 +- workspaces-tests/tests/upgrade.rs | 5 +- workspaces-tests/tests/upgrade_multisig.rs | 5 +- 33 files changed, 1292 insertions(+), 872 deletions(-) delete mode 100644 src/standard/nep141.rs create mode 100644 src/standard/nep141/error.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/.github/workflows/rust.yml b/.github/workflows/rust.yml index 52293d4..915c5e0 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 components: rustfmt - name: Check formatting run: > @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 components: clippy - name: Run linter run: cargo clippy -- -D warnings @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 - name: Run unit and integration tests run: cargo test --workspace --exclude workspaces-tests workspaces-test: @@ -51,7 +51,7 @@ jobs: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.69 + toolchain: 1.72 targets: wasm32-unknown-unknown - name: Run workspaces tests run: > diff --git a/README.md b/README.md index 3b3e28f..d7ca4c7 100644 --- a/README.md +++ b/README.md @@ -99,19 +99,28 @@ e.emit(); To create a contract that is compatible with the NEP-141 and NEP-148 standards, that emits standard-compliant (NEP-141, NEP-297) events. ```rust -use near_sdk_contract_tools::FungibleToken; +use near_sdk_contract_tools::ft::*; use near_sdk::near_bindgen; #[derive(FungibleToken)] -#[fungible_token( - name = "My Fungible Token", - symbol = "MYFT", - decimals = 18, - no_hooks -)] +#[fungible_token(no_hooks)] #[near_bindgen] -struct FungibleToken { - // ... +struct FungibleToken {} + +#[near_bindgen] +impl FungibleToken { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + + contract.set_metadata(&FungibleTokenMetadata::new( + "My Fungible Token".to_string(), + "MYFT".to_string(), + 24, + )); + + contract + } } ``` @@ -140,20 +149,15 @@ pub struct MyNft {} One may wish to combine the features of multiple macros in one contract. All of the macros are written such that they will work in a standalone manner, so this should largely work without issue. However, sometimes it may be desirable for the macros to work in _combination_ with each other. For example, to make a fungible token pausable, use the fungible token hooks to require that a contract be unpaused before making a token transfer: ```rust -use near_sdk_contract_tools::{ - pause::Pause, - standard::nep141::{Nep141Hook, Nep141Transfer}, - FungibleToken, Pause, -}; +use near_sdk_contract_tools::{ft::*, pause::Pause, Pause}; use near_sdk::near_bindgen; #[derive(FungibleToken, Pause)] -#[fungible_token(name = "Pausable Fungible Token", symbol = "PFT", decimals = 18)] #[near_bindgen] struct Contract {} -impl Nep141Hook for Contract { - fn before_transfer(&mut self, _transfer: &Nep141Transfer) { +impl SimpleNep141Hook for Contract { + fn before_transfer(&self, _transfer: &Nep141Transfer) { Contract::require_unpaused(); } } diff --git a/macros/src/standard/fungible_token.rs b/macros/src/standard/fungible_token.rs index 866a0af..888f54a 100644 --- a/macros/src/standard/fungible_token.rs +++ b/macros/src/standard/fungible_token.rs @@ -9,17 +9,11 @@ use super::{nep141, nep148}; #[darling(attributes(fungible_token), supports(struct_named))] pub struct FungibleTokenMeta { // NEP-141 fields - pub storage_key: Option, + pub core_storage_key: Option, pub no_hooks: Flag, // NEP-148 fields - pub spec: Option, - pub name: String, - pub symbol: String, - pub icon: Option, - pub reference: Option, - pub reference_hash: Option, - pub decimals: u8, + pub metadata_storage_key: Option, // darling pub generics: syn::Generics, @@ -34,17 +28,10 @@ pub struct FungibleTokenMeta { pub fn expand(meta: FungibleTokenMeta) -> Result { let FungibleTokenMeta { - storage_key, + core_storage_key, + metadata_storage_key, no_hooks, - spec, - name, - symbol, - icon, - reference, - reference_hash, - decimals, - generics, ident, @@ -53,8 +40,9 @@ pub fn expand(meta: FungibleTokenMeta) -> Result { } = meta; let expand_nep141 = nep141::expand(nep141::Nep141Meta { - storage_key, + storage_key: core_storage_key, no_hooks, + extension_hooks: None, generics: generics.clone(), ident: ident.clone(), @@ -64,14 +52,7 @@ pub fn expand(meta: FungibleTokenMeta) -> Result { }); let expand_nep148 = nep148::expand(nep148::Nep148Meta { - spec, - name, - symbol, - icon, - reference, - reference_hash, - decimals, - + storage_key: metadata_storage_key, generics, ident, diff --git a/macros/src/standard/nep141.rs b/macros/src/standard/nep141.rs index 2e6c962..718dc32 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; @@ -10,6 +8,7 @@ use syn::Expr; pub struct Nep141Meta { pub storage_key: Option, pub no_hooks: Flag, + pub extension_hooks: Option, pub generics: syn::Generics, pub ident: syn::Ident, @@ -24,6 +23,7 @@ pub fn expand(meta: Nep141Meta) -> Result { let Nep141Meta { storage_key, no_hooks, + extension_hooks, generics, ident, @@ -41,20 +41,21 @@ pub fn expand(meta: Nep141Meta) -> Result { } }); - let before_transfer = no_hooks.is_present().not().then(|| { - quote! { - let hook_state = >::before_transfer(self, &transfer); - } - }); + let self_hook = if no_hooks.is_present() { + quote! { () } + } else { + quote! { Self } + }; - let after_transfer = no_hooks.is_present().not().then(|| { - quote! { - >::after_transfer(self, &transfer, hook_state); - } - }); + let hook = if let Some(extension_hooks) = extension_hooks { + quote! { (#self_hook, #extension_hooks) } + } else { + self_hook + }; Ok(quote! { impl #imp #me::standard::nep141::Nep141ControllerInternal for #ident #ty #wher { + type Hook = #hook; #root } @@ -67,36 +68,23 @@ 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) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); } #[payable] @@ -107,41 +95,57 @@ 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) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + + 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() } } @@ -154,12 +158,51 @@ pub fn expand(meta: Nep141Meta) -> Result { receiver_id: #near_sdk::AccountId, amount: #near_sdk::json_types::U128, ) -> #near_sdk::json_types::U128 { - #me::standard::nep141::Nep141Controller::resolve_transfer( - self, - sender_id, - receiver_id, - amount.into(), - ).into() + use #near_sdk::{env, PromiseResult, serde_json, json_types::U128}; + use #me::standard::nep141::*; + + let amount = amount.0; + + 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 = Nep141Controller::balance_of(self, &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, + }; + + Nep141Controller::transfer(self, &transfer) + .unwrap_or_else(|e| env::panic_str(&e.to_string())); + + refund_amount + } else { + 0 + } + } else { + 0 + }; + + // Used amount + U128(amount - refunded_amount) } } }) diff --git a/macros/src/standard/nep148.rs b/macros/src/standard/nep148.rs index 5580317..227f3bd 100644 --- a/macros/src/standard/nep148.rs +++ b/macros/src/standard/nep148.rs @@ -1,18 +1,12 @@ -use darling::{FromDeriveInput, ToTokens}; +use darling::FromDeriveInput; use proc_macro2::TokenStream; use quote::quote; +use syn::Expr; #[derive(Debug, FromDeriveInput)] #[darling(attributes(nep148), supports(struct_named))] pub struct Nep148Meta { - pub spec: Option, - pub name: String, - pub symbol: String, - pub icon: Option, - pub reference: Option, - pub reference_hash: Option, - pub decimals: u8, - + pub storage_key: Option, pub generics: syn::Generics, pub ident: syn::Ident, @@ -23,64 +17,35 @@ pub struct Nep148Meta { pub near_sdk: syn::Path, } -fn optionize(t: Option) -> TokenStream -where - T: ToTokens, -{ - t.map_or_else(|| quote! { None }, |v| quote! { Some(#v) }) -} - pub fn expand(meta: Nep148Meta) -> Result { let Nep148Meta { + storage_key, generics, ident, - // fields - spec, - name, - symbol, - icon, - reference, - reference_hash, - decimals, me, near_sdk, } = meta; - let spec = spec.map(|s| s.to_token_stream()).unwrap_or_else(|| { + let root = storage_key.map(|storage_key| { quote! { - #me::standard::nep148::FT_METADATA_SPEC + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } } }); - let icon = optionize(icon); - let reference = optionize(reference); - - // TODO: Download reference field at compile time and calculate reference_hash automatically - let reference_hash = optionize(reference_hash.map(|s| { - let v = format!("{:?}", base64::decode(s).unwrap()) - .parse::() - .unwrap(); - - quote! { #near_sdk::json_types::Base64VecU8::from(#v.to_vec()) } - })); - let (imp, ty, wher) = generics.split_for_impl(); Ok(quote! { - use #me::standard::nep148::Nep148; + impl #imp #me::standard::nep148::Nep148ControllerInternal for #ident #ty #wher { + #root + } + #[#near_sdk::near_bindgen] impl #imp #me::standard::nep148::Nep148 for #ident #ty #wher { fn ft_metadata(&self) -> #me::standard::nep148::FungibleTokenMetadata { - #me::standard::nep148::FungibleTokenMetadata { - spec: #spec.into(), - name: #name.into(), - symbol: #symbol.into(), - icon: #icon.map(|s: &str| s.into()), - reference: #reference.map(|s: &str| s.into()), - reference_hash: #reference_hash, - decimals: #decimals, - } + #me::standard::nep148::Nep148Controller::get_metadata(self) } } }) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 5eca3a9..7e8f0a9 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.69" # https://github.com/near/nearcore/issues/9143 +channel = "1.72" 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/lib.rs b/src/lib.rs index f1f34d7..74f0f41 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,8 @@ pub enum DefaultStorageKey { ApprovalManager, /// Default storage key for [`standard::nep141::Nep141ControllerInternal::root`]. Nep141, + /// Default storage key for [`standard::nep148::Nep148ControllerInternal::root`]. + Nep148, /// Default storage key for [`standard::nep171::Nep171ControllerInternal::root`]. Nep171, /// Default storage key for [`standard::nep177::Nep177ControllerInternal::root`]. @@ -34,6 +36,7 @@ impl IntoStorageKey for DefaultStorageKey { match self { DefaultStorageKey::ApprovalManager => b"~am".to_vec(), DefaultStorageKey::Nep141 => b"~$141".to_vec(), + DefaultStorageKey::Nep148 => b"~$148".to_vec(), DefaultStorageKey::Nep171 => b"~$171".to_vec(), DefaultStorageKey::Nep177 => b"~$177".to_vec(), DefaultStorageKey::Nep178 => b"~$178".to_vec(), @@ -66,3 +69,11 @@ pub mod nft { Nep171, Nep177, Nep178, Nep181, NonFungibleToken, }; } + +/// Re-exports of the FT standard traits. +pub mod ft { + pub use crate::{ + standard::{nep141::*, nep148::*}, + FungibleToken, Nep141, Nep148, + }; +} 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/error.rs b/src/standard/nep141/error.rs new file mode 100644 index 0000000..397dd19 --- /dev/null +++ b/src/standard/nep141/error.rs @@ -0,0 +1,81 @@ +//! Error types for NEP-141 implementations. + +use near_sdk::AccountId; +use thiserror::Error; + +/// Errors that may occur when withdrawing (burning) tokens. +#[derive(Debug, Error)] +pub enum WithdrawError { + /// The account does not have enough balance to withdraw the given amount. + #[error(transparent)] + BalanceUnderflow(#[from] BalanceUnderflowError), + /// The total supply is less than the amount to be burned. + #[error(transparent)] + TotalSupplyUnderflow(#[from] TotalSupplyUnderflowError), +} + +/// An account does not have enough balance to withdraw the given amount. +#[derive(Debug, Error)] +#[error("The account {account_id} does not have enough balance to withdraw {amount} (current balance: {balance}).")] +pub struct BalanceUnderflowError { + /// The account ID. + pub account_id: AccountId, + /// The current balance of the account. + pub balance: u128, + /// The amount of the failed withdrawal attempt. + pub amount: u128, +} + +/// The total supply is less than the amount to be burned. +#[derive(Debug, Error)] +#[error("The total supply ({total_supply}) is less than the amount to be burned ({amount}).")] +pub struct TotalSupplyUnderflowError { + /// The total supply. + pub total_supply: u128, + /// The amount of the failed withdrawal attempt. + pub amount: u128, +} + +/// Errors that may occur when depositing (minting) tokens. +#[derive(Debug, Error)] +pub enum DepositError { + /// The balance of the receiver would overflow u128. + #[error(transparent)] + BalanceOverflow(#[from] BalanceOverflowError), + /// The total supply would overflow u128. + #[error(transparent)] + TotalSupplyOverflow(#[from] TotalSupplyOverflowError), +} + +/// The balance of the account would overflow u128. +#[derive(Debug, Error)] +#[error("The balance of {account_id} ({balance}) plus {amount} would overflow u128.")] +pub struct BalanceOverflowError { + /// The account ID. + pub account_id: AccountId, + /// The current balance of the account. + pub balance: u128, + /// The amount of the failed deposit attempt. + pub amount: u128, +} + +/// The total supply would overflow u128. +#[derive(Debug, Error)] +#[error("The total supply ({total_supply}) plus {amount} would overflow u128.")] +pub struct TotalSupplyOverflowError { + /// The total supply. + pub total_supply: u128, + /// The amount of the failed deposit attempt. + pub amount: u128, +} + +/// Errors that may occur when transferring tokens. +#[derive(Debug, Error)] +pub enum TransferError { + /// The balance of the receiver would overflow u128. + #[error("Balance of the receiver would overflow u128: {0}")] + ReceiverBalanceOverflow(#[from] BalanceOverflowError), + /// The balance of the sender is insufficient. + #[error("Balance of the sender is insufficient: {0}")] + SenderBalanceUnderflow(#[from] BalanceUnderflowError), +} 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..15797d0 --- /dev/null +++ b/src/standard/nep141/mod.rs @@ -0,0 +1,330 @@ +//! NEP-141 fungible token core implementation +//! + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + AccountId, BorshStorageKey, Gas, +}; +use serde::{Deserialize, Serialize}; + +use crate::{slot::Slot, standard::nep297::*, DefaultStorageKey}; + +mod error; +pub use error::*; +mod event; +pub use event::*; +mod ext; +pub use ext::*; +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. + fn withdraw_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), WithdrawError>; + + /// Increases the token balance of an account. Updates total supply. No + /// event emission. + fn deposit_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), DepositError>; + + /// 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, + ) -> Result<(), TransferError>; + + /// Performs an NEP-141 token transfer, with event emission. + /// + /// # Panics + /// + /// See: [`Nep141Controller::transfer_unchecked`] + fn transfer(&mut self, transfer: &Nep141Transfer) -> Result<(), TransferError>; + + /// 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, + ) -> Result<(), DepositError>; + + /// 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, + ) -> Result<(), WithdrawError>; +} + +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, + ) -> Result<(), WithdrawError> { + 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 { + return Err(BalanceUnderflowError { + account_id: account_id.clone(), + balance, + amount, + } + .into()); + } + + let total_supply = self.total_supply(); + if let Some(total_supply) = total_supply.checked_sub(amount) { + Self::slot_total_supply().write(&total_supply); + } else { + return Err(TotalSupplyUnderflowError { + total_supply, + amount, + } + .into()); + } + } + + Ok(()) + } + + fn deposit_unchecked( + &mut self, + account_id: &AccountId, + amount: u128, + ) -> Result<(), DepositError> { + 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 { + return Err(BalanceOverflowError { + account_id: account_id.clone(), + balance, + amount, + } + .into()); + } + + let total_supply = self.total_supply(); + if let Some(total_supply) = total_supply.checked_add(amount) { + Self::slot_total_supply().write(&total_supply); + } else { + return Err(TotalSupplyOverflowError { + total_supply, + amount, + } + .into()); + } + } + + Ok(()) + } + + fn transfer_unchecked( + &mut self, + sender_account_id: &AccountId, + receiver_account_id: &AccountId, + amount: u128, + ) -> Result<(), TransferError> { + 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 { + return Err(BalanceOverflowError { + account_id: receiver_account_id.clone(), + balance: receiver_balance, + amount, + } + .into()); + } + } else { + return Err(BalanceUnderflowError { + account_id: sender_account_id.clone(), + balance: sender_balance, + amount, + } + .into()); + } + + Ok(()) + } + + fn transfer(&mut self, transfer: &Nep141Transfer) -> Result<(), TransferError> { + 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); + + Ok(()) + } + + fn mint( + &mut self, + account_id: AccountId, + amount: u128, + memo: Option, + ) -> Result<(), DepositError> { + 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(); + + Ok(()) + } + + fn burn( + &mut self, + account_id: AccountId, + amount: u128, + memo: Option, + ) -> Result<(), WithdrawError> { + 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(); + + Ok(()) + } +} diff --git a/src/standard/nep148.rs b/src/standard/nep148.rs index 5f66cea..ebcebc8 100644 --- a/src/standard/nep148.rs +++ b/src/standard/nep148.rs @@ -1,16 +1,22 @@ //! NEP-148 fungible token metadata implementation //! -#![allow(missing_docs)] // ext_contract doesn't play nice with #![warn(missing_docs)] use near_sdk::{ borsh::{self, BorshDeserialize, BorshSerialize}, - ext_contract, + env, json_types::Base64VecU8, + BorshStorageKey, }; use serde::{Deserialize, Serialize}; -/// Version of the NEP-148 metadata spec +use crate::{slot::Slot, DefaultStorageKey}; + +pub use ext::*; + +/// Version of the NEP-148 metadata spec. pub const FT_METADATA_SPEC: &str = "ft-1.0.0"; +/// Error message for unset metadata. +pub const ERR_METADATA_UNSET: &str = "NEP-148 metadata is not set"; /// NEP-148-compatible metadata struct #[derive(BorshDeserialize, BorshSerialize, Deserialize, Serialize, Eq, PartialEq, Clone, Debug)] @@ -30,49 +36,121 @@ pub struct FungibleTokenMetadata { /// For tamper protection. pub reference_hash: Option, /// Cosmetic. Number of base-10 decimal places to shift the floating point. - /// 18 is a common value. + /// 24 is a common value. pub decimals: u8, } -/// Contract that supports the NEP-148 metadata standard -#[ext_contract(ext_nep148)] -pub trait Nep148 { +impl FungibleTokenMetadata { + /// Creates a new metadata struct. + pub fn new(name: String, symbol: String, decimals: u8) -> Self { + Self { + spec: FT_METADATA_SPEC.into(), + name, + symbol, + icon: None, + reference: None, + reference_hash: None, + decimals, + } + } + + /// Sets the spec field. + pub fn spec(mut self, spec: String) -> Self { + self.spec = spec; + self + } + + /// Sets the name field. + pub fn name(mut self, name: String) -> Self { + self.name = name; + self + } + + /// Sets the symbol field. + pub fn symbol(mut self, symbol: String) -> Self { + self.symbol = symbol; + self + } + + /// Sets the icon field. + pub fn icon(mut self, icon: String) -> Self { + self.icon = Some(icon); + self + } + + /// Sets the reference field. + pub fn reference(mut self, reference: String) -> Self { + self.reference = Some(reference); + self + } + + /// Sets the reference_hash field. + pub fn reference_hash(mut self, reference_hash: Base64VecU8) -> Self { + self.reference_hash = Some(reference_hash); + self + } + + /// Sets the decimals field. + pub fn decimals(mut self, decimals: u8) -> Self { + self.decimals = decimals; + self + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey { + Metadata, +} + +/// Internal functions for [`Nep148Controller`]. +pub trait Nep148ControllerInternal { + /// Returns the root storage slot for NEP-148. + fn root() -> Slot<()> { + Slot::new(DefaultStorageKey::Nep148) + } + + /// Returns the storage slot for NEP-148 metadata. + fn metadata() -> Slot { + Self::root().field(StorageKey::Metadata) + } +} + +/// Management functions for NEP-148. +pub trait Nep148Controller { /// Returns the metadata struct for this contract. - fn ft_metadata(&self) -> FungibleTokenMetadata; + /// + /// # Panics + /// + /// Panics if the metadata has not been set. + fn get_metadata(&self) -> FungibleTokenMetadata; + + /// Sets the metadata struct for this contract. + fn set_metadata(&mut self, metadata: &FungibleTokenMetadata); } -#[cfg(test)] -mod tests { - use crate::standard::nep148::FungibleTokenMetadata; - use near_sdk::borsh::BorshSerialize; - - #[test] - fn borsh_serialization_ignores_cow() { - let m1 = FungibleTokenMetadata { - spec: "spec".into(), - name: "name".into(), - symbol: "symbol".into(), - icon: Some("icon".into()), - reference: Some("reference".into()), - reference_hash: Some(b"reference_hash".to_vec().into()), - decimals: 18, - }; - - let m2 = FungibleTokenMetadata { - spec: "spec".to_owned(), - name: "name".to_owned(), - symbol: "symbol".to_owned(), - icon: Some("icon".to_owned()), - reference: Some("reference".to_owned()), - reference_hash: Some(b"reference_hash".to_vec().into()), - decimals: 18, - }; - - assert_eq!(m1, m2); - - let m1_serialized = m1.try_to_vec().unwrap(); - let m2_serialized = m2.try_to_vec().unwrap(); - - assert_eq!(m1_serialized, m2_serialized); +impl Nep148Controller for T { + fn get_metadata(&self) -> FungibleTokenMetadata { + Self::metadata() + .read() + .unwrap_or_else(|| env::panic_str(ERR_METADATA_UNSET)) + } + + fn set_metadata(&mut self, metadata: &FungibleTokenMetadata) { + Self::metadata().set(Some(metadata)); + } +} + +mod ext { + #![allow(missing_docs)] // ext_contract doesn't play well + + use near_sdk::ext_contract; + + use super::FungibleTokenMetadata; + + /// Contract that supports the NEP-148 metadata standard + #[ext_contract(ext_nep148)] + pub trait Nep148 { + /// Returns the metadata struct for this contract. + fn ft_metadata(&self) -> FungibleTokenMetadata; } } diff --git a/tests/macros/mod.rs b/tests/macros/mod.rs index 5efe265..a55cde8 100644 --- a/tests/macros/mod.rs +++ b/tests/macros/mod.rs @@ -4,8 +4,8 @@ use near_sdk::{ test_utils::VMContextBuilder, testing_env, AccountId, BorshStorageKey, }; -use near_sdk_contract_tools::escrow::Escrow; use near_sdk_contract_tools::{ + escrow::Escrow, migrate::{MigrateExternal, MigrateHook}, owner::Owner, pause::Pause, @@ -396,35 +396,62 @@ mod pausable_fungible_token { }; use near_sdk_contract_tools::{ pause::Pause, - standard::nep141::{Nep141, Nep141Controller, Nep141Hook, Nep141Transfer}, + standard::{nep141::*, nep148::*}, FungibleToken, Pause, }; #[derive(FungibleToken, Pause, BorshDeserialize, BorshSerialize)] - #[fungible_token(name = "Pausable Fungible Token", symbol = "PFT", decimals = 18)] #[near_bindgen] struct Contract { pub storage_usage: u64, } + #[near_bindgen] + impl Contract { + #[init] + pub fn new() -> Self { + let mut contract = Self { storage_usage: 0 }; + + contract.set_metadata(&FungibleTokenMetadata::new( + "Pausable Fungible Token".into(), + "PFT".into(), + 18, + )); + + contract + } + } + #[derive(Default)] struct HookState { 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; } } @@ -433,9 +460,9 @@ mod pausable_fungible_token { let alice: AccountId = "alice".parse().unwrap(); let bob: AccountId = "bob_account".parse().unwrap(); - let mut c = Contract { storage_usage: 0 }; + let mut c = Contract::new(); - c.deposit_unchecked(&alice, 100); + c.deposit_unchecked(&alice, 100).unwrap(); let context = VMContextBuilder::new() .attached_deposit(1) @@ -455,9 +482,9 @@ mod pausable_fungible_token { let alice: AccountId = "alice".parse().unwrap(); let bob: AccountId = "bob_account".parse().unwrap(); - let mut c = Contract { storage_usage: 0 }; + let mut c = Contract::new(); - c.deposit_unchecked(&alice, 100); + c.deposit_unchecked(&alice, 100).unwrap(); let context = VMContextBuilder::new() .attached_deposit(1) diff --git a/tests/macros/standard/fungible_token.rs b/tests/macros/standard/fungible_token.rs index aab81b6..9109835 100644 --- a/tests/macros/standard/fungible_token.rs +++ b/tests/macros/standard/fungible_token.rs @@ -1,71 +1,84 @@ -use near_sdk::{ - json_types::Base64VecU8, near_bindgen, test_utils::VMContextBuilder, testing_env, AccountId, -}; +use near_sdk::{json_types::Base64VecU8, near_bindgen}; use near_sdk_contract_tools::{ - standard::nep141::{Nep141, Nep141Controller}, + standard::{nep141::*, nep148::*}, FungibleToken, }; #[derive(FungibleToken)] -#[fungible_token( - name = "My Fungible Token", - symbol = "MYFT", - decimals = 18, - icon = "https://example.com/icon.png", - reference = "https://example.com/metadata.json", - reference_hash = "YXNkZg==", - no_hooks -)] +#[fungible_token(no_hooks)] #[near_bindgen] struct MyFungibleTokenContract {} -#[test] -fn fungible_token_transfer() { - let mut ft = MyFungibleTokenContract {}; +#[near_bindgen] +impl MyFungibleTokenContract { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; - let alice: AccountId = "alice".parse().unwrap(); - let bob: AccountId = "bob".parse().unwrap(); + contract.set_metadata( + &FungibleTokenMetadata::new("My Fungible Token".into(), "MYFT".into(), 24) + .icon("https://example.com/icon.png".into()) + .reference("https://example.com/metadata.json".into()) + .reference_hash(Base64VecU8::from([97, 115, 100, 102].to_vec())), + ); - assert_eq!(ft.ft_balance_of(alice.clone()).0, 0); - assert_eq!(ft.ft_balance_of(bob.clone()).0, 0); - assert_eq!(ft.ft_total_supply().0, 0); + contract + } +} - ft.deposit_unchecked(&alice, 100); - ft.deposit_unchecked(&bob, 20); +#[cfg(test)] +mod tests { + use super::*; + use near_sdk::{test_utils::VMContextBuilder, testing_env, AccountId}; - assert_eq!(ft.ft_balance_of(alice.clone()).0, 100); - assert_eq!(ft.ft_balance_of(bob.clone()).0, 20); - assert_eq!(ft.ft_total_supply().0, 120); + #[test] + fn fungible_token_transfer() { + let mut ft = MyFungibleTokenContract::new(); - let context = VMContextBuilder::new() - .predecessor_account_id(alice.clone()) - .attached_deposit(1) - .build(); + let alice: AccountId = "alice".parse().unwrap(); + let bob: AccountId = "bob".parse().unwrap(); - testing_env!(context); + assert_eq!(ft.ft_balance_of(alice.clone()).0, 0); + assert_eq!(ft.ft_balance_of(bob.clone()).0, 0); + assert_eq!(ft.ft_total_supply().0, 0); - ft.ft_transfer(bob.clone(), 50.into(), None); + ft.deposit_unchecked(&alice, 100).unwrap(); + ft.deposit_unchecked(&bob, 20).unwrap(); - assert_eq!(ft.ft_balance_of(alice.clone()).0, 50); - assert_eq!(ft.ft_balance_of(bob.clone()).0, 70); - assert_eq!(ft.ft_total_supply().0, 120); -} + assert_eq!(ft.ft_balance_of(alice.clone()).0, 100); + assert_eq!(ft.ft_balance_of(bob.clone()).0, 20); + assert_eq!(ft.ft_total_supply().0, 120); + + let context = VMContextBuilder::new() + .predecessor_account_id(alice.clone()) + .attached_deposit(1) + .build(); + + testing_env!(context); + + ft.ft_transfer(bob.clone(), 50.into(), None); + + assert_eq!(ft.ft_balance_of(alice.clone()).0, 50); + assert_eq!(ft.ft_balance_of(bob.clone()).0, 70); + assert_eq!(ft.ft_total_supply().0, 120); + } + + #[test] + fn metadata() { + let ft = MyFungibleTokenContract::new(); + let meta = ft.ft_metadata(); -#[test] -fn metadata() { - let ft = MyFungibleTokenContract {}; - let meta = ft.ft_metadata(); - - assert_eq!(meta.decimals, 18); - assert_eq!(meta.name, "My Fungible Token"); - assert_eq!(meta.symbol, "MYFT"); - assert_eq!(meta.icon, Some("https://example.com/icon.png".into())); - assert_eq!( - meta.reference, - Some("https://example.com/metadata.json".into()) - ); - assert_eq!( - meta.reference_hash, - Some(Base64VecU8::from([97, 115, 100, 102].to_vec())) - ); + assert_eq!(meta.decimals, 24); + assert_eq!(meta.name, "My Fungible Token"); + assert_eq!(meta.symbol, "MYFT"); + assert_eq!(meta.icon, Some("https://example.com/icon.png".into())); + assert_eq!( + meta.reference, + Some("https://example.com/metadata.json".into()) + ); + assert_eq!( + meta.reference_hash, + Some(Base64VecU8::from([97, 115, 100, 102].to_vec())) + ); + } } diff --git a/tests/macros/standard/nep141.rs b/tests/macros/standard/nep141.rs index 9c8ccd8..3be84d9 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 @@ -64,8 +74,6 @@ impl near_sdk_contract_tools::standard::nep141::Nep141Receiver for FungibleToken } } -// TODO: transfer_call testing (not possible without workspaces-rs or something -// like that, and workspaces-rs doesn't work on macOS) #[test] fn nep141_transfer() { let mut ft = FungibleToken { @@ -80,8 +88,8 @@ fn nep141_transfer() { assert_eq!(ft.ft_balance_of(bob.clone()).0, 0); assert_eq!(ft.ft_total_supply().0, 0); - ft.deposit_unchecked(&alice, 100); - ft.deposit_unchecked(&bob, 20); + ft.deposit_unchecked(&alice, 100).unwrap(); + ft.deposit_unchecked(&bob, 20).unwrap(); assert_eq!(ft.transfers.pop(), None); assert_eq!(ft.ft_balance_of(alice.clone()).0, 100); @@ -105,10 +113,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/tests/macros/standard/nep148.rs b/tests/macros/standard/nep148.rs index f5b79c0..b1adc1b 100644 --- a/tests/macros/standard/nep148.rs +++ b/tests/macros/standard/nep148.rs @@ -1,21 +1,30 @@ use near_sdk::{json_types::Base64VecU8, near_bindgen}; -use near_sdk_contract_tools::Nep148; +use near_sdk_contract_tools::{standard::nep148::*, Nep148}; #[derive(Nep148)] -#[nep148( - name = "Test Fungible Token", - symbol = "TFT", - decimals = 18, - icon = "https://example.com/icon.png", - reference = "https://example.com/metadata.json", - reference_hash = "YXNkZg==" -)] #[near_bindgen] struct DerivesFTMetadata {} +#[near_bindgen] +impl DerivesFTMetadata { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + + contract.set_metadata( + &FungibleTokenMetadata::new("Test Fungible Token".into(), "TFT".into(), 18) + .icon("https://example.com/icon.png".into()) + .reference("https://example.com/metadata.json".into()) + .reference_hash(Base64VecU8::from([97, 115, 100, 102].to_vec())), + ); + + contract + } +} + #[test] fn test() { - let ft = DerivesFTMetadata {}; + let ft = DerivesFTMetadata::new(); let meta = ft.ft_metadata(); println!("{:?}", &meta); assert_eq!(meta.decimals, 18); diff --git a/workspaces-tests-utils/Cargo.toml b/workspaces-tests-utils/Cargo.toml index 2304f9f..35d7851 100644 --- a/workspaces-tests-utils/Cargo.toml +++ b/workspaces-tests-utils/Cargo.toml @@ -6,4 +6,4 @@ publish = false [target.'cfg(not(windows))'.dependencies] near-sdk.workspace = true -workspaces = "0.7" +near-workspaces = "0.8" diff --git a/workspaces-tests-utils/src/lib.rs b/workspaces-tests-utils/src/lib.rs index ef49adf..633518b 100644 --- a/workspaces-tests-utils/src/lib.rs +++ b/workspaces-tests-utils/src/lib.rs @@ -2,7 +2,7 @@ #![cfg(not(windows))] use near_sdk::{serde::de::DeserializeOwned, serde_json::json}; -use workspaces::{result::ExecutionFinalResult, Account, Contract}; +use near_workspaces::{result::ExecutionFinalResult, Account, Contract}; pub async fn nft_token(contract: &Contract, token_id: &str) -> Option { contract @@ -21,7 +21,7 @@ pub struct Setup { /// Setup for individual tests pub async fn setup(wasm: &[u8], num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(wasm).await.unwrap(); diff --git a/workspaces-tests/Cargo.toml b/workspaces-tests/Cargo.toml index 216b1c5..d48241b 100644 --- a/workspaces-tests/Cargo.toml +++ b/workspaces-tests/Cargo.toml @@ -69,7 +69,8 @@ thiserror.workspace = true [dev-dependencies] near-crypto = "0.15.0" tokio = "1.21.1" +pretty_assertions = "1.4.0" [target.'cfg(not(windows))'.dev-dependencies] -workspaces = "0.7" +near-workspaces = "0.8" workspaces-tests-utils = { path = "../workspaces-tests-utils" } diff --git a/workspaces-tests/src/bin/fungible_token.rs b/workspaces-tests/src/bin/fungible_token.rs index e7f655d..6306e2e 100644 --- a/workspaces-tests/src/bin/fungible_token.rs +++ b/workspaces-tests/src/bin/fungible_token.rs @@ -9,10 +9,13 @@ use near_sdk::{ json_types::U128, near_bindgen, PanicOnDefault, }; -use near_sdk_contract_tools::{standard::nep141::*, FungibleToken}; +use near_sdk_contract_tools::{ + standard::{nep141::*, nep148::*}, + FungibleToken, +}; #[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, FungibleToken)] -#[fungible_token(name = "My Fungible Token", symbol = "MYFT", decimals = 18, no_hooks)] +#[fungible_token(no_hooks)] #[near_bindgen] pub struct Contract {} @@ -20,10 +23,18 @@ pub struct Contract {} impl Contract { #[init] pub fn new() -> Self { - Self {} + let mut contract = Self {}; + + contract.set_metadata(&FungibleTokenMetadata::new( + "My Fungible Token".into(), + "MYFT".into(), + 24, + )); + + contract } pub fn mint(&mut self, amount: U128) { - self.deposit_unchecked(&env::predecessor_account_id(), amount.into()); + Nep141Controller::mint(self, env::predecessor_account_id(), amount.into(), None).unwrap(); } } diff --git a/workspaces-tests/tests/counter_multisig.rs b/workspaces-tests/tests/counter_multisig.rs index f82d9c9..640da22 100644 --- a/workspaces-tests/tests/counter_multisig.rs +++ b/workspaces-tests/tests/counter_multisig.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::serde_json::json; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/counter_multisig.wasm"); @@ -13,7 +14,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(WASM).await.unwrap(); diff --git a/workspaces-tests/tests/escrow.rs b/workspaces-tests/tests/escrow.rs index db216b9..b0308fe 100644 --- a/workspaces-tests/tests/escrow.rs +++ b/workspaces-tests/tests/escrow.rs @@ -4,8 +4,9 @@ use near_sdk::{ serde::{Deserialize, Serialize}, serde_json::{self, json}, }; +use near_workspaces::{Account, AccountId, Contract}; +use pretty_assertions::assert_eq; use tokio::join; -use workspaces::{Account, AccountId, Contract}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/escrow.wasm"); @@ -36,7 +37,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![]; diff --git a/workspaces-tests/tests/fungible_token.rs b/workspaces-tests/tests/fungible_token.rs index 0be29f0..610111f 100644 --- a/workspaces-tests/tests/fungible_token.rs +++ b/workspaces-tests/tests/fungible_token.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::{json_types::U128, serde_json::json}; -use workspaces::{Account, AccountId, Contract}; +use near_workspaces::{Account, AccountId, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/fungible_token.wasm"); @@ -24,7 +25,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(WASM).await.unwrap(); @@ -162,3 +163,40 @@ async fn transfer_no_deposit() { .unwrap() .unwrap(); } + +#[tokio::test] +#[should_panic(expected = "Balance of the sender is insufficient")] +async fn transfer_more_than_balance() { + let Setup { contract, accounts } = setup_balances(3, |i| 10u128.pow(3 - i as u32).into()).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + alice + .call(contract.id(), "ft_transfer") + .args_json(json!({ + "receiver_id": bob.id(), + "amount": "1000000", + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +#[should_panic(expected = "TotalSupplyOverflowError")] +async fn transfer_overflow_u128() { + let Setup { contract, accounts } = setup_balances(2, |_| (u128::MAX / 2).into()).await; + let alice = &accounts[0]; + + alice + .call(contract.id(), "mint") + .args_json(json!({ + "amount": "2", + })) + .transact() + .await + .unwrap() + .unwrap(); +} diff --git a/workspaces-tests/tests/native_multisig.rs b/workspaces-tests/tests/native_multisig.rs index 9034b3a..46b3003 100644 --- a/workspaces-tests/tests/native_multisig.rs +++ b/workspaces-tests/tests/native_multisig.rs @@ -5,13 +5,14 @@ use std::{future::IntoFuture, time::Duration}; use near_crypto::{KeyType, SecretKey}; use near_sdk::{serde_json::json, Gas, ONE_NEAR}; use near_sdk_contract_tools::approval::native_transaction_action::PromiseAction; -use tokio::{join, task::JoinSet, time::sleep}; -use workspaces::{ +use near_workspaces::{ result::{ExecutionResult, Value}, sandbox, types::{AccessKeyPermission, Finality}, - Account, Contract, DevNetwork, Worker, + Account, AccountDetailsPatch, Contract, DevNetwork, Worker, }; +use pretty_assertions::assert_eq; +use tokio::{join, time::sleep}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/native_multisig.wasm"); @@ -106,23 +107,12 @@ async fn stake() { const MINIMUM_STAKE: u128 = 800_000_000_000_000_000_000_000_000; - let mut set = JoinSet::new(); - - // Fund contract account to meet minumum stake requirement. - // At the time of writing, it looks like workspaces network configuration is not yet released: - // https://github.com/near/workspaces-rs/commit/8df17e5d6ebbfe9ced52beb133f3e5b07a86dffb - // Otherwise we could just decrease the staking requirement. - for _ in 0..10 { - let w = worker.clone(); - let contract_id = contract.id().clone(); - set.spawn(async move { - let a = w.dev_create_account().await.unwrap(); - a.delete_account(&contract_id).await.unwrap().unwrap(); - }); - } - - // Await all - while set.join_next().await.is_some() {} + worker + .patch(contract.id()) + .account(AccountDetailsPatch::default().balance(MINIMUM_STAKE * 4)) + .transact() + .await + .unwrap(); let alice = &accounts[0]; let bob = &accounts[1]; @@ -136,7 +126,7 @@ async fn stake() { "Account should start with no staked tokens" ); - let stake_amount = MINIMUM_STAKE; + let stake_amount = MINIMUM_STAKE * 2; let request_id = alice .call(contract.id(), "request") @@ -235,7 +225,7 @@ async fn create_account_transfer_deploy_contract_function_call() { let bob = &accounts[1]; let new_account_id_str = format!("new.{}", contract.id()); - let new_account_id: workspaces::AccountId = new_account_id_str.parse().unwrap(); + let new_account_id: near_workspaces::AccountId = new_account_id_str.parse().unwrap(); // Account does not exist yet assert!(worker.view_account(&new_account_id).await.is_err()); diff --git a/workspaces-tests/tests/non_fungible_token.rs b/workspaces-tests/tests/non_fungible_token.rs index 84f2c29..9ec5673 100644 --- a/workspaces-tests/tests/non_fungible_token.rs +++ b/workspaces-tests/tests/non_fungible_token.rs @@ -9,8 +9,9 @@ use near_sdk_contract_tools::standard::{ nep178, nep297::Event, }; +use near_workspaces::{operations::Function, types::Gas}; +use pretty_assertions::assert_eq; use tokio::task::JoinSet; -use workspaces::operations::Function; use workspaces_tests_utils::{expect_execution_error, nft_token, setup, Setup}; const WASM_171_ONLY: &[u8] = @@ -22,6 +23,8 @@ const WASM_FULL: &[u8] = const RECEIVER_WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/non_fungible_token_receiver.wasm"); +const THIRTY_TERAGAS: Gas = Gas::from_gas(30_000_000_000_000); + fn token_meta(id: String) -> near_sdk::serde_json::Value { near_sdk::serde_json::to_value(TokenMetadata { title: Some(id), @@ -508,7 +511,7 @@ async fn transfer_call_success() { "receiver_id": bob.id(), "msg": "", })) - .gas(30_000_000_000_000) + .gas(THIRTY_TERAGAS) .deposit(1) .transact() .await @@ -567,7 +570,7 @@ async fn transfer_call_return_success() { "receiver_id": bob.id(), "msg": "return", })) - .gas(30_000_000_000_000) + .gas(THIRTY_TERAGAS) .deposit(1) .transact() .await @@ -636,7 +639,7 @@ async fn transfer_call_receiver_panic() { "receiver_id": bob.id(), "msg": "panic", })) - .gas(30_000_000_000_000) + .gas(THIRTY_TERAGAS) .deposit(1) .transact() .await @@ -706,7 +709,7 @@ async fn transfer_call_receiver_send_return() { "receiver_id": bob.id(), "msg": format!("transfer:{}", charlie.id()), })) - .gas(300_000_000_000_000) // xtra gas + .gas(THIRTY_TERAGAS.saturating_mul(10)) // xtra gas .deposit(1) .transact() .await diff --git a/workspaces-tests/tests/rbac.rs b/workspaces-tests/tests/rbac.rs index d6d9484..4a5ffdb 100644 --- a/workspaces-tests/tests/rbac.rs +++ b/workspaces-tests/tests/rbac.rs @@ -6,8 +6,9 @@ use near_sdk::{ serde::Deserialize, serde_json::{self, json}, }; +use near_workspaces::{Account, AccountId, Contract}; +use pretty_assertions::assert_eq; use tokio::join; -use workspaces::{Account, AccountId, Contract}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/rbac.wasm"); @@ -27,7 +28,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![]; diff --git a/workspaces-tests/tests/simple_multisig.rs b/workspaces-tests/tests/simple_multisig.rs index 16e1e18..cf75814 100644 --- a/workspaces-tests/tests/simple_multisig.rs +++ b/workspaces-tests/tests/simple_multisig.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::serde_json::json; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/simple_multisig.wasm"); @@ -13,7 +14,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize contract let contract = worker.dev_deploy(WASM).await.unwrap(); diff --git a/workspaces-tests/tests/storage_fee.rs b/workspaces-tests/tests/storage_fee.rs index ebd2dd3..fe230e4 100644 --- a/workspaces-tests/tests/storage_fee.rs +++ b/workspaces-tests/tests/storage_fee.rs @@ -1,8 +1,7 @@ #![cfg(not(windows))] use near_sdk::{json_types::U128, serde_json::json, ONE_NEAR}; - -use workspaces::{sandbox, Account, Contract, DevNetwork, Worker}; +use near_workspaces::{sandbox, Account, Contract, DevNetwork, Worker}; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/storage_fee.wasm"); @@ -73,7 +72,8 @@ async fn storage_fee() { // How much was actually charged to the account? // Note that there will be *some* overhead, e.g. collection indexing - let net_fee = balance_before - balance_after - (r.total_gas_burnt as u128 * gas_price); + let net_fee = + balance_before - balance_after - (r.total_gas_burnt.as_gas() as u128 * gas_price); assert!(net_fee >= minimum_storage_fee); assert!(net_fee - minimum_storage_fee < byte_cost * 100); // Sanity/validity check / allow up to 100 bytes worth of additional storage to be charged diff --git a/workspaces-tests/tests/upgrade.rs b/workspaces-tests/tests/upgrade.rs index 3726eb1..6392b3e 100644 --- a/workspaces-tests/tests/upgrade.rs +++ b/workspaces-tests/tests/upgrade.rs @@ -4,7 +4,8 @@ use near_sdk::{ borsh::{self, BorshSerialize}, serde::Serialize, }; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM_BORSH: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/upgrade_old_borsh.wasm"); @@ -42,7 +43,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::sandbox().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![]; diff --git a/workspaces-tests/tests/upgrade_multisig.rs b/workspaces-tests/tests/upgrade_multisig.rs index 5dbc8c2..715d8c4 100644 --- a/workspaces-tests/tests/upgrade_multisig.rs +++ b/workspaces-tests/tests/upgrade_multisig.rs @@ -1,7 +1,8 @@ #![cfg(not(windows))] use near_sdk::{json_types::Base64VecU8, serde_json::json}; -use workspaces::{Account, Contract}; +use near_workspaces::{Account, Contract}; +use pretty_assertions::assert_eq; const WASM: &[u8] = include_bytes!("../../target/wasm32-unknown-unknown/release/upgrade_old_multisig.wasm"); @@ -16,7 +17,7 @@ struct Setup { /// Setup for individual tests async fn setup(num_accounts: usize, wasm: &[u8]) -> Setup { - let worker = workspaces::testnet().await.unwrap(); + let worker = near_workspaces::sandbox().await.unwrap(); // Initialize user accounts let mut accounts = vec![];