diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a8e5cd8..3553fd894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added +- **BREAKING** Added [NEP-199 - Non-Fungible Token Royalties and Payouts](https://github.com/willemneal/NEPs/blob/18828873648eff1a2e8464db234aefd70918b3e0/neps/nep-0199.md) implementation. This +will need a state migration in case of upgrade for any contracts using NFT of previous versions due to an extra field. + ## [5.0.0-alpha.1](https://github.com/near/near-sdk-rs/compare/4.1.1...near-sdk-v5.0.0-alpha.1) - 2023-11-18 ### Added diff --git a/examples/non-fungible-token/nft/src/lib.rs b/examples/non-fungible-token/nft/src/lib.rs index da8192545..abd42f57e 100644 --- a/examples/non-fungible-token/nft/src/lib.rs +++ b/examples/non-fungible-token/nft/src/lib.rs @@ -23,7 +23,8 @@ use near_contract_standards::non_fungible_token::enumeration::NonFungibleTokenEn use near_contract_standards::non_fungible_token::metadata::{ NFTContractMetadata, NonFungibleTokenMetadataProvider, TokenMetadata, NFT_METADATA_SPEC, }; -use near_contract_standards::non_fungible_token::NonFungibleToken; +use near_contract_standards::non_fungible_token::payout::Payout; +use near_contract_standards::non_fungible_token::{NonFungibleToken, NonFungibleTokenPayout}; use near_contract_standards::non_fungible_token::{Token, TokenId}; use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::collections::LazyOption; @@ -51,6 +52,7 @@ enum StorageKey { TokenMetadata, Enumeration, Approval, + Royalties, } #[near_bindgen] @@ -84,6 +86,7 @@ impl Contract { Some(StorageKey::TokenMetadata), Some(StorageKey::Enumeration), Some(StorageKey::Approval), + Some(StorageKey::Royalties), ), metadata: LazyOption::new(StorageKey::Metadata, Some(&metadata)), } @@ -214,6 +217,34 @@ impl NonFungibleTokenEnumeration for Contract { } } +#[near_bindgen] +impl NonFungibleTokenPayout for Contract { + #[allow(unused_variables)] + fn nft_payout(&self, token_id: String, balance: U128, max_len_payout: Option) -> Payout { + self.tokens.nft_payout(token_id, balance, max_len_payout) + } + + #[payable] + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: String, + approval_id: Option, + memo: Option, + balance: U128, + max_len_payout: Option, + ) -> Payout { + self.tokens.nft_transfer_payout( + receiver_id, + token_id, + approval_id, + memo, + balance, + max_len_payout, + ) + } +} + #[near_bindgen] impl NonFungibleTokenMetadataProvider for Contract { fn nft_metadata(&self) -> NFTContractMetadata { @@ -331,6 +362,84 @@ mod tests { } } + #[test] + fn test_transfer_payout() { + let mut context = get_context(accounts(0)); + testing_env!(context.build()); + let mut contract = Contract::new_default_meta(accounts(0).into()); + + testing_env!(context + .storage_usage(env::storage_usage()) + .attached_deposit(MINT_STORAGE_COST) + .predecessor_account_id(accounts(0)) + .build()); + let token_id = "0".to_string(); + contract.nft_mint(token_id.clone(), accounts(0), sample_token_metadata()); + + testing_env!(context + .storage_usage(env::storage_usage()) + .attached_deposit(NearToken::from_yoctonear(1)) + .predecessor_account_id(accounts(0)) + .build()); + + let payout = contract.nft_transfer_payout( + accounts(1), + token_id.clone(), + None, + None, + U128::from(1), + None, + ); + + let mut expected_payout = HashMap::new(); + expected_payout.insert(accounts(0), U128::from(1)); + + assert_eq!(payout.payout, expected_payout); + + testing_env!(context + .storage_usage(env::storage_usage()) + .account_balance(env::account_balance()) + .is_view(true) + .attached_deposit(NearToken::from_near(0)) + .build()); + + if let Some(token) = contract.nft_token(token_id.clone()) { + assert_eq!(token.token_id, token_id); + assert_eq!(token.owner_id, accounts(1)); + assert_eq!(token.metadata.unwrap(), sample_token_metadata()); + assert_eq!(token.approved_account_ids.unwrap(), HashMap::new()); + } else { + panic!("token not correctly created, or not found by nft_token"); + } + } + + #[test] + fn test_payout() { + let mut context = get_context(accounts(0)); + testing_env!(context.build()); + let mut contract = Contract::new_default_meta(accounts(0).into()); + + testing_env!(context + .storage_usage(env::storage_usage()) + .attached_deposit(MINT_STORAGE_COST) + .predecessor_account_id(accounts(0)) + .build()); + let token_id = "0".to_string(); + contract.nft_mint(token_id.clone(), accounts(0), sample_token_metadata()); + + testing_env!(context + .storage_usage(env::storage_usage()) + .predecessor_account_id(accounts(0)) + .build()); + + let payout = contract.nft_payout(token_id, U128::from(1), None); + + let mut expected_payout = HashMap::new(); + expected_payout.insert(accounts(0), U128::from(1)); + + assert_eq!(payout.payout, expected_payout); + } + #[test] fn test_approve() { let mut context = get_context(accounts(0)); diff --git a/examples/non-fungible-token/tests/workspaces/main.rs b/examples/non-fungible-token/tests/workspaces/main.rs index b0d04236c..9a09d5bc0 100644 --- a/examples/non-fungible-token/tests/workspaces/main.rs +++ b/examples/non-fungible-token/tests/workspaces/main.rs @@ -1,4 +1,5 @@ mod test_approval; mod test_core; mod test_enumeration; +mod test_payout; mod utils; diff --git a/examples/non-fungible-token/tests/workspaces/test_core.rs b/examples/non-fungible-token/tests/workspaces/test_core.rs index 067fdc2f6..df09aeb3c 100644 --- a/examples/non-fungible-token/tests/workspaces/test_core.rs +++ b/examples/non-fungible-token/tests/workspaces/test_core.rs @@ -191,6 +191,7 @@ async fn simulate_transfer_call_receiver_panics_and_nft_resolve_transfer_produce .deposit(ONE_YOCTO) .transact() .await?; + assert!(res.is_failure()); // Prints no logs diff --git a/examples/non-fungible-token/tests/workspaces/test_enumeration.rs b/examples/non-fungible-token/tests/workspaces/test_enumeration.rs index 651ec4cfd..7156a3b06 100644 --- a/examples/non-fungible-token/tests/workspaces/test_enumeration.rs +++ b/examples/non-fungible-token/tests/workspaces/test_enumeration.rs @@ -97,12 +97,8 @@ async fn simulate_enum_nft_supply_for_owner() -> anyhow::Result<()> { let (nft_contract, alice, _, _) = init(&worker).await?; // Get number from account with no NFTs - let owner_num_tokens: U128 = nft_contract - .call("nft_supply_for_owner") - .args_json((alice.id(),)) - .view() - .await? - .json()?; + let owner_num_tokens: U128 = + nft_contract.call("nft_supply_for_owner").args_json((alice.id(),)).view().await?.json()?; assert_eq!(owner_num_tokens, U128::from(0)); let owner_num_tokens: U128 = nft_contract diff --git a/examples/non-fungible-token/tests/workspaces/test_payout.rs b/examples/non-fungible-token/tests/workspaces/test_payout.rs new file mode 100644 index 000000000..8bd1442a9 --- /dev/null +++ b/examples/non-fungible-token/tests/workspaces/test_payout.rs @@ -0,0 +1,61 @@ +use crate::utils::{init, TOKEN_ID}; +use near_contract_standards::non_fungible_token::Token; +use near_sdk::json_types::U128; +use near_sdk::NearToken; + +const ONE_YOCTO: NearToken = NearToken::from_yoctonear(1); + +#[tokio::test] +async fn simulate_payout() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox().await?; + let (nft_contract, alice, _, _) = init(&worker).await?; + + let res = nft_contract + .call("nft_payout") + .args_json((TOKEN_ID, U128::from(1), Option::::None)) + .max_gas() + .transact() + .await?; + assert!(res.is_success()); + + // A single NFT transfer event should have been logged: + assert_eq!(res.logs().len(), 0); + + Ok(()) +} + +#[tokio::test] +async fn nft_transfer_payout() -> anyhow::Result<()> { + let worker = near_workspaces::sandbox().await?; + let (nft_contract, alice, _, _) = init(&worker).await?; + + let token = + nft_contract.call("nft_token").args_json((TOKEN_ID,)).view().await?.json::()?; + assert_eq!(token.owner_id.to_string(), nft_contract.id().to_string()); + + let res = nft_contract + .call("nft_transfer_payout") + .args_json(( + alice.id(), + TOKEN_ID, + Option::::None, + Some("simple transfer".to_string()), + U128::from(1), + Option::::None, + )) + .max_gas() + .deposit(ONE_YOCTO) + .transact() + .await?; + + assert!(res.is_success()); + + // A single NFT transfer event should have been logged: + assert_eq!(res.logs().len(), 1); + + let token = + nft_contract.call("nft_token").args_json((TOKEN_ID,)).view().await?.json::()?; + assert_eq!(token.owner_id.to_string(), alice.id().to_string()); + + Ok(()) +} diff --git a/near-contract-standards/src/non_fungible_token/core/core_impl.rs b/near-contract-standards/src/non_fungible_token/core/core_impl.rs index 2bd71f9f0..2e5e574fc 100644 --- a/near-contract-standards/src/non_fungible_token/core/core_impl.rs +++ b/near-contract-standards/src/non_fungible_token/core/core_impl.rs @@ -4,6 +4,7 @@ use crate::non_fungible_token::core::resolver::ext_nft_resolver; use crate::non_fungible_token::core::NonFungibleTokenCore; use crate::non_fungible_token::events::{NftMint, NftTransfer}; use crate::non_fungible_token::metadata::TokenMetadata; +use crate::non_fungible_token::payout::Royalties; use crate::non_fungible_token::token::{Token, TokenId}; use crate::non_fungible_token::utils::{refund_approved_account_ids, refund_deposit_to_account}; use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; @@ -49,6 +50,7 @@ pub struct NonFungibleToken { // required by approval extension pub approvals_by_id: Option>>, pub next_approval_id_by_id: Option>, + pub royalties: Option, } #[derive(BorshStorageKey, BorshSerialize)] @@ -58,18 +60,20 @@ pub enum StorageKey { } impl NonFungibleToken { - pub fn new( + pub fn new( owner_by_id_prefix: Q, owner_id: AccountId, token_metadata_prefix: Option, enumeration_prefix: Option, approval_prefix: Option, + royalties_prefix: Option, ) -> Self where Q: IntoStorageKey, R: IntoStorageKey, S: IntoStorageKey, T: IntoStorageKey, + Y: IntoStorageKey, { let (approvals_by_id, next_approval_id_by_id) = if let Some(prefix) = approval_prefix { let prefix: Vec = prefix.into_storage_key(); @@ -89,6 +93,7 @@ impl NonFungibleToken { tokens_per_owner: enumeration_prefix.map(LookupMap::new), approvals_by_id, next_approval_id_by_id, + royalties: royalties_prefix.map(|prefix| Royalties::new(prefix)), }; this.measure_min_token_storage_cost(); this diff --git a/near-contract-standards/src/non_fungible_token/mod.rs b/near-contract-standards/src/non_fungible_token/mod.rs index d8ee10547..cc1014d92 100644 --- a/near-contract-standards/src/non_fungible_token/mod.rs +++ b/near-contract-standards/src/non_fungible_token/mod.rs @@ -12,6 +12,9 @@ mod macros; /// Metadata traits and implementation according to the [NFT enumeration standard](https://nomicon.io/Standards/NonFungibleToken/Metadata.html). /// This covers both the contract metadata and the individual token metadata. pub mod metadata; + +pub mod payout; + /// The Token struct for the non-fungible token. mod token; pub use self::token::{Token, TokenId}; @@ -24,5 +27,6 @@ pub use self::approval::NonFungibleTokenApproval; pub use self::core::NonFungibleToken; pub use self::core::NonFungibleTokenResolver; pub use self::enumeration::NonFungibleTokenEnumeration; +pub use self::payout::NonFungibleTokenPayout; pub mod events; diff --git a/near-contract-standards/src/non_fungible_token/payout/mod.rs b/near-contract-standards/src/non_fungible_token/payout/mod.rs new file mode 100644 index 000000000..3b5f7a1b2 --- /dev/null +++ b/near-contract-standards/src/non_fungible_token/payout/mod.rs @@ -0,0 +1,112 @@ +mod payout_impl; + +use crate::non_fungible_token::TokenId; +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; +use near_sdk::collections::TreeMap; +use near_sdk::json_types::U128; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::AccountId; +use std::collections::HashMap; + +type BasisPoint = u16; + +/// This struct represents NFT royalty payout for each receiver. +#[derive(Default, BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +#[serde(crate = "near_sdk::serde")] +#[borsh(crate = "near_sdk::borsh")] +pub struct Payout { + pub payout: HashMap, +} + +/// This struct represents percentage of total royalty per receiver as well as the total percentage +/// of distributed royalties based on incoming payment. +#[derive(BorshDeserialize, BorshSerialize, Debug)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Royalties { + /// A mapping of accounts to the percentage of total royalty to be distributed per token. + pub accounts: TreeMap>, +} + +/// An interface allowing non-fungible token contracts to request that financial contracts pay-out +/// multiple receivers, enabling flexible royalty implementations. +/// +/// [Royalties and Payouts standard]: +/// +/// # Examples +/// +/// ``` +/// use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; +/// use near_sdk::{PanicOnDefault, AccountId, PromiseOrValue, near_bindgen, assert_one_yocto}; +/// use near_contract_standards::non_fungible_token::{core::NonFungibleTokenCore, NonFungibleToken, NonFungibleTokenPayout, payout::Payout, TokenId, Token}; +/// use near_sdk::json_types::U128; +/// +/// #[near_bindgen] +/// #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +/// #[borsh(crate = "near_sdk::borsh")] +/// pub struct Contract { +/// tokens: NonFungibleToken, +///} +/// #[near_bindgen] +/// impl NonFungibleTokenCore for Contract { +/// #[payable] +/// fn nft_transfer(&mut self, receiver_id: AccountId, token_id: TokenId, approval_id: Option, memo: Option) { +/// self.tokens.nft_transfer(receiver_id, token_id, approval_id, memo); +/// } +/// +/// #[payable] +/// fn nft_transfer_call(&mut self, receiver_id: AccountId, token_id: TokenId, approval_id: Option, memo: Option, msg: String) -> PromiseOrValue { +/// self.tokens.nft_transfer_call(receiver_id, token_id, approval_id, memo, msg) +/// } +/// +/// fn nft_token(&self, token_id: TokenId) -> Option { +/// self.tokens.nft_token(token_id) +/// } +///} +/// #[near_bindgen] +/// impl NonFungibleTokenPayout for Contract { +/// #[allow(unused_variables)] +/// fn nft_payout( +/// &self, +/// token_id: String, +/// balance: U128, +/// max_len_payout: Option, +/// ) -> Payout { +/// self.tokens.nft_payout(token_id, balance, max_len_payout) +/// } +/// #[payable] +/// fn nft_transfer_payout( +/// &mut self, +/// receiver_id: AccountId, +/// token_id: String, +/// approval_id: Option, +/// memo: Option, +/// balance: U128, +/// max_len_payout: Option, +/// ) -> Payout { +/// self.tokens.nft_transfer_payout( +/// receiver_id, +/// token_id, +/// approval_id, +/// memo, +/// balance, +/// max_len_payout, +/// ) +/// } +/// } +/// ``` +/// +pub trait NonFungibleTokenPayout { + fn nft_payout(&self, token_id: String, balance: U128, max_len_payout: Option) -> Payout; + /// Given a `token_id` and NEAR-denominated balance, transfer the token + /// and return the `Payout` struct for the given token. Panic if the + /// length of the payout exceeds `max_len_payout.` + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: String, + approval_id: Option, + memo: Option, + balance: U128, + max_len_payout: Option, + ) -> Payout; +} diff --git a/near-contract-standards/src/non_fungible_token/payout/payout_impl.rs b/near-contract-standards/src/non_fungible_token/payout/payout_impl.rs new file mode 100644 index 000000000..82d664b29 --- /dev/null +++ b/near-contract-standards/src/non_fungible_token/payout/payout_impl.rs @@ -0,0 +1,323 @@ +use super::NonFungibleTokenPayout; +use crate::fungible_token::Balance; +use crate::non_fungible_token::core::NonFungibleTokenCore; +use crate::non_fungible_token::payout::*; +use crate::non_fungible_token::NonFungibleToken; +use near_sdk::{assert_one_yocto, env}; +use near_sdk::{require, AccountId, IntoStorageKey}; + +impl Royalties { + pub fn new(key_prefix: S) -> Self + where + S: IntoStorageKey, + { + let temp_accounts: TreeMap> = + TreeMap::new(key_prefix); + let this = Self { accounts: temp_accounts }; + this.validate(); + this + } + + pub(crate) fn validate(&self) { + require!( + self.accounts.len() <= 10, + "can only have a maximum of 10 accounts spliting royalties" + ); + + let mut total_per_token = HashMap::new(); + + self.accounts.iter().for_each(|(_, percent_per_token)| { + percent_per_token.iter().for_each(|(token_id, percent)| { + require!(*percent <= 100, "each royalty should be at most 100"); + *total_per_token.entry(token_id.to_owned()).or_default() += percent; + }); + }); + + total_per_token.values().for_each(|total: &u16| { + require!( + *total <= 100, + "total percent of each royalty split must be at most 100 per token" + ) + }); + } + + /// Create a payout. + /// + /// # Arguments + /// * `token_id` - token_id for payout. + /// * `balance` - total balance dedicated to the payout. + /// * `owner_id` - nft owner account id. + /// + /// NOTE: The owner gets whatever is left after distributing the rest of the payout plus the + /// percentage specified explicitly, if any. + pub fn create_payout( + &self, + token_id: TokenId, + balance: Balance, + owner_id: &AccountId, + ) -> Payout { + let mut payout = Payout { + payout: self + .accounts + .iter() + .map(|(account, percent_per_token)| { + ( + account.clone(), + U128::from(apply_percent( + *percent_per_token.get(&token_id).unwrap_or(&0), + balance, + )), + ) + }) + .filter(|(_, payout)| payout.0 > 0) + .collect(), + }; + let rest = balance - payout.payout.values().fold(0, |acc, &sum| acc + sum.0); + let owner_payout: u128 = payout.payout.get(owner_id).map_or(0, |x| x.0) + rest; + payout.payout.insert(owner_id.clone(), owner_payout.into()); + payout + } +} + +fn apply_percent(percent: BasisPoint, int: u128) -> u128 { + int.checked_mul(percent as u128).unwrap_or_else(|| env::panic_str("royalty overflow")) / 100u128 +} + +impl NonFungibleTokenPayout for NonFungibleToken { + fn nft_payout(&self, token_id: String, balance: U128, max_len_payout: Option) -> Payout { + let owner_id = self.owner_by_id.get(&token_id).expect("No such token_id"); + let payout = self + .royalties + .as_ref() + .map_or(Payout::default(), |r| r.create_payout(token_id, balance.0, &owner_id)); + + if let Some(max_len_payout) = max_len_payout { + require!( + payout.payout.len() <= max_len_payout as usize, + "payout number can't exceed `max_len_payout`" + ); + } + + payout + } + + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: String, + approval_id: Option, + memo: Option, + balance: U128, + max_len_payout: Option, + ) -> Payout { + assert_one_yocto(); + let payout = self.nft_payout(token_id.clone(), balance, max_len_payout); + self.nft_transfer(receiver_id, token_id, approval_id, memo); + payout + } +} +#[cfg(test)] +mod tests { + use crate::fungible_token::Balance; + use crate::non_fungible_token::payout::payout_impl::apply_percent; + use crate::non_fungible_token::payout::Royalties; + use near_sdk::collections::TreeMap; + use near_sdk::json_types::U128; + use near_sdk::AccountIdRef; + use std::collections::HashMap; + use std::mem; + + const KEY_PREFIX: &[u8] = "test_prefix".as_bytes(); + + #[test] + fn validate_happy_path() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + // Works with up to 100% and at most 10 accounts. + for idx in 0..10 { + map.insert( + &AccountIdRef::new_or_panic(&format!("bob_{}", idx)).into(), + &HashMap::from([(token_id.clone(), 10)]), + ); + } + + let mut royalties = Royalties::new(KEY_PREFIX); + + mem::swap(&mut royalties.accounts, &mut map); + royalties.validate(); + + // Make sure that max royalties works. + let owner_id = AccountIdRef::new_or_panic("alice").into(); + let payout = royalties.create_payout(token_id, 1000, &owner_id); + for (key, value) in payout.payout.iter() { + map.contains_key(key); + if *key == owner_id { + assert_eq!(*value, U128::from(0)); + } else { + assert_eq!(*value, U128::from(apply_percent(10, 1000))); + } + } + } + + #[test] + fn validate_owner_rest_path() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + for idx in 0..10 { + map.insert( + &AccountIdRef::new_or_panic(&format!("bob_{}", idx)).into(), + &HashMap::from([(token_id.clone(), 8)]), + ); + } + + let mut royalties = Royalties::new(KEY_PREFIX); + + mem::swap(&mut royalties.accounts, &mut map); + royalties.validate(); + + // Make sure we don't overflow and don't end up with mismatched results due to using int as + // opposed to float. + let balance = Balance::MAX / 10_000 * 100; + let owner_id = AccountIdRef::new_or_panic("alice"); + let payout = royalties.create_payout(token_id, balance, &owner_id.into()); + for (key, value) in payout.payout.iter() { + map.contains_key(key); + if *key == owner_id { + assert_eq!(*value, U128::from(apply_percent(20, balance))); + } else { + assert_eq!(*value, U128::from(apply_percent(8, balance))); + } + } + } + + #[test] + fn validate_owner_rest_and_royalty_path() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + for idx in 0..9 { + map.insert( + &AccountIdRef::new_or_panic(&format!("bob_{}", idx)).into(), + &HashMap::from([(token_id.clone(), 8)]), + ); + } + + map.insert( + &AccountIdRef::new_or_panic("alice").into(), + &HashMap::from([(token_id.clone(), 8)]), + ); + + let mut royalties = Royalties::new(KEY_PREFIX); + + mem::swap(&mut royalties.accounts, &mut map); + royalties.validate(); + + // Make sure we don't overflow and don't end up with mismatched results due to using int as + // opposed to float. + let balance = Balance::MAX / 10_000 * 100; + let owner_id = AccountIdRef::new_or_panic("alice"); + let payout = royalties.create_payout(token_id, balance, &owner_id.into()); + for (key, value) in payout.payout.iter() { + map.contains_key(key); + if *key == owner_id { + assert_eq!(*value, U128::from(apply_percent(28, balance))); + } else { + assert_eq!(*value, U128::from(apply_percent(8, balance))); + } + } + } + + #[test] + fn validate_empty_inputs() { + let _ = Royalties::new(KEY_PREFIX); + } + + #[test] + #[should_panic(expected = "royalty overflow")] + fn create_payout_overflow() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + for idx in 0..10 { + map.insert( + &AccountIdRef::new_or_panic(&format!("bob_{}", idx)).into(), + &HashMap::from([(token_id.clone(), 8)]), + ); + } + + let mut royalties = Royalties::new(KEY_PREFIX); + mem::swap(&mut royalties.accounts, &mut map); + + royalties.create_payout( + token_id, + Balance::MAX, + &AccountIdRef::new_or_panic("alice").into(), + ); + } + + #[test] + #[should_panic(expected = "royalty overflow")] + fn apply_percent_overflow() { + apply_percent(10, Balance::MAX); + } + + #[test] + #[should_panic(expected = "can only have a maximum of 10 accounts spliting royalties")] + fn validate_too_many_accounts() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + // Fails with 11 accounts. + for idx in 0..11 { + map.insert( + &AccountIdRef::new_or_panic(&format!("bob_{}", idx)).into(), + &HashMap::from([(token_id.clone(), 8)]), + ); + } + + let mut royalties = Royalties::new(KEY_PREFIX); + + mem::swap(&mut royalties.accounts, &mut map); + royalties.validate(); + } + + #[test] + #[should_panic(expected = "each royalty should be at most 100")] + fn validate_royalty_per_account_fails() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + // Fails with more than 100% per account. + map.insert( + &AccountIdRef::new_or_panic("bob").into(), + &HashMap::from([(token_id.clone(), 101)]), + ); + + let mut royalties = Royalties::new(KEY_PREFIX); + + mem::swap(&mut royalties.accounts, &mut map); + royalties.validate(); + } + + #[test] + #[should_panic(expected = "total percent of each royalty split must be at most 100")] + fn validate_total_royalties_fails() { + let mut map = TreeMap::new(KEY_PREFIX); + let token_id = "token_id".to_string(); + + // Fails with total royalties over 100%. + for idx in 0..10 { + map.insert( + &AccountIdRef::new_or_panic(&format!("bob_{}", idx)).into(), + &HashMap::from([(token_id.clone(), 11)]), + ); + } + + let mut royalties = Royalties::new(KEY_PREFIX); + + mem::swap(&mut royalties.accounts, &mut map); + royalties.validate(); + } +} diff --git a/near-sdk/compilation_tests/borsh_storage_key_generics.rs b/near-sdk/compilation_tests/borsh_storage_key_generics.rs index e7a9c311c..b058601a1 100644 --- a/near-sdk/compilation_tests/borsh_storage_key_generics.rs +++ b/near-sdk/compilation_tests/borsh_storage_key_generics.rs @@ -1,6 +1,6 @@ //! Testing BorshStorageKey macro with lifetimes and generics. -use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::collections::LookupMap; use near_sdk::{near_bindgen, BorshStorageKey};