use std::{ fmt::{Debug, Display}, marker::PhantomData, }; use near_contract_standards::fungible_token::core::ext_ft_core; #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] use near_primitives::action::FunctionCallAction; use near_sdk::{ env, json_types::U128, near, serde_json::{self, json}, AccountId, AccountIdRef, Gas, NearToken, Promise, }; use crate::{number::Decimal, panic_with_message}; /// Assets may be configuread as one of the supported asset types. /// /// The following asset contract standards are supported: /// /// - [NEP-141 Fungible Token (FT)](https://nomicon.io/Standards/Tokens/FungibleToken/Core) /// - [NEP-245 Multi-Token (MT)](https://nomicon.io/Standards/Tokens/MultiToken/Core) /// /// --- /// /// Assets can be constructed using associated functions: /// /// ``` /// let my_ft = FungibleAsset::::nep141("contract_id".parse().unwrap()); /// let my_mt = FungibleAsset::::nep245( /// "contract_id".parse().unwrap(), /// "token_id".to_string(), /// ); /// ``` #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [json, borsh])] pub struct FungibleAsset { // Necessary because there is no clean way to use PhantomData in an enum. // https://internals.rust-lang.org/t/type-parameter-not-used-on-enums/13342 #[serde(skip)] #[borsh(skip)] discriminant: PhantomData, #[serde(flatten)] kind: FungibleAssetKind, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [json, borsh])] enum FungibleAssetKind { Nep141(AccountId), Nep245 { contract_id: AccountId, token_id: String, }, } impl FungibleAsset { /// Gas for simple transfers (`ft_transfer`) pub const GAS_FT_TRANSFER: Gas = Gas::from_tgas(6); /// Gas for simple NEP-245 transfers (`mt_transfer`) pub const GAS_MT_TRANSFER: Gas = Gas::from_tgas(7); /// Gas for `transfer_call` operations (includes callback to receiver) /// NEP-141 `ft_transfer_call`: Transfer + receiver callback execution /// Needs extra gas for the receiver contract logic (e.g., market liquidation) pub const GAS_FT_TRANSFER_CALL: Gas = Gas::from_tgas(100); /// Gas for NEP-245 `mt_transfer_call` operations /// NEAR Intents `mt_transfer_call`: Transfer + receiver callback + collateral transfer back pub const GAS_MT_TRANSFER_CALL: Gas = Gas::from_tgas(150); #[allow(clippy::missing_panics_doc, clippy::unwrap_used)] pub fn transfer(&self, receiver_id: AccountId, amount: FungibleAssetAmount) -> Promise { match self.kind { FungibleAssetKind::Nep141(ref contract_id) => ext_ft_core::ext(contract_id.clone()) .with_static_gas(Self::GAS_FT_TRANSFER) .with_attached_deposit(NearToken::from_yoctonear(1)) .ft_transfer(receiver_id, u128::from(amount).into(), None), FungibleAssetKind::Nep245 { ref contract_id, ref token_id, } => Promise::new(contract_id.clone()).function_call( "mt_transfer".into(), serde_json::to_vec(&json!({ "receiver_id": receiver_id, "token_id": token_id, "amount": amount, })) .unwrap(), NearToken::from_yoctonear(1), Self::GAS_MT_TRANSFER, ), } } #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn transfer_call_method_name(&self) -> &str { match self.kind { FungibleAssetKind::Nep141(_) => "ft_transfer_call", FungibleAssetKind::Nep245 { .. } => "mt_transfer_call", } } #[allow(clippy::missing_panics_doc, clippy::unwrap_used)] pub fn transfer_call( &self, receiver_id: &AccountId, amount: FungibleAssetAmount, msg: Option<&str>, ) -> Promise { let msg = msg.unwrap_or_default().to_string(); match self.kind { FungibleAssetKind::Nep141(ref contract_id) => ext_ft_core::ext(contract_id.clone()) .with_static_gas(Self::GAS_FT_TRANSFER) .with_attached_deposit(NearToken::from_yoctonear(1)) .ft_transfer_call(receiver_id.clone(), u128::from(amount).into(), None, msg), FungibleAssetKind::Nep245 { ref contract_id, ref token_id, } => Promise::new(contract_id.clone()).function_call( "mt_transfer_call".into(), serde_json::to_vec(&json!({ "receiver_id": receiver_id, "token_id": token_id, "amount": amount, "msg": msg, })) .unwrap(), NearToken::from_yoctonear(1), Self::GAS_MT_TRANSFER, ), } } /// Creates a simple `ft_transfer` action (no callback). #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn transfer_action( &self, receiver_id: &AccountId, amount: FungibleAssetAmount, ) -> FunctionCallAction { let (method_name, args, gas) = match self.kind { FungibleAssetKind::Nep141(_) => ( "ft_transfer", json!({ "receiver_id": receiver_id, "amount": u128::from(amount).to_string(), }), Self::GAS_FT_TRANSFER, ), FungibleAssetKind::Nep245 { ref token_id, .. } => ( "mt_transfer", json!({ "receiver_id": receiver_id, "token_id": token_id, "amount": u128::from(amount).to_string(), }), Self::GAS_MT_TRANSFER, ), }; FunctionCallAction { method_name: method_name.to_string(), #[allow(clippy::unwrap_used)] args: serde_json::to_vec(&args).unwrap(), gas: gas.as_gas(), deposit: 1, // 1 yoctoNEAR for security } } #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn transfer_call_action( &self, receiver_id: &AccountId, amount: FungibleAssetAmount, msg: &str, ) -> FunctionCallAction { let (args, gas) = match self.kind { FungibleAssetKind::Nep141(_) => ( json!({ "receiver_id": receiver_id, "amount": u128::from(amount).to_string(), "msg": msg, }), Self::GAS_FT_TRANSFER_CALL, ), FungibleAssetKind::Nep245 { ref token_id, .. } => ( json!({ "receiver_id": receiver_id, "token_id": token_id, "amount": u128::from(amount).to_string(), "msg": msg, }), Self::GAS_MT_TRANSFER_CALL, ), }; FunctionCallAction { method_name: self.transfer_call_method_name().to_string(), #[allow( clippy::unwrap_used, reason = "All of the types have infallible serialization" )] args: serde_json::to_vec(&args).unwrap(), gas: gas.as_gas(), deposit: NearToken::from_yoctonear(1).as_yoctonear(), } } #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn balance_of_action(&self, account_id: &AccountId) -> FunctionCallAction { let (method_name, args) = match self.kind { FungibleAssetKind::Nep141(_) => ( "ft_balance_of", json!({ "account_id": account_id, }), ), FungibleAssetKind::Nep245 { ref token_id, .. } => ( "mt_balance_of", json!({ "account_id": account_id, "token_id": token_id, }), ), }; FunctionCallAction { method_name: method_name.to_string(), #[allow( clippy::unwrap_used, reason = "All of the types have infallible serialization" )] args: serde_json::to_vec(&args).unwrap(), gas: Gas::from_tgas(3).as_gas(), deposit: 0, } } pub fn nep141(contract_id: AccountId) -> Self { Self { discriminant: PhantomData, kind: FungibleAssetKind::Nep141(contract_id), } } pub fn nep245(contract_id: AccountId, token_id: String) -> Self { Self { discriminant: PhantomData, kind: FungibleAssetKind::Nep245 { contract_id, token_id, }, } } pub fn is_nep141(&self, account_id: &AccountId) -> bool { matches!(self.kind, FungibleAssetKind::Nep141(ref contract_id) if contract_id == account_id) } pub fn into_nep141(self) -> Option { match self.kind { FungibleAssetKind::Nep141(contract_id) => Some(contract_id), FungibleAssetKind::Nep245 { .. } => None, } } pub fn is_nep245(&self, account_id: &AccountId, token_id: &str) -> bool { let t = token_id; matches!(self.kind, FungibleAssetKind::Nep245 { ref contract_id, ref token_id } if contract_id == account_id && token_id == t) } pub fn into_nep245(self) -> Option<(AccountId, String)> { match self.kind { FungibleAssetKind::Nep245 { contract_id, token_id, } => Some((contract_id, token_id)), FungibleAssetKind::Nep141(_) => None, } } #[allow(clippy::missing_panics_doc, clippy::unwrap_used)] pub fn current_account_balance(&self) -> Promise { let current_account_id = env::current_account_id(); match self.kind { FungibleAssetKind::Nep141(ref account_id) => { ext_ft_core::ext(account_id.clone()).ft_balance_of(current_account_id.clone()) } FungibleAssetKind::Nep245 { ref contract_id, ref token_id, } => Promise::new(contract_id.clone()).function_call( "mt_balance_of".into(), serde_json::to_vec(&json!({ "account_id": current_account_id, "token_id": token_id, })) .unwrap(), NearToken::from_millinear(0), Gas::from_tgas(4), ), } } pub fn coerce(self) -> FungibleAsset { FungibleAsset { discriminant: PhantomData, kind: self.kind, } } pub fn contract_id(&self) -> &AccountIdRef { match self.kind { FungibleAssetKind::Nep141(ref account_id) => account_id, FungibleAssetKind::Nep245 { ref contract_id, .. } => contract_id, } } } impl Display for FungibleAsset { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.kind { FungibleAssetKind::Nep141(ref contract_id) => { write!(f, "nep141:{contract_id}") } FungibleAssetKind::Nep245 { ref contract_id, ref token_id, } => write!(f, "nep245:{contract_id}:{token_id}"), } } } impl std::str::FromStr for FungibleAsset { type Err = FungibleAssetParseError; fn from_str(s: &str) -> Result { // Use splitn to limit splits - important for NEP-245 where token_id can contain colons // e.g., "nep245:intents.near:nep141:btc.omft.near" should split into 3 parts max let parts: Vec<&str> = s.splitn(3, ':').collect(); match parts.as_slice() { ["nep141", contract_id] => { let account_id = contract_id .parse::() .map_err(|e| FungibleAssetParseError::InvalidAccountId(e.to_string()))?; Ok(FungibleAsset::nep141(account_id)) } ["nep245", contract_id, token_id] => { let account_id = contract_id .parse::() .map_err(|e| FungibleAssetParseError::InvalidAccountId(e.to_string()))?; if token_id.is_empty() { return Err(FungibleAssetParseError::EmptyTokenId); } Ok(FungibleAsset::nep245(account_id, (*token_id).to_string())) } _ => Err(FungibleAssetParseError::InvalidFormat), } } } #[derive(Debug, thiserror::Error)] pub enum FungibleAssetParseError { #[error( "Invalid format. Expected 'nep141:' or 'nep245::'" )] InvalidFormat, #[error("Invalid account ID: {0}")] InvalidAccountId(String), #[error("Token ID cannot be empty for NEP-245 assets")] EmptyTokenId, } mod sealed { pub trait Sealed {} } pub trait AssetClass: sealed::Sealed + Copy + Clone + Send + Sync + std::fmt::Debug {} #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [borsh, json])] pub struct CollateralAsset; impl sealed::Sealed for CollateralAsset {} impl AssetClass for CollateralAsset {} #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[near(serializers = [borsh, json])] pub struct BorrowAsset; impl sealed::Sealed for BorrowAsset {} impl AssetClass for BorrowAsset {} #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[near(serializers = [borsh, json])] #[serde(from = "U128", into = "U128")] pub struct FungibleAssetAmount { amount: U128, #[borsh(skip)] discriminant: PhantomData, } impl Debug for FungibleAssetAmount { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}<{}>", self.amount.0, std::any::type_name::()) } } impl Default for FungibleAssetAmount { fn default() -> Self { Self::zero() } } impl From for FungibleAssetAmount { fn from(amount: U128) -> Self { Self { amount, discriminant: PhantomData, } } } impl From> for U128 { fn from(value: FungibleAssetAmount) -> Self { value.amount } } impl From for FungibleAssetAmount { fn from(value: u128) -> Self { Self::new(value) } } impl FungibleAssetAmount { pub fn new(amount: u128) -> Self { Self { amount: U128(amount), discriminant: PhantomData, } } pub const fn zero() -> Self { Self { amount: U128(0), discriminant: PhantomData, } } pub fn is_zero(&self) -> bool { self.amount.0 == 0 } #[must_use] pub fn unwrap_add(self, other: impl Into, message: &str) -> Self { Self { amount: self .amount .0 .checked_add(other.into().amount.0) .unwrap_or_else(|| panic_with_message(&format!("Arithmetic overflow: {message}"))) .into(), ..self } } #[must_use] pub fn saturating_add(self, other: impl Into) -> Self { Self { amount: self.amount.0.saturating_add(other.into().amount.0).into(), ..self } } #[must_use] pub fn checked_add(self, other: impl Into) -> Option { Some(Self { amount: self.amount.0.checked_add(other.into().amount.0)?.into(), ..self }) } #[must_use] pub fn unwrap_sub(self, other: impl Into, message: &str) -> Self { Self { amount: self .amount .0 .checked_sub(other.into().amount.0) .unwrap_or_else(|| panic_with_message(&format!("Arithmetic underflow: {message}"))) .into(), ..self } } #[must_use] pub fn saturating_sub(self, other: impl Into) -> Self { Self { amount: self.amount.0.saturating_sub(other.into().amount.0).into(), ..self } } #[must_use] pub fn checked_sub(self, other: impl Into) -> Option { Some(Self { amount: self.amount.0.checked_sub(other.into().amount.0)?.into(), ..self }) } } impl From> for Decimal { fn from(value: FungibleAssetAmount) -> Self { value.amount.0.into() } } impl From> for u128 { fn from(value: FungibleAssetAmount) -> Self { value.amount.0 } } impl std::fmt::Display for FungibleAssetAmount { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.amount.0) } } impl> std::ops::Add for FungibleAssetAmount { type Output = Self; fn add(self, rhs: R) -> Self::Output { Self { amount: U128(self.amount.0 + rhs.into().amount.0), ..self } } } impl> std::ops::AddAssign for FungibleAssetAmount { fn add_assign(&mut self, rhs: R) { self.amount.0 += rhs.into().amount.0; } } impl> std::ops::Sub for FungibleAssetAmount { type Output = Self; fn sub(self, rhs: R) -> Self::Output { Self { amount: U128(self.amount.0 - rhs.into().amount.0), ..self } } } impl> std::ops::SubAssign for FungibleAssetAmount { fn sub_assign(&mut self, rhs: R) { self.amount.0 -= rhs.into().amount.0; } } pub type BorrowAssetAmount = FungibleAssetAmount; pub type CollateralAssetAmount = FungibleAssetAmount; #[cfg(test)] mod tests { use super::*; use near_sdk::serde_json; #[test] fn serialization() { let amount = BorrowAssetAmount::new(100); let serialized = serde_json::to_string(&amount).unwrap(); assert_eq!(serialized, "\"100\""); let deserialized: BorrowAssetAmount = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized, amount); } #[test] fn checked_add() { let v = BorrowAssetAmount::new(0).checked_add(BorrowAssetAmount::new(0)); assert_eq!(v, Some(BorrowAssetAmount::new(0))); let v = BorrowAssetAmount::new(0).checked_add(BorrowAssetAmount::new(100)); assert_eq!(v, Some(BorrowAssetAmount::new(100))); let v = BorrowAssetAmount::new(100).checked_add(BorrowAssetAmount::new(0)); assert_eq!(v, Some(BorrowAssetAmount::new(100))); let v = BorrowAssetAmount::new(100).checked_add(BorrowAssetAmount::new(100)); assert_eq!(v, Some(BorrowAssetAmount::new(200))); let v = BorrowAssetAmount::new(1).checked_add(BorrowAssetAmount::new(u128::MAX)); assert_eq!(v, None); } #[test] fn checked_sub() { let v = BorrowAssetAmount::new(0).checked_sub(BorrowAssetAmount::new(0)); assert_eq!(v, Some(BorrowAssetAmount::new(0))); let v = BorrowAssetAmount::new(0).checked_sub(BorrowAssetAmount::new(100)); assert_eq!(v, None); let v = BorrowAssetAmount::new(100).checked_sub(BorrowAssetAmount::new(0)); assert_eq!(v, Some(BorrowAssetAmount::new(100))); let v = BorrowAssetAmount::new(100).checked_sub(BorrowAssetAmount::new(100)); assert_eq!(v, Some(BorrowAssetAmount::new(0))); let v = BorrowAssetAmount::new(1).checked_sub(BorrowAssetAmount::new(u128::MAX - 33)); assert_eq!(v, None); } #[test] fn saturating_add() { let v = BorrowAssetAmount::new(0).saturating_add(BorrowAssetAmount::new(0)); assert_eq!(v, BorrowAssetAmount::new(0)); let v = BorrowAssetAmount::new(0).saturating_add(BorrowAssetAmount::new(100)); assert_eq!(v, BorrowAssetAmount::new(100)); let v = BorrowAssetAmount::new(100).saturating_add(BorrowAssetAmount::new(0)); assert_eq!(v, BorrowAssetAmount::new(100)); let v = BorrowAssetAmount::new(100).saturating_add(BorrowAssetAmount::new(100)); assert_eq!(v, BorrowAssetAmount::new(200)); let v = BorrowAssetAmount::new(100).saturating_add(BorrowAssetAmount::new(u128::MAX - 33)); assert_eq!(v, BorrowAssetAmount::new(u128::MAX)); } #[test] fn saturating_sub() { let v = BorrowAssetAmount::new(0).saturating_sub(BorrowAssetAmount::new(0)); assert_eq!(v, BorrowAssetAmount::new(0)); let v = BorrowAssetAmount::new(0).saturating_sub(BorrowAssetAmount::new(100)); assert_eq!(v, BorrowAssetAmount::new(0)); let v = BorrowAssetAmount::new(100).saturating_sub(BorrowAssetAmount::new(0)); assert_eq!(v, BorrowAssetAmount::new(100)); let v = BorrowAssetAmount::new(100).saturating_sub(BorrowAssetAmount::new(100)); assert_eq!(v, BorrowAssetAmount::new(0)); let v = BorrowAssetAmount::new(100).saturating_sub(BorrowAssetAmount::new(u128::MAX - 33)); assert_eq!(v, BorrowAssetAmount::new(0)); } #[test] #[should_panic = "overflow"] fn overflow_unwrap_add() { let _ = BorrowAssetAmount::new(100).unwrap_add(BorrowAssetAmount::new(u128::MAX), "overflow"); } #[test] #[should_panic = "overflow"] fn overflow_unwrap_sub() { let _ = BorrowAssetAmount::new(100).unwrap_sub(BorrowAssetAmount::new(u128::MAX), "overflow"); } #[test] #[should_panic = "attempt to add with overflow"] fn overflow_add() { let _ = BorrowAssetAmount::new(u128::MAX) + BorrowAssetAmount::new(1); } #[test] #[should_panic = "attempt to add with overflow"] fn overflow_add_assign() { let mut v = BorrowAssetAmount::new(u128::MAX); v += BorrowAssetAmount::new(1); } #[test] #[should_panic = "attempt to subtract with overflow"] fn overflow_sub() { let _ = BorrowAssetAmount::new(0) - BorrowAssetAmount::new(1); } #[test] #[should_panic = "attempt to subtract with overflow"] fn overflow_sub_assign() { let mut v = BorrowAssetAmount::new(1); v -= BorrowAssetAmount::new(u128::MAX); } } #[derive(Clone, Debug)] #[near(serializers = [json])] pub enum ReturnStyle { Nep141FtTransferCall, Nep245MtTransferCall, } impl ReturnStyle { pub fn serialize(&self, amount: FungibleAssetAmount) -> serde_json::Value { match self { Self::Nep141FtTransferCall => serde_json::json!(amount), Self::Nep245MtTransferCall => serde_json::json!([amount]), } } }