diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 85a99c4..52293d4 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.66 + toolchain: 1.69 components: rustfmt - name: Check formatting run: > @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master with: - toolchain: 1.66 + toolchain: 1.69 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.66 + toolchain: 1.69 - 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.66 + toolchain: 1.69 targets: wasm32-unknown-unknown - name: Run workspaces tests run: > diff --git a/Cargo.toml b/Cargo.toml index d0dd7ec..1afeb89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,22 +12,32 @@ name = "near-sdk-contract-tools" repository = "https://github.com/NEARFoundation/near-sdk-contract-tools" version = "1.0.1" -[dependencies] -near-sdk = {version = "4.1.0", default-features = false} -near-sdk-contract-tools-macros = {version = "=1.0.1", path = "./macros"} +[workspace.dependencies] +near-sdk = { version = "4.1.1", default-features = false } +near-sdk-contract-tools-macros = { version = "=1.0.1", path = "./macros" } serde = "1.0.144" serde_json = "1.0.85" thiserror = "1.0.35" +[dependencies] +near-sdk.workspace = true +near-sdk-contract-tools-macros.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + [dev-dependencies] -near-sdk = {version = "4.1.0", default-features = false, features = ["unit-testing", "legacy"]} +near-sdk = { workspace = true, default-features = false, features = [ + "unit-testing", + "legacy", +] } [features] unstable = ["near-sdk/unstable"] +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [workspace] -members = [ - ".", - "macros", - "workspaces-tests", -] +members = [".", "macros", "workspaces-tests", "workspaces-tests-utils"] diff --git a/README.md b/README.md index b4dee45..fc2a94a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This package is a collection of common tools and patterns in NEAR smart contract - Pause (derive macro available) - Derive macro for [NEP-297 events](https://nomicon.io/Standards/EventsFormat) - Derive macro for [NEP-141](https://nomicon.io/Standards/Tokens/FungibleToken/Core) (and [NEP-148](https://nomicon.io/Standards/Tokens/FungibleToken/Metadata)) fungible tokens +- Derive macro for [NEP-171](https://nomicon.io/Standards/NonFungibleToken/NonFungibleToken) non-fungible tokens, and extensions [NEP-177](https://nomicon.io/Standards/Tokens/NonFungibleToken/Metadata), [NEP-178](https://nomicon.io/Standards/Tokens/NonFungibleToken/ApprovalManagement), and [NEP-181](https://nomicon.io/Standards/Tokens/NonFungibleToken/Enumeration). Not to be confused with [`near-contract-standards`](https://crates.io/crates/near-contract-standards), which contains official implementations of standardized NEPs. This crate is intended to be a complement to `near-contract-standards`. diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 21f9c23..cfff60a 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -111,7 +111,7 @@ pub fn derive_rbac(input: TokenStream) -> TokenStream { /// Adds NEP-141 fungible token core functionality to a contract. Exposes /// `ft_*` functions to the public blockchain, implements internal controller -/// and receiver functionality (see: [`near_sdk_contract_tools::standard::nep141`]). +/// and receiver functionality. /// /// The storage key prefix for the fields can be optionally specified (default: /// `"~$141"`) using `#[nep141(storage_key = "")]`. @@ -148,6 +148,56 @@ pub fn derive_fungible_token(input: TokenStream) -> TokenStream { make_derive(input, standard::fungible_token::expand) } +/// Adds NEP-171 non-fungible token core functionality to a contract. Exposes +/// `nft_*` functions to the public blockchain, implements internal controller +/// and receiver functionality. +/// +/// The storage key prefix for the fields can be optionally specified (default: +/// `"~$171"`) using `#[nep171(storage_key = "")]`. +/// +/// Fields: +/// - `no_hooks`: Flag. Removes the requirement for the contract to implement +/// transfer hooks. +/// - `token_data`: specify the token metadata loading extensions invoked by +/// `nft_token`. +#[proc_macro_derive(Nep171, attributes(nep171))] +pub fn derive_nep171(input: TokenStream) -> TokenStream { + make_derive(input, standard::nep171::expand) +} + +/// Adds NEP-177 non-fungible token metadata functionality to a contract. +/// +/// The storage key prefix for the fields can be optionally specified (default: +/// `"~$177"`) using `#[nep177(storage_key = "")]`. +#[proc_macro_derive(Nep177, attributes(nep177))] +pub fn derive_nep177(input: TokenStream) -> TokenStream { + make_derive(input, standard::nep177::expand) +} + +/// Adds NEP-178 non-fungible token approvals functionality to a contract. +/// +/// The storage key prefix for the fields can be optionally specified (default: +/// `"~$178"`) using `#[nep178(storage_key = "")]`. +#[proc_macro_derive(Nep178, attributes(nep178))] +pub fn derive_nep178(input: TokenStream) -> TokenStream { + make_derive(input, standard::nep178::expand) +} + +/// Adds NEP-181 non-fungible token enumeration functionality to a contract. +/// +/// The storage key prefix for the fields can be optionally specified (default: +/// `"~$181"`) using `#[nep181(storage_key = "")]`. +#[proc_macro_derive(Nep181, attributes(nep181))] +pub fn derive_nep181(input: TokenStream) -> TokenStream { + make_derive(input, standard::nep181::expand) +} + +/// Implements all NFT functionality at once, like `#[derive(Nep171, Nep177, Nep178, Nep181)]`. +#[proc_macro_derive(NonFungibleToken, attributes(non_fungible_token))] +pub fn derive_non_fungible_token(input: TokenStream) -> TokenStream { + make_derive(input, standard::non_fungible_token::expand) +} + /// Migrate a contract's default struct from one schema to another. /// /// Fields may be specified in the `#[migrate(...)]` attribute. diff --git a/macros/src/standard/mod.rs b/macros/src/standard/mod.rs index cecb132..4a95ca5 100644 --- a/macros/src/standard/mod.rs +++ b/macros/src/standard/mod.rs @@ -1,6 +1,11 @@ pub mod event; pub mod fungible_token; +pub mod non_fungible_token; pub mod nep141; pub mod nep148; +pub mod nep171; +pub mod nep177; +pub mod nep178; +pub mod nep181; pub mod nep297; diff --git a/macros/src/standard/nep171.rs b/macros/src/standard/nep171.rs new file mode 100644 index 0000000..5220ec5 --- /dev/null +++ b/macros/src/standard/nep171.rs @@ -0,0 +1,236 @@ +use std::ops::Not; + +use darling::{util::Flag, FromDeriveInput}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Expr; + +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(nep171), supports(struct_named))] +pub struct Nep171Meta { + pub storage_key: Option, + pub no_hooks: Flag, + pub extension_hooks: Option, + pub check_external_transfer: Option, + pub token_data: Option, + + pub generics: syn::Generics, + pub ident: syn::Ident, + + // crates + #[darling(rename = "crate", default = "crate::default_crate_name")] + pub me: syn::Path, + #[darling(default = "crate::default_near_sdk")] + pub near_sdk: syn::Path, +} + +pub fn expand(meta: Nep171Meta) -> Result { + let Nep171Meta { + storage_key, + no_hooks, + extension_hooks, + check_external_transfer, + token_data, + + generics, + ident, + + me, + near_sdk, + } = meta; + + let (imp, ty, wher) = generics.split_for_impl(); + + let token_data = token_data + .map(|token_data| quote! { #token_data }) + .unwrap_or_else(|| { + quote! { () } + }); + + let check_external_transfer = check_external_transfer + .map(|check_external_transfer| quote! { #check_external_transfer }) + .unwrap_or_else(|| { + quote! { #me::standard::nep171::DefaultCheckExternalTransfer } + }); + + let root = storage_key.map(|storage_key| { + quote! { + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } + } + }); + + let extension_hooks_type = extension_hooks + .map(|extension_hooks| quote! { #extension_hooks }) + .unwrap_or_else(|| { + quote! { () } + }); + + let self_hooks_type = no_hooks + .is_present() + .not() + .then(|| { + quote! { + Self + } + }) + .unwrap_or_else(|| { + quote! { + () + } + }); + + let hooks_type = quote! { (#self_hooks_type, #extension_hooks_type) }; + + Ok(quote! { + impl #imp #me::standard::nep171::Nep171ControllerInternal for #ident #ty #wher { + type Hook = #hooks_type; + type CheckExternalTransfer = #check_external_transfer; + type LoadTokenMetadata = #token_data; + + #root + } + + #[#near_sdk::near_bindgen] + impl #imp #me::standard::nep171::Nep171Resolver for #ident #ty #wher { + #[private] + fn nft_resolve_transfer( + &mut self, + previous_owner_id: #near_sdk::AccountId, + receiver_id: #near_sdk::AccountId, + token_id: #me::standard::nep171::TokenId, + approved_account_ids: Option>, + ) -> bool { + use #me::standard::nep171::*; + + let _ = approved_account_ids; // #[near_bindgen] cares about parameter names + + #near_sdk::require!( + #near_sdk::env::promise_results_count() == 1, + "Requires exactly one promise result.", + ); + + let should_revert = + if let #near_sdk::PromiseResult::Successful(value) = #near_sdk::env::promise_result(0) { + let value = #near_sdk::serde_json::from_slice::(&value).unwrap_or(true); + value + } else { + true + }; + + if should_revert { + let token_ids = [token_id]; + + let transfer = Nep171Transfer { + token_id: &token_ids[0], + authorization: Nep171TransferAuthorization::Owner, + sender_id: &receiver_id, + receiver_id: &previous_owner_id, + memo: None, + msg: None, + revert: true, + }; + + ::external_transfer(self, &transfer) + .is_err() + } else { + true + } + } + } + + #[#near_sdk::near_bindgen] + impl #imp #me::standard::nep171::Nep171 for #ident #ty #wher { + #[payable] + fn nft_transfer( + &mut self, + receiver_id: #near_sdk::AccountId, + token_id: #me::standard::nep171::TokenId, + approval_id: Option, + memo: Option, + ) { + use #me::standard::nep171::*; + + #near_sdk::assert_one_yocto(); + + let sender_id = #near_sdk::env::predecessor_account_id(); + + let token_ids = [token_id]; + + let transfer = Nep171Transfer { + token_id: &token_ids[0], + authorization: approval_id.map(Nep171TransferAuthorization::ApprovalId).unwrap_or(Nep171TransferAuthorization::Owner), + sender_id: &sender_id, + receiver_id: &receiver_id, + memo: memo.as_deref(), + msg: None, + revert: false, + }; + + ::external_transfer(self, &transfer) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + } + + #[payable] + fn nft_transfer_call( + &mut self, + receiver_id: #near_sdk::AccountId, + token_id: #me::standard::nep171::TokenId, + approval_id: Option, + memo: Option, + msg: String, + ) -> #near_sdk::PromiseOrValue { + use #me::standard::nep171::*; + + #near_sdk::assert_one_yocto(); + + #near_sdk::require!( + #near_sdk::env::prepaid_gas() >= GAS_FOR_NFT_TRANSFER_CALL, + INSUFFICIENT_GAS_MESSAGE, + ); + + let sender_id = #near_sdk::env::predecessor_account_id(); + + let token_ids = [token_id]; + + let transfer = Nep171Transfer { + token_id: &token_ids[0], + authorization: approval_id.map(Nep171TransferAuthorization::ApprovalId).unwrap_or(Nep171TransferAuthorization::Owner), + sender_id: &sender_id, + receiver_id: &receiver_id, + memo: memo.as_deref(), + msg: Some(&msg), + revert: false, + }; + + ::external_transfer(self, &transfer) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + + let [token_id] = token_ids; + + ext_nep171_receiver::ext(receiver_id.clone()) + .with_static_gas(#near_sdk::env::prepaid_gas() - GAS_FOR_NFT_TRANSFER_CALL) + .nft_on_transfer( + sender_id.clone(), + sender_id.clone(), + token_id.clone(), + msg.clone(), + ) + .then( + ext_nep171_resolver::ext(#near_sdk::env::current_account_id()) + .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) + .nft_resolve_transfer(sender_id.clone(), receiver_id.clone(), token_id.clone(), None), + ) + .into() + } + + fn nft_token( + &self, + token_id: #me::standard::nep171::TokenId, + ) -> Option<#me::standard::nep171::Token> { + ::load_token(self, &token_id) + } + } + }) +} diff --git a/macros/src/standard/nep177.rs b/macros/src/standard/nep177.rs new file mode 100644 index 0000000..dccc7f3 --- /dev/null +++ b/macros/src/standard/nep177.rs @@ -0,0 +1,54 @@ +use darling::FromDeriveInput; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Expr; + +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(nep177), supports(struct_named))] +pub struct Nep177Meta { + pub storage_key: Option, + + pub generics: syn::Generics, + pub ident: syn::Ident, + + // crates + #[darling(rename = "crate", default = "crate::default_crate_name")] + pub me: syn::Path, + #[darling(default = "crate::default_near_sdk")] + pub near_sdk: syn::Path, +} + +pub fn expand(meta: Nep177Meta) -> Result { + let Nep177Meta { + storage_key, + + generics, + ident, + + me, + near_sdk, + } = meta; + + let (imp, ty, wher) = generics.split_for_impl(); + + let root = storage_key.map(|storage_key| { + quote! { + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } + } + }); + + Ok(quote! { + impl #imp #me::standard::nep177::Nep177ControllerInternal for #ident #ty #wher { + #root + } + + #[#near_sdk::near_bindgen] + impl #imp #me::standard::nep177::Nep177 for #ident #ty #wher { + fn nft_metadata(&self) -> #me::standard::nep177::ContractMetadata { + #me::standard::nep177::Nep177Controller::contract_metadata(self) + } + } + }) +} diff --git a/macros/src/standard/nep178.rs b/macros/src/standard/nep178.rs new file mode 100644 index 0000000..a9bf170 --- /dev/null +++ b/macros/src/standard/nep178.rs @@ -0,0 +1,181 @@ +use std::ops::Not; + +use darling::{util::Flag, FromDeriveInput}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Expr; + +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(nep178), supports(struct_named))] +pub struct Nep178Meta { + pub storage_key: Option, + pub no_hooks: Flag, + + pub generics: syn::Generics, + pub ident: syn::Ident, + + // crates + #[darling(rename = "crate", default = "crate::default_crate_name")] + pub me: syn::Path, + #[darling(default = "crate::default_near_sdk")] + pub near_sdk: syn::Path, +} + +pub fn expand(meta: Nep178Meta) -> Result { + let Nep178Meta { + storage_key, + no_hooks, + + generics, + ident, + + me, + near_sdk, + } = meta; + + let (imp, ty, wher) = generics.split_for_impl(); + + let root = storage_key.map(|storage_key| { + quote! { + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } + } + }); + + let before_nft_approve = no_hooks.is_present().not().then(|| { + quote! { + let hook_state = >::before_nft_approve(&self, &token_id, &account_id); + } + }); + + let after_nft_approve = no_hooks.is_present().not().then(|| { + quote! { + >::after_nft_approve(self, &token_id, &account_id, &approval_id, hook_state); + } + }); + + let before_nft_revoke = no_hooks.is_present().not().then(|| { + quote! { + let hook_state = >::before_nft_revoke(&self, &token_id, &account_id); + } + }); + + let after_nft_revoke = no_hooks.is_present().not().then(|| { + quote! { + >::after_nft_revoke(self, &token_id, &account_id, hook_state); + } + }); + + let before_nft_revoke_all = no_hooks.is_present().not().then(|| { + quote! { + let hook_state = >::before_nft_revoke_all(&self, &token_id); + } + }); + + let after_nft_revoke_all = no_hooks.is_present().not().then(|| { + quote! { + >::after_nft_revoke_all(self, &token_id, hook_state); + } + }); + + Ok(quote! { + impl #imp #me::standard::nep178::Nep178ControllerInternal for #ident #ty #wher { + #root + } + + #[#near_sdk::near_bindgen] + impl #imp #me::standard::nep178::Nep178 for #ident #ty #wher { + #[payable] + fn nft_approve( + &mut self, + token_id: #me::standard::nep171::TokenId, + account_id: #near_sdk::AccountId, + msg: Option, + ) -> #near_sdk::PromiseOrValue<()> { + #me::utils::assert_nonzero_deposit(); + + let predecessor = #near_sdk::env::predecessor_account_id(); + + #before_nft_approve + + let approval_id = #me::standard::nep178::Nep178Controller::approve( + self, + &token_id, + &predecessor, + &account_id, + ) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + + #after_nft_approve + + msg.map_or(#near_sdk::PromiseOrValue::Value(()), |msg| { + #me::standard::nep178::ext_nep178_receiver::ext(account_id) + .nft_on_approve(token_id, predecessor, approval_id, msg) + .into() + }) + } + + #[payable] + fn nft_revoke( + &mut self, + token_id: #me::standard::nep171::TokenId, + account_id: #near_sdk::AccountId, + ) { + #near_sdk::assert_one_yocto(); + + let predecessor = #near_sdk::env::predecessor_account_id(); + + #before_nft_revoke + + #me::standard::nep178::Nep178Controller::revoke( + self, + &token_id, + &predecessor, + &account_id, + ) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + + #after_nft_revoke + } + + #[payable] + fn nft_revoke_all(&mut self, token_id: #me::standard::nep171::TokenId) { + #near_sdk::assert_one_yocto(); + + let predecessor = #near_sdk::env::predecessor_account_id(); + + #before_nft_revoke_all + + #me::standard::nep178::Nep178Controller::revoke_all( + self, + &token_id, + &predecessor, + ) + .unwrap_or_else(|e| #near_sdk::env::panic_str(&e.to_string())); + + #after_nft_revoke_all + } + + fn nft_is_approved( + &self, + token_id: #me::standard::nep171::TokenId, + approved_account_id: #near_sdk::AccountId, + approval_id: Option<#me::standard::nep178::ApprovalId>, + ) -> bool { + match ( + #me::standard::nep178::Nep178Controller::get_approval_id_for( + self, + &token_id, + &approved_account_id, + ), + approval_id, + ) { + (Some(saved_approval_id), Some(provided_approval_id)) => saved_approval_id == provided_approval_id, + (Some(_), _) => true, + _ => false, + } + } + } + }) +} diff --git a/macros/src/standard/nep181.rs b/macros/src/standard/nep181.rs new file mode 100644 index 0000000..98355d2 --- /dev/null +++ b/macros/src/standard/nep181.rs @@ -0,0 +1,116 @@ +use darling::FromDeriveInput; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Expr; + +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(nep181), supports(struct_named))] +pub struct Nep181Meta { + pub storage_key: Option, + + pub generics: syn::Generics, + pub ident: syn::Ident, + + // crates + #[darling(rename = "crate", default = "crate::default_crate_name")] + pub me: syn::Path, + #[darling(default = "crate::default_near_sdk")] + pub near_sdk: syn::Path, +} + +pub fn expand(meta: Nep181Meta) -> Result { + let Nep181Meta { + storage_key, + + generics, + ident, + + me, + near_sdk, + } = meta; + + let (imp, ty, wher) = generics.split_for_impl(); + + let root = storage_key.map(|storage_key| { + quote! { + fn root() -> #me::slot::Slot<()> { + #me::slot::Slot::root(#storage_key) + } + } + }); + + Ok(quote! { + impl #imp #me::standard::nep181::Nep181ControllerInternal for #ident #ty #wher { + #root + } + + #[#near_sdk::near_bindgen] + impl #imp #me::standard::nep181::Nep181 for #ident #ty #wher { + fn nft_total_supply(&self) -> #near_sdk::json_types::U128 { + #me::standard::nep181::Nep181Controller::total_enumerated_tokens(self) + .into() + } + + fn nft_tokens( + &self, + from_index: Option<#near_sdk::json_types::U128>, + limit: Option, + ) -> Vec { + use #me::standard::{ + nep171::Nep171Controller, nep181::Nep181Controller, + }; + + Nep181Controller::with_tokens(self, |tokens| { + let from_index = from_index.map_or(0, |i| i.0 as usize); + let it = tokens + .iter() + .skip(from_index) + .map(|token_id| Nep171Controller::load_token(self, token_id).unwrap_or_else(|| { + #near_sdk::env::panic_str(&format!("Inconsistent state: Token `{}` is in the enumeration set but its metadata could not be loaded.", token_id)) + })); + + if let Some(limit) = limit { + it.take(limit as usize).collect() + } else { + it.collect() + } + }) + } + + fn nft_supply_for_owner(&self, account_id: #near_sdk::AccountId) -> #near_sdk::json_types::U128 { + #me::standard::nep181::Nep181Controller::with_tokens_for_owner( + self, + &account_id, + |tokens| (tokens.len() as u128).into(), + ) + } + + fn nft_tokens_for_owner( + &self, + account_id: #near_sdk::AccountId, + from_index: Option<#near_sdk::json_types::U128>, + limit: Option, + ) -> Vec { + use #me::standard::{ + nep171::Nep171Controller, nep181::Nep181Controller, + }; + + Nep181Controller::with_tokens_for_owner(self, &account_id, |tokens| { + let from_index = from_index.map_or(0, |i| i.0 as usize); + let it = tokens + .iter() + .skip(from_index) + .map(|token_id| Nep171Controller::load_token(self, token_id).unwrap_or_else(|| { + #near_sdk::env::panic_str(&format!("Inconsistent state: Token `{}` is in the enumeration set but its metadata could not be loaded.", token_id)) + })); + + if let Some(limit) = limit { + it.take(limit as usize).collect() + } else { + it.collect() + } + }) + } + } + }) +} diff --git a/macros/src/standard/non_fungible_token.rs b/macros/src/standard/non_fungible_token.rs new file mode 100644 index 0000000..552ab0d --- /dev/null +++ b/macros/src/standard/non_fungible_token.rs @@ -0,0 +1,114 @@ +use darling::{util::Flag, FromDeriveInput}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::Expr; + +use super::{nep171, nep177, nep178, nep181}; + +#[derive(Debug, FromDeriveInput)] +#[darling(attributes(non_fungible_token), supports(struct_named))] +pub struct NonFungibleTokenMeta { + // NEP-171 fields + pub core_storage_key: Option, + pub no_core_hooks: Flag, + + // NEP-177 fields + pub metadata_storage_key: Option, + + // NEP-178 fields + pub approval_storage_key: Option, + pub no_approval_hooks: Flag, + + // NEP-181 fields + pub enumeration_storage_key: Option, + + // darling + pub generics: syn::Generics, + pub ident: syn::Ident, + + // crates + #[darling(rename = "crate", default = "crate::default_crate_name")] + pub me: syn::Path, + #[darling(default = "crate::default_near_sdk")] + pub near_sdk: syn::Path, +} + +pub fn expand(meta: NonFungibleTokenMeta) -> Result { + let NonFungibleTokenMeta { + core_storage_key: storage_key, + no_core_hooks: no_hooks, + + metadata_storage_key, + + approval_storage_key, + no_approval_hooks, + + enumeration_storage_key, + + generics, + ident, + + me, + near_sdk, + } = meta; + + let expand_nep171 = nep171::expand(nep171::Nep171Meta { + storage_key, + no_hooks, + extension_hooks: Some( + syn::parse_quote! { (#me::standard::nep178::TokenApprovals, #me::standard::nep181::TokenEnumeration) }, + ), + check_external_transfer: Some(syn::parse_quote! { #me::standard::nep178::TokenApprovals }), + + token_data: Some( + syn::parse_quote! { (#me::standard::nep177::TokenMetadata, #me::standard::nep178::TokenApprovals) }, + ), + + generics: generics.clone(), + ident: ident.clone(), + + me: me.clone(), + near_sdk: near_sdk.clone(), + }); + + let expand_nep177 = nep177::expand(nep177::Nep177Meta { + storage_key: metadata_storage_key, + + generics: generics.clone(), + ident: ident.clone(), + + me: me.clone(), + near_sdk: near_sdk.clone(), + }); + + let expand_nep178 = nep178::expand(nep178::Nep178Meta { + storage_key: approval_storage_key, + no_hooks: no_approval_hooks, + generics: generics.clone(), + ident: ident.clone(), + me: me.clone(), + near_sdk: near_sdk.clone(), + }); + + let expand_nep181 = nep181::expand(nep181::Nep181Meta { + storage_key: enumeration_storage_key, + generics, + ident, + me, + near_sdk, + }); + + let mut e = darling::Error::accumulator(); + + let nep171 = e.handle(expand_nep171); + let nep177 = e.handle(expand_nep177); + let nep178 = e.handle(expand_nep178); + let nep181 = e.handle(expand_nep181); + + e.finish_with(quote! { + #nep171 + #nep177 + #nep178 + #nep181 + }) +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 99c6e11..5eca3a9 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.66" +channel = "1.69" # https://github.com/near/nearcore/issues/9143 diff --git a/src/fast_account_id.rs b/src/fast_account_id.rs new file mode 100644 index 0000000..b44e4e9 --- /dev/null +++ b/src/fast_account_id.rs @@ -0,0 +1,212 @@ +//! A fast alternative to `near_sdk::AccountId` that is faster to use, and has a +//! smaller Borsh serialization footprint. + +use std::{ops::Deref, rc::Rc, str::FromStr}; + +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; + +/// An alternative to `near_sdk::AccountId` that is faster to use, and has a +/// smaller Borsh serialization footprint. +/// +/// Limitations: +/// - Does not implement `serde` serialization traits. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FastAccountId(Rc); + +impl FastAccountId { + /// Creates a new `FastAccountId` from a `&str` without performing any checks. + pub fn new_unchecked(account_id: &str) -> Self { + Self(Rc::from(account_id)) + } +} + +impl std::fmt::Display for FastAccountId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", &self.0) + } +} + +impl Deref for FastAccountId { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for FastAccountId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for FastAccountId { + fn from(account_id: near_sdk::AccountId) -> Self { + Self(Rc::from(account_id.as_str())) + } +} + +impl From for near_sdk::AccountId { + fn from(account_id: FastAccountId) -> Self { + Self::new_unchecked(account_id.0.to_string()) + } +} + +impl FromStr for FastAccountId { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + near_sdk::AccountId::from_str(s).map(Self::from) + } +} + +impl TryFrom<&str> for FastAccountId { + type Error = ::Err; + + fn try_from(s: &str) -> Result { + near_sdk::AccountId::from_str(s).map(Self::from) + } +} + +impl BorshSerialize for FastAccountId { + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + let len: u8 = self.0.len() as u8; + writer.write_all(&[len])?; + let compressed = compress_account_id(&self.0).ok_or(std::io::ErrorKind::InvalidData)?; + writer.write_all(&compressed)?; + Ok(()) + } +} + +impl BorshDeserialize for FastAccountId { + fn deserialize(buf: &mut &[u8]) -> std::io::Result { + let len = buf[0] as usize; + let compressed = &buf[1..]; + let account_id = decompress_account_id(compressed, len); + *buf = &buf[1 + compressed_size(len)..]; + Ok(Self(Rc::from(account_id))) + } +} + +static ALPHABET: &[u8; 39] = b".abcdefghijklmnopqrstuvwxyz0123456789-_"; + +fn char_index(c: u8) -> Option { + ALPHABET.iter().position(|&x| x == c) +} + +fn append_sub_byte(v: &mut [u8], start_bit: usize, sub_byte: u8, num_bits: usize) { + assert!(num_bits <= 8); + + let sub_bits = sub_byte & (0b1111_1111 >> (8 - num_bits)); + + let bit_offset = start_bit % 8; + let keep_mask = !select_bits_mask(bit_offset, num_bits); + let first_byte = (v[start_bit / 8] & keep_mask) | (sub_bits << bit_offset); + + v[start_bit / 8] = first_byte; + + if bit_offset + num_bits > 8 { + let second_byte = sub_bits >> (8 - bit_offset); + v[start_bit / 8 + 1] = second_byte; + } +} + +fn read_sub_byte(v: &[u8], start_bit: usize, num_bits: usize) -> u8 { + assert!(num_bits <= 8); + + let bit_offset = start_bit % 8; + let keep_mask = select_bits_mask(bit_offset, num_bits); + let first_byte = v[start_bit / 8] & keep_mask; + + let mut sub_byte = first_byte >> bit_offset; + + if bit_offset + num_bits > 8 { + let num_bits_second = bit_offset + num_bits - 8; + let second_byte = v[start_bit / 8 + 1]; + let keep_mask = 0b1111_1111 >> (8 - num_bits_second); + sub_byte |= (second_byte & keep_mask) << (8 - bit_offset); + } + + sub_byte +} + +const fn select_bits_mask(start_bit_index: usize, num_bits: usize) -> u8 { + (0b1111_1111 << start_bit_index) + & (0b1111_1111 >> (8usize.saturating_sub(num_bits + start_bit_index))) +} + +fn decompress_account_id(compressed: &[u8], len: usize) -> String { + let mut s = String::with_capacity(len); + for i in 0..len { + let sub_byte = read_sub_byte(compressed, i * 6, 6); + let c = ALPHABET[sub_byte as usize] as char; + s.push(c); + } + s +} + +fn compressed_size(len: usize) -> usize { + len * 3 / 4 + (len * 3 % 4 > 0) as usize +} + +fn compress_account_id(account_id: &str) -> Option> { + let mut v = vec![0u8; compressed_size(account_id.len())]; + + let mut i = 0; + for c in account_id.as_bytes() { + let index = char_index(*c)? as u8; + append_sub_byte(&mut v, i, index, 6); + i += 6; + } + + Some(v) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_append_sub_byte() { + let mut v = vec![0u8; 2]; + append_sub_byte(&mut v, 0, 0b111, 3); + append_sub_byte(&mut v, 3, 0b010, 3); + append_sub_byte(&mut v, 6, 0b110, 3); + append_sub_byte(&mut v, 9, 0b1110101, 7); + + assert_eq!(v, vec![0b10010111, 0b11101011]); + } + + #[test] + fn test_read_sub_byte() { + let v = vec![0b10010111, 0b11101011]; + assert_eq!(read_sub_byte(&v, 0, 3), 0b111); + assert_eq!(read_sub_byte(&v, 3, 3), 0b010); + assert_eq!(read_sub_byte(&v, 6, 3), 0b110); + assert_eq!(read_sub_byte(&v, 9, 7), 0b1110101); + } + + #[test] + fn test_compression_decompression() { + let account_id = "test.near"; + let compressed = compress_account_id(account_id).unwrap(); + assert_eq!(compressed.len(), 7); + let decompressed = decompress_account_id(&compressed, account_id.len()); + assert_eq!(account_id, decompressed); + } + + #[test] + fn test_account_id_borsh() { + let account_id = "0".repeat(64); + let sdk_account_id = near_sdk::AccountId::new_unchecked(account_id.clone()); + let expected_serialized_length = 64 * 3 / 4 + 1; // no +1 for remainder (64 * 3 % 4 == 0), but +1 for length + let account_id = FastAccountId::new_unchecked(&account_id); + let serialized = account_id.try_to_vec().unwrap(); + assert_eq!(serialized.len(), expected_serialized_length); + let deserializalized = FastAccountId::try_from_slice(&serialized).unwrap(); + assert_eq!(account_id, deserializalized); + + let sdk_serialized = sdk_account_id.try_to_vec().unwrap(); + assert!(sdk_serialized.len() > serialized.len()); // gottem + } +} diff --git a/src/lib.rs b/src/lib.rs index 76cc957..f1f34d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,29 @@ #![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] + +use near_sdk::IntoStorageKey; +pub use near_sdk_contract_tools_macros::*; /// Default storage keys used by various traits' `root()` functions. #[derive(Clone, Debug)] pub enum DefaultStorageKey { - /// Default storage key for [`approval::ApprovalManager::root`] + /// Default storage key for [`approval::ApprovalManagerInternal::root`]. ApprovalManager, - /// Default storage key for [`standard::nep141::Nep141Controller::root`] + /// Default storage key for [`standard::nep141::Nep141ControllerInternal::root`]. Nep141, - /// Default storage key for [`owner::Owner::root`] + /// Default storage key for [`standard::nep171::Nep171ControllerInternal::root`]. + Nep171, + /// Default storage key for [`standard::nep177::Nep177ControllerInternal::root`]. + Nep177, + /// Default storage key for [`standard::nep178::Nep178ControllerInternal::root`]. + Nep178, + /// Default storage key for [`standard::nep181::Nep181ControllerInternal::root`]. + Nep181, + /// Default storage key for [`owner::OwnerInternal::root`]. Owner, - /// Default storage key for [`pause::Pause::root`] + /// Default storage key for [`pause::PauseInternal::root`]. Pause, - /// Default storage key for [`rbac::Rbac::root`] + /// Default storage key for [`rbac::RbacInternal::root`]. Rbac, /// Default storage key for [`escrow::Escrow::root`] Escrow, @@ -22,6 +34,10 @@ impl IntoStorageKey for DefaultStorageKey { match self { DefaultStorageKey::ApprovalManager => b"~am".to_vec(), DefaultStorageKey::Nep141 => b"~$141".to_vec(), + DefaultStorageKey::Nep171 => b"~$171".to_vec(), + DefaultStorageKey::Nep177 => b"~$177".to_vec(), + DefaultStorageKey::Nep178 => b"~$178".to_vec(), + DefaultStorageKey::Nep181 => b"~$181".to_vec(), DefaultStorageKey::Owner => b"~o".to_vec(), DefaultStorageKey::Pause => b"~p".to_vec(), DefaultStorageKey::Rbac => b"~r".to_vec(), @@ -34,6 +50,7 @@ pub mod standard; pub mod approval; pub mod escrow; +pub mod fast_account_id; pub mod migrate; pub mod owner; pub mod pause; @@ -42,5 +59,10 @@ pub mod slot; pub mod upgrade; pub mod utils; -use near_sdk::IntoStorageKey; -pub use near_sdk_contract_tools_macros::*; +/// Re-exports of the NFT standard traits. +pub mod nft { + pub use crate::{ + standard::{nep171::*, nep177::*, nep178::*, nep181::*}, + Nep171, Nep177, Nep178, Nep181, NonFungibleToken, + }; +} diff --git a/src/slot.rs b/src/slot.rs index 577e1ba..18c3f53 100644 --- a/src/slot.rs +++ b/src/slot.rs @@ -101,9 +101,6 @@ impl Slot { /// If the given value is `Some(T)`, writes `T` to storage. Otherwise, /// removes the key from storage. - /// - /// Use of this method makes the slot function similarly to - /// [`near_sdk::collections::LazyOption`]. pub fn set(&mut self, value: Option<&T>) -> bool { match value { Some(value) => self.write(value), diff --git a/src/standard/mod.rs b/src/standard/mod.rs index a8e5fb1..faaac74 100644 --- a/src/standard/mod.rs +++ b/src/standard/mod.rs @@ -1,5 +1,9 @@ -//! Implementations of NEP standards +//! Implementations of NEP standards. pub mod nep141; pub mod nep148; +pub mod nep171; +pub mod nep177; +pub mod nep178; +pub mod nep181; pub mod nep297; diff --git a/src/standard/nep171/error.rs b/src/standard/nep171/error.rs new file mode 100644 index 0000000..f164617 --- /dev/null +++ b/src/standard/nep171/error.rs @@ -0,0 +1,66 @@ +//! Potential errors produced by various token manipulations. + +use near_sdk::AccountId; +use thiserror::Error; + +use crate::standard::nep178::ApprovalId; + +use super::TokenId; + +/// Occurs when trying to create a token ID that already exists. +/// Overwriting pre-existing token IDs is not allowed. +#[derive(Error, Clone, Debug)] +#[error("Token `{token_id}` already exists")] +pub struct TokenAlreadyExistsError { + /// The conflicting token ID. + pub token_id: TokenId, +} + +/// When attempting to interact with a non-existent token ID. +#[derive(Error, Clone, Debug)] +#[error("Token `{token_id}` does not exist")] +pub struct TokenDoesNotExistError { + /// The invalid token ID. + pub token_id: TokenId, +} + +/// Occurs when performing a checked operation that expects a token to be +/// owned by a particular account, but the token is _not_ owned by that +/// account. +#[derive(Error, Clone, Debug)] +#[error("Token `{token_id}` is owned by `{owner_id}` instead of expected `{expected_owner_id}`")] +pub struct TokenNotOwnedByExpectedOwnerError { + /// The token was supposed to be owned by this account. + pub expected_owner_id: AccountId, + /// The token is actually owned by this account. + pub owner_id: AccountId, + /// The ID of the token in question. + pub token_id: TokenId, +} + +/// Occurs when a particular account is not allowed to transfer a token (e.g. on behalf of another user). See: NEP-178. +#[derive(Error, Clone, Debug)] +#[error("Sender `{sender_id}` does not have permission to transfer token `{token_id}`, owned by `{owner_id}`, with approval ID {approval_id}")] +pub struct SenderNotApprovedError { + /// The unapproved sender. + pub sender_id: AccountId, + /// The owner of the token. + pub owner_id: AccountId, + /// The ID of the token in question. + pub token_id: TokenId, + /// The approval ID that the sender tried to use to transfer the token. + pub approval_id: ApprovalId, +} + +/// Occurs when attempting to perform a transfer of a token from one +/// account to the same account. +#[derive(Error, Clone, Debug)] +#[error( + "Receiver must be different from current owner `{owner_id}` to transfer token `{token_id}`" +)] +pub struct TokenReceiverIsCurrentOwnerError { + /// The account ID of current owner of the token. + pub owner_id: AccountId, + /// The ID of the token in question. + pub token_id: TokenId, +} diff --git a/src/standard/nep171/event.rs b/src/standard/nep171/event.rs new file mode 100644 index 0000000..74f012a --- /dev/null +++ b/src/standard/nep171/event.rs @@ -0,0 +1,66 @@ +//! Event log metadata & associated structures. + +use near_sdk::AccountId; +use serde::Serialize; + +/// Tokens minted to a single owner. +#[derive(Serialize, Debug, Clone)] +pub struct NftMintLog { + /// To whom were the new tokens minted? + pub owner_id: AccountId, + /// Which tokens were minted? + pub token_ids: Vec, + /// Additional mint information. + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Tokens are transferred from one account to another. +#[derive(Serialize, Debug, Clone)] +pub struct NftTransferLog { + /// NEP-178 authorized account ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized_id: Option, + /// Account ID of the previous owner. + pub old_owner_id: AccountId, + /// Account ID of the new owner. + pub new_owner_id: AccountId, + /// IDs of the transferred tokens. + pub token_ids: Vec, + /// Additional transfer information. + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Tokens are burned from a single holder. +#[derive(Serialize, Debug, Clone)] +pub struct NftBurnLog { + /// What is the ID of the account from which the tokens were burned? + pub owner_id: AccountId, + /// IDs of the burned tokens. + pub token_ids: Vec, + /// NEP-178 authorized account ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized_id: Option, + /// Additional burn information. + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Token metadata update. +#[derive(Serialize, Debug, Clone)] +pub struct NftMetadataUpdateLog { + /// IDs of the updated tokens. + pub token_ids: Vec, + /// Additional update information. + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// Contract metadata update. +#[derive(Serialize, Debug, Clone)] +pub struct NftContractMetadataUpdateLog { + /// Additional update information. + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} diff --git a/src/standard/nep171/ext.rs b/src/standard/nep171/ext.rs new file mode 100644 index 0000000..c1b4dc2 --- /dev/null +++ b/src/standard/nep171/ext.rs @@ -0,0 +1,64 @@ +#![allow(missing_docs)] + +use std::collections::HashMap; + +use near_sdk::{ext_contract, AccountId, PromiseOrValue}; + +use super::TokenId; + +/// Interface of contracts that implement NEP-171. +#[ext_contract(ext_nep171)] +pub trait Nep171 { + /// Transfer a token. + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: Option, + memo: Option, + ); + + /// Transfer a token, and call [`Nep171Receiver::nft_on_transfer`] on the receiving account. + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: Option, + memo: Option, + msg: String, + ) -> PromiseOrValue; + + /// Get individual token information. + fn nft_token(&self, token_id: TokenId) -> Option; +} + +/// Original token contract follow-up to [`Nep171::nft_transfer_call`]. +#[ext_contract(ext_nep171_resolver)] +pub trait Nep171Resolver { + /// Final method call on the original token contract during an + /// [`Nep171::nft_transfer_call`] promise chain. + fn nft_resolve_transfer( + &mut self, + previous_owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + approved_account_ids: Option>, + ) -> bool; +} + +/// A contract that may be the recipient of an `nft_transfer_call` function +/// call. +#[ext_contract(ext_nep171_receiver)] +pub trait Nep171Receiver { + /// Function that is called in an `nft_transfer_call` promise chain. + /// Performs some action after receiving a non-fungible token. + /// + /// Returns `true` if token should be returned to `sender_id`. + fn nft_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_id: AccountId, + token_id: TokenId, + msg: String, + ) -> PromiseOrValue; +} diff --git a/src/standard/nep171/mod.rs b/src/standard/nep171/mod.rs new file mode 100644 index 0000000..78172a7 --- /dev/null +++ b/src/standard/nep171/mod.rs @@ -0,0 +1,757 @@ +//! NEP-171 non-fungible token core implementation. +//! +//! Reference: +//! +//! # Usage +//! +//! It is recommended to use the [`near_sdk_contract_tools_macros::Nep171`] +//! derive macro or the [`near_sdk_contract_tools_macros::NonFungibleToken`] +//! macro to implement NEP-171 with this crate. +//! +//! ## Basic implementation with no transfer hooks +//! +//! ```rust +#![doc = include_str!("../../../tests/macros/standard/nep171/no_hooks.rs")] +//! ``` +//! +//! ## Basic implementation with transfer hooks +//! +//! ```rust +#![doc = include_str!("../../../tests/macros/standard/nep171/hooks.rs")] +//! ``` +//! +//! ## Using the `NonFungibleToken` derive macro for partially-automatic integration with other utilities +//! +//! The `NonFungibleToken` derive macro automatically wires up all of the NFT-related standards' implementations (NEP-171, NEP-177, NEP-178) for you. +//! +//! ```rust +#![doc = include_str!("../../../tests/macros/standard/nep171/non_fungible_token.rs")] +//! ``` +//! +//! ## Manual integration with other utilities +//! +//! Note: NFT-related utilities are automatically integrated with each other +//! when using the [`near_sdk_contract_tools_macros::NonFungibleToken`] derive +//! macro. +//! ```rust +#![doc = include_str!("../../../tests/macros/standard/nep171/manual_integration.rs")] +//! ``` + +use std::error::Error; + +use near_sdk::{ + borsh::{self, BorshSerialize}, + serde::{Deserialize, Serialize}, + AccountId, BorshStorageKey, Gas, +}; +use near_sdk_contract_tools_macros::event; +use thiserror::Error; + +use crate::{slot::Slot, DefaultStorageKey}; + +use super::nep297::Event; + +pub mod error; +pub mod event; +// separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] +mod ext; +pub use ext::*; + +/// Minimum required gas for [`Nep171Resolver::nft_resolve_transfer`] call in promise chain during [`Nep171::nft_transfer_call`]. +pub const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(5_000_000_000_000); +/// Minimum gas required to execute the main body of [`Nep171::nft_transfer_call`] + gas for [`Nep171Resolver::nft_resolve_transfer`]. +pub const GAS_FOR_NFT_TRANSFER_CALL: Gas = Gas(25_000_000_000_000 + GAS_FOR_RESOLVE_TRANSFER.0); +/// Error message when insufficient gas is attached to function calls with a minimum attached gas requirement (i.e. those that produce a promise chain, perform cross-contract calls). +pub const INSUFFICIENT_GAS_MESSAGE: &str = "More gas is required"; + +/// NFT token IDs. +pub type TokenId = String; + +/// NEP-171 standard events. +#[event( + crate = "crate", + macros = "crate", + serde = "serde", + standard = "nep171", + version = "1.2.0" +)] +#[derive(Debug, Clone)] +pub enum Nep171Event { + /// Emitted when a token is newly minted. + NftMint(Vec), + /// Emitted when a token is transferred between two parties. + NftTransfer(Vec), + /// Emitted when a token is burned. + NftBurn(Vec), + /// Emitted when the metadata associated with an NFT contract is updated. + NftMetadataUpdate(Vec), + /// Emitted when the metadata associated with an NFT contract is updated. + ContractMetadataUpdate(Vec), +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey<'a> { + TokenOwner(&'a str), +} + +/// Potential errors encountered when performing a burn operation. +#[derive(Error, Clone, Debug)] +pub enum Nep171BurnError { + /// The token could not be burned because it does not exist. + #[error(transparent)] + TokenDoesNotExist(#[from] error::TokenDoesNotExistError), + /// The token could not be burned because it is not owned by the expected owner. + #[error(transparent)] + TokenNotOwnedByExpectedOwner(#[from] error::TokenNotOwnedByExpectedOwnerError), +} + +/// Potential errors encountered when attempting to mint a new token. +#[derive(Error, Clone, Debug)] +pub enum Nep171MintError { + /// The token could not be minted because a token with the same ID already exists. + #[error(transparent)] + TokenAlreadyExists(#[from] error::TokenAlreadyExistsError), +} + +/// Potential errors encountered when performing a token transfer. +#[derive(Error, Clone, Debug)] +pub enum Nep171TransferError { + /// The token could not be transferred because it does not exist. + #[error(transparent)] + TokenDoesNotExist(#[from] error::TokenDoesNotExistError), + /// The token could not be transferred because the sender is not allowed to perform transfers of this token on behalf of its current owner. See: NEP-178. + /// + /// NOTE: If you only implement NEP-171, approval IDs will _not work_, and this error will always be returned whenever the sender is not the current owner. + #[error(transparent)] + SenderNotApproved(#[from] error::SenderNotApprovedError), + /// The token could not be transferred because the token is being sent to the account that currently owns it. Reflexive transfers are not allowed. + #[error(transparent)] + TokenReceiverIsCurrentOwner(#[from] error::TokenReceiverIsCurrentOwnerError), + /// The token could not be transferred because it is no longer owned by the expected owner. + #[error(transparent)] + TokenNotOwnedByExpectedOwner(#[from] error::TokenNotOwnedByExpectedOwnerError), +} + +/// Internal (storage location) methods for implementors of [`Nep171Controller`]. +pub trait Nep171ControllerInternal { + /// Various lifecycle hooks for NEP-171 tokens. + type Hook: Nep171Hook + where + Self: Sized; + + /// Invoked during an external transfer. + type CheckExternalTransfer: CheckExternalTransfer + where + Self: Sized; + + /// Load additional token data into [`Token::extensions_metadata`]. + type LoadTokenMetadata: LoadTokenMetadata + where + Self: Sized; + + /// Root storage slot. + fn root() -> Slot<()> { + Slot::root(DefaultStorageKey::Nep171) + } + + /// Storage slot for the owner of a token. + fn slot_token_owner(token_id: &TokenId) -> Slot { + Self::root().field(StorageKey::TokenOwner(token_id)) + } +} + +/// Non-public controller interface for NEP-171 implementations. +pub trait Nep171Controller { + /// Various lifecycle hooks for NEP-171 tokens. + type Hook: Nep171Hook + where + Self: Sized; + + /// Invoked during an external transfer. + type CheckExternalTransfer: CheckExternalTransfer + where + Self: Sized; + + /// Load additional token data into [`Token::extensions_metadata`]. + type LoadTokenMetadata: LoadTokenMetadata + where + Self: Sized; + + /// Transfer a token from `sender_id` to `receiver_id`, as for an external + /// call to `nft_transfer`. Checks that the transfer is valid using + /// [`CheckExternalTransfer::check_external_transfer`] before performing + /// the transfer. Runs relevant hooks. + fn external_transfer(&mut self, transfer: &Nep171Transfer) -> Result<(), Nep171TransferError> + where + Self: Sized; + + /// Performs a token transfer without running [`CheckExternalTransfer::check_external_transfer`]. + /// + /// # Warning + /// + /// This function performs _no checks_. It is up to the caller to ensure + /// that the transfer is valid. Possible unintended effects of invalid + /// transfers include: + /// + /// - Transferring a token "from" an account that does not own it. + /// - Creating token IDs that did not previously exist. + /// - Transferring a token to the account that already owns it. + fn transfer_unchecked( + &mut self, + token_ids: &[TokenId], + current_owner_id: AccountId, + sender_id: AccountId, + receiver_id: AccountId, + memo: Option, + ); + + /// Mints a new token `token_id` to `owner_id`. Runs relevant hooks. + fn mint( + &mut self, + token_ids: &[TokenId], + new_owner_id: &AccountId, + memo: Option, + ) -> Result<(), Nep171MintError>; + + /// Mints a new token `token_id` to `owner_id` without checking if the + /// token already exists. Does not run hooks. + fn mint_unchecked( + &mut self, + token_ids: &[TokenId], + new_owner_id: &AccountId, + memo: Option, + ); + + /// Burns tokens `token_ids` owned by `current_owner_id`. Runs relevant hooks. + fn burn( + &mut self, + token_ids: &[TokenId], + current_owner_id: &AccountId, + memo: Option, + ) -> Result<(), Nep171BurnError>; + + /// Burns tokens `token_ids` without checking the owners. Does not run hooks. + fn burn_unchecked(&mut self, token_ids: &[TokenId]) -> bool; + + /// Returns the owner of a token, if it exists. + fn token_owner(&self, token_id: &TokenId) -> Option; + + /// Loads the metadata associated with a token. + fn load_token(&self, token_id: &TokenId) -> Option; +} + +/// Transfer metadata generic over both types of transfer (`nft_transfer` and +/// `nft_transfer_call`). +#[derive(Serialize, BorshSerialize, PartialEq, Eq, Clone, Debug, Hash)] +pub struct Nep171Transfer<'a> { + /// Why is this sender allowed to perform this transfer? + pub authorization: Nep171TransferAuthorization, + /// Sending account ID. + pub sender_id: &'a AccountId, + /// Receiving account ID. + pub receiver_id: &'a AccountId, + /// Token ID. + pub token_id: &'a TokenId, + /// Optional memo string. + pub memo: Option<&'a str>, + /// Message passed to contract located at `receiver_id` in the case of `nft_transfer_call`. + pub msg: Option<&'a str>, + /// `true` if the transfer is a revert for a `nft_transfer_call`. + pub revert: bool, +} + +/// Authorization for a transfer. +#[derive(Serialize, BorshSerialize, PartialEq, Eq, Clone, Debug, Hash)] +pub enum Nep171TransferAuthorization { + /// The sender is the owner of the token. + Owner, + /// The sender holds a valid approval ID for the token. + ApprovalId(u32), +} + +/// Different ways of checking if a transfer is valid. +pub trait CheckExternalTransfer { + /// Checks if a transfer is valid. Returns the account ID of the current + /// owner of the token. + fn check_external_transfer( + contract: &C, + transfer: &Nep171Transfer, + ) -> Result; +} + +/// Default external transfer checker. Only allows transfers by the owner of a +/// token. Does not support approval IDs. +pub struct DefaultCheckExternalTransfer; + +impl CheckExternalTransfer for DefaultCheckExternalTransfer { + fn check_external_transfer( + contract: &T, + transfer: &Nep171Transfer, + ) -> Result { + let owner_id = contract.token_owner(transfer.token_id).ok_or_else(|| { + error::TokenDoesNotExistError { + token_id: transfer.token_id.clone(), + } + })?; + + match transfer.authorization { + Nep171TransferAuthorization::Owner => { + if transfer.sender_id != &owner_id { + return Err(error::TokenNotOwnedByExpectedOwnerError { + expected_owner_id: transfer.sender_id.clone(), + owner_id, + token_id: transfer.token_id.clone(), + } + .into()); + } + } + Nep171TransferAuthorization::ApprovalId(approval_id) => { + return Err(error::SenderNotApprovedError { + owner_id, + sender_id: transfer.sender_id.clone(), + token_id: transfer.token_id.clone(), + approval_id, + } + .into()) + } + } + + if transfer.receiver_id == &owner_id { + return Err(error::TokenReceiverIsCurrentOwnerError { + owner_id, + token_id: transfer.token_id.clone(), + } + .into()); + } + + Ok(owner_id) + } +} + +/// Contracts may implement this trait to inject code into NEP-171 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 Nep171Hook { + /// State value passed from `before_nft_transfer` to `after_nft_transfer`. + type NftTransferState; + /// State value passed from `before_mint` to `after_mint`. + type MintState; + /// State value passed from `before_burn` to `after_burn`. + type BurnState; + + /// Executed before a token transfer is conducted. + /// + /// May return an optional state value which will be passed along to the + /// following `after_nft_transfer`. + /// + /// MUST NOT PANIC if the transfer is a revert. + fn before_nft_transfer(contract: &C, transfer: &Nep171Transfer) -> Self::NftTransferState; + + /// Executed after a token transfer is conducted. + /// + /// Receives the state value returned by `before_nft_transfer`. + /// + /// MUST NOT PANIC if the transfer is a revert. + fn after_nft_transfer( + contract: &mut C, + transfer: &Nep171Transfer, + state: Self::NftTransferState, + ); + + /// Executed before a token is minted. + fn before_mint(contract: &C, token_ids: &[TokenId], owner_id: &AccountId) -> Self::MintState; + /// Executed after a token is minted. + fn after_mint( + contract: &mut C, + token_ids: &[TokenId], + owner_id: &AccountId, + state: Self::MintState, + ); + + /// Executed before a token is burned. + fn before_burn(contract: &C, token_ids: &[TokenId], owner_id: &AccountId) -> Self::BurnState; + /// Executed after a token is burned. + fn after_burn( + contract: &mut C, + token_ids: &[TokenId], + owner_id: &AccountId, + state: Self::BurnState, + ); +} + +impl Nep171Hook for () { + type NftTransferState = (); + type MintState = (); + type BurnState = (); + + fn before_nft_transfer(_contract: &C, _transfer: &Nep171Transfer) {} + + fn after_nft_transfer(_contract: &mut C, _transfer: &Nep171Transfer, _state: ()) {} + + fn before_mint( + _contract: &C, + _token_ids: &[TokenId], + _owner_id: &AccountId, + ) -> Self::MintState { + } + + fn after_mint( + _contract: &mut C, + _token_ids: &[TokenId], + _owner_id: &AccountId, + _state: Self::MintState, + ) { + } + + fn before_burn( + _contract: &C, + _token_ids: &[TokenId], + _owner_id: &AccountId, + ) -> Self::BurnState { + } + + fn after_burn( + _contract: &mut C, + _token_ids: &[TokenId], + _owner_id: &AccountId, + _state: Self::BurnState, + ) { + } +} + +impl Nep171Hook for (Handl0, Handl1) +where + Handl0: Nep171Hook, + Handl1: Nep171Hook, +{ + type MintState = (Handl0::MintState, Handl1::MintState); + type NftTransferState = (Handl0::NftTransferState, Handl1::NftTransferState); + type BurnState = (Handl0::BurnState, Handl1::BurnState); + + fn before_mint( + contract: &Cont, + token_ids: &[TokenId], + owner_id: &AccountId, + ) -> Self::MintState { + ( + Handl0::before_mint(contract, token_ids, owner_id), + Handl1::before_mint(contract, token_ids, owner_id), + ) + } + + fn after_mint( + contract: &mut Cont, + token_ids: &[TokenId], + owner_id: &AccountId, + state: Self::MintState, + ) { + Handl0::after_mint(contract, token_ids, owner_id, state.0); + Handl1::after_mint(contract, token_ids, owner_id, state.1); + } + + fn before_nft_transfer( + contract: &Cont, + transfer: &Nep171Transfer, + ) -> (Handl0::NftTransferState, Handl1::NftTransferState) { + ( + Handl0::before_nft_transfer(contract, transfer), + Handl1::before_nft_transfer(contract, transfer), + ) + } + + fn after_nft_transfer( + contract: &mut Cont, + transfer: &Nep171Transfer, + state: (Handl0::NftTransferState, Handl1::NftTransferState), + ) { + Handl0::after_nft_transfer(contract, transfer, state.0); + Handl1::after_nft_transfer(contract, transfer, state.1); + } + + fn before_burn( + contract: &Cont, + token_ids: &[TokenId], + owner_id: &AccountId, + ) -> Self::BurnState { + ( + Handl0::before_burn(contract, token_ids, owner_id), + Handl1::before_burn(contract, token_ids, owner_id), + ) + } + + fn after_burn( + contract: &mut Cont, + token_ids: &[TokenId], + owner_id: &AccountId, + state: Self::BurnState, + ) { + Handl0::after_burn(contract, token_ids, owner_id, state.0); + Handl1::after_burn(contract, token_ids, owner_id, state.1); + } +} + +/// Alternative to [`Nep171Hook`] for implementing NEP-171 hooks. Implementing +/// the full [`Nep171Hook`] trait is sometimes inconvenient, so this trait +/// provides a simpler interface. There is a blanket implementation of +/// [`Nep171Hook`] for all types that implement this trait. +pub trait SimpleNep171Hook { + /// Executed before a token is minted. + fn before_mint(&self, _token_ids: &[TokenId], _owner_id: &AccountId) {} + /// Executed after a token is minted. + fn after_mint(&mut self, _token_ids: &[TokenId], _owner_id: &AccountId) {} + /// Executed before a token transfer is conducted. + fn before_nft_transfer(&self, _transfer: &Nep171Transfer) {} + /// Executed after a token transfer is conducted. + fn after_nft_transfer(&mut self, _transfer: &Nep171Transfer) {} + /// Executed before a token is burned. + fn before_burn(&self, _token_ids: &[TokenId], _owner_id: &AccountId) {} + /// Executed after a token is burned. + fn after_burn(&mut self, _token_ids: &[TokenId], _owner_id: &AccountId) {} +} + +impl Nep171Hook for T { + type MintState = (); + type NftTransferState = (); + type BurnState = (); + + fn before_mint(contract: &Self, token_ids: &[TokenId], owner_id: &AccountId) { + SimpleNep171Hook::before_mint(contract, token_ids, owner_id); + } + + fn after_mint(contract: &mut Self, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { + SimpleNep171Hook::after_burn(contract, token_ids, owner_id); + } + + fn before_nft_transfer(contract: &T, transfer: &Nep171Transfer) { + SimpleNep171Hook::before_nft_transfer(contract, transfer); + } + + fn after_nft_transfer(contract: &mut T, transfer: &Nep171Transfer, _: ()) { + SimpleNep171Hook::after_nft_transfer(contract, transfer); + } + + fn before_burn(contract: &T, token_ids: &[TokenId], owner_id: &AccountId) { + SimpleNep171Hook::before_burn(contract, token_ids, owner_id); + } + + fn after_burn(contract: &mut T, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { + SimpleNep171Hook::after_burn(contract, token_ids, owner_id); + } +} + +impl Nep171Controller for T { + type Hook = ::Hook; + type CheckExternalTransfer = ::CheckExternalTransfer; + type LoadTokenMetadata = ::LoadTokenMetadata; + + fn external_transfer(&mut self, transfer: &Nep171Transfer) -> Result<(), Nep171TransferError> { + match Self::CheckExternalTransfer::check_external_transfer(self, transfer) { + Ok(current_owner_id) => { + let state = ::Hook::before_nft_transfer(self, transfer); + + self.transfer_unchecked( + &[transfer.token_id.to_string()], + current_owner_id, + transfer.sender_id.clone(), + transfer.receiver_id.clone(), + transfer.memo.map(ToString::to_string), + ); + + ::Hook::after_nft_transfer(self, transfer, state); + + Ok(()) + } + Err(e) => Err(e), + } + } + + fn transfer_unchecked( + &mut self, + token_ids: &[TokenId], + current_owner_id: AccountId, + _sender_id: AccountId, + receiver_id: AccountId, + memo: Option, + ) { + if !token_ids.is_empty() { + Nep171Event::NftTransfer(vec![event::NftTransferLog { + authorized_id: None, + old_owner_id: current_owner_id, + new_owner_id: receiver_id.clone(), + token_ids: token_ids.iter().map(ToString::to_string).collect(), + memo, + }]) + .emit(); + } + + for token_id in token_ids { + let mut slot = Self::slot_token_owner(token_id); + slot.write(&receiver_id); + } + } + + fn mint_unchecked( + &mut self, + token_ids: &[TokenId], + new_owner_id: &AccountId, + memo: Option, + ) { + if token_ids.is_empty() { + return; + } + + Nep171Event::NftMint(vec![event::NftMintLog { + token_ids: token_ids.iter().map(ToString::to_string).collect(), + owner_id: new_owner_id.clone(), + memo, + }]) + .emit(); + + token_ids.iter().for_each(|token_id| { + let mut slot = Self::slot_token_owner(token_id); + slot.write(new_owner_id); + }); + } + + fn mint( + &mut self, + token_ids: &[TokenId], + new_owner_id: &AccountId, + memo: Option, + ) -> Result<(), Nep171MintError> { + for token_id in token_ids { + let slot = Self::slot_token_owner(token_id); + if slot.exists() { + return Err(error::TokenAlreadyExistsError { + token_id: token_id.to_string(), + } + .into()); + } + } + + let state = Self::Hook::before_mint(self, token_ids, new_owner_id); + + self.mint_unchecked(token_ids, new_owner_id, memo); + + Self::Hook::after_mint(self, token_ids, new_owner_id, state); + + Ok(()) + } + + fn burn( + &mut self, + token_ids: &[TokenId], + current_owner_id: &AccountId, + memo: Option, + ) -> Result<(), Nep171BurnError> { + if token_ids.is_empty() { + return Ok(()); + } + + for token_id in token_ids { + if let Some(actual_owner_id) = self.token_owner(token_id) { + if &actual_owner_id != current_owner_id { + return Err(error::TokenNotOwnedByExpectedOwnerError { + expected_owner_id: current_owner_id.clone(), + owner_id: actual_owner_id, + token_id: (*token_id).clone(), + } + .into()); + } + } else { + return Err(error::TokenDoesNotExistError { + token_id: (*token_id).clone(), + } + .into()); + } + } + + let state = Self::Hook::before_burn(self, token_ids, current_owner_id); + + self.burn_unchecked(token_ids); + + Self::Hook::after_burn(self, token_ids, current_owner_id, state); + + Nep171Event::NftBurn(vec![event::NftBurnLog { + token_ids: token_ids.iter().map(ToString::to_string).collect(), + owner_id: current_owner_id.clone(), + authorized_id: None, + memo, + }]) + .emit(); + + Ok(()) + } + + fn burn_unchecked(&mut self, token_ids: &[TokenId]) -> bool { + let mut removed_successfully = true; + + for token_id in token_ids { + removed_successfully &= Self::slot_token_owner(token_id).remove(); + } + + removed_successfully + } + + fn token_owner(&self, token_id: &TokenId) -> Option { + Self::slot_token_owner(token_id).read() + } + + fn load_token(&self, token_id: &TokenId) -> Option { + let mut metadata = std::collections::HashMap::new(); + Self::LoadTokenMetadata::load(self, token_id, &mut metadata).ok()?; + Some(Token { + token_id: token_id.clone(), + owner_id: self.token_owner(token_id)?, + extensions_metadata: metadata, + }) + } +} + +/// Token information structure. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Token { + /// Token ID. + pub token_id: TokenId, + /// Current owner of the token. + pub owner_id: AccountId, + /// Metadata provided by extensions. + #[serde(flatten)] + pub extensions_metadata: std::collections::HashMap, +} + +/// Trait for NFT extensions to load token metadata. +pub trait LoadTokenMetadata { + /// Load token metadata into `metadata`. + fn load( + contract: &C, + token_id: &TokenId, + metadata: &mut std::collections::HashMap, + ) -> Result<(), Box>; +} + +impl LoadTokenMetadata for () { + fn load( + _contract: &C, + _token_id: &TokenId, + _metadata: &mut std::collections::HashMap, + ) -> Result<(), Box> { + Ok(()) + } +} + +impl, U: LoadTokenMetadata> LoadTokenMetadata for (T, U) { + fn load( + contract: &C, + token_id: &TokenId, + metadata: &mut std::collections::HashMap, + ) -> Result<(), Box> { + T::load(contract, token_id, metadata)?; + U::load(contract, token_id, metadata)?; + Ok(()) + } +} + +// further variations are technically unnecessary: just use (T, (U, V)) or ((T, U), V) diff --git a/src/standard/nep177.rs b/src/standard/nep177.rs new file mode 100644 index 0000000..e508d1c --- /dev/null +++ b/src/standard/nep177.rs @@ -0,0 +1,372 @@ +//! NEP-177 non-fungible token contract metadata implementation. +//! +//! Reference: +use std::error::Error; + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, + json_types::U64, + serde::*, + AccountId, BorshStorageKey, +}; +use thiserror::Error; + +use crate::{ + slot::Slot, + standard::{ + nep171::{ + self, + error::TokenDoesNotExistError, + event::{NftContractMetadataUpdateLog, NftMetadataUpdateLog}, + *, + }, + nep297::Event, + }, + DefaultStorageKey, +}; + +pub use ext::*; + +const CONTRACT_METADATA_NOT_INITIALIZED_ERROR: &str = "Contract metadata not initialized"; + +/// Non-fungible token contract metadata. +#[derive( + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, +)] +#[serde(crate = "near_sdk::serde")] +pub struct ContractMetadata { + /// The metadata specification version. Essentially a version like "nft-2.0.0", replacing "2.0.0" with the implemented version of NEP-177. + pub spec: String, + /// The name of the NFT contract, e.g. "Mochi Rising — Digital Edition" or "Metaverse 3". + pub name: String, + /// The symbol of the NFT contract, e.g. "MOCHI" or "M3". + pub symbol: String, + /// Data URI for the contract icon. + pub icon: Option, + /// Gateway known to have reliable access to decentralized storage assets referenced by `reference` or `media` URLs. + pub base_uri: Option, + /// URL to a JSON file with more info about the NFT contract. + pub reference: Option, + /// Base-64-encoded SHA-256 hash of the referenced JSON file. Required if `reference` is present. + pub reference_hash: Option, +} + +impl ContractMetadata { + /// The metadata specification version. + pub const SPEC: &'static str = "nft-2.1.0"; + + /// Creates a new contract metadata, specifying the name, symbol, and + /// optional base URI. Other fields are set to `None`. + pub fn new(name: String, symbol: String, base_uri: Option) -> Self { + Self { + spec: Self::SPEC.to_string(), + name, + symbol, + icon: None, + base_uri, + reference: None, + reference_hash: None, + } + } +} + +/// Non-fungible token metadata. +#[derive( + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, +)] +#[serde(crate = "near_sdk::serde")] +pub struct TokenMetadata { + /// This token's title, e.g. "Arch Nemesis: Mail Carrier" or "Parcel #5055". + pub title: Option, + /// Free-text description of this specific token. + pub description: Option, + /// The token's image or other associated media. + pub media: Option, + /// Base-64-encoded SHA-256 hash of the media. Required if `media` is present. + pub media_hash: Option, + /// Number of copies of this set of metadata in existence when token was minted. + pub copies: Option, + /// When the token was issued, in milliseconds since the UNIX epoch. + pub issued_at: Option, + /// When the token expires, in milliseconds since the UNIX epoch. + pub expires_at: Option, + /// When the token starts being valid, in milliseconds since the UNIX epoch. + pub starts_at: Option, + /// When the token was last updated, in milliseconds since the UNIX epoch. + pub updated_at: Option, + /// Anything extra the NFT wants to store on-chain. Can be stringified JSON. + pub extra: Option, + /// URL to an off-chain JSON file with more info about the token. + pub reference: Option, + /// Base-64-encoded SHA-256 hash of the referenced JSON file. Required if `reference` is present. + pub reference_hash: Option, +} + +// Builder pattern for TokenMetadata. +impl TokenMetadata { + /// Create a new `TokenMetadata` with all fields set to `None`. + pub fn new() -> Self { + Self::default() + } + + /// Set the title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the description. + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set the media. + pub fn media(mut self, media: impl Into) -> Self { + self.media = Some(media.into()); + self + } + + /// Set the media hash. + pub fn media_hash(mut self, media_hash: impl Into) -> Self { + self.media_hash = Some(media_hash.into()); + self + } + + /// Set the copies. + pub fn copies(mut self, copies: impl Into) -> Self { + self.copies = Some(copies.into()); + self + } + + /// Set the time the token was issued. + pub fn issued_at(mut self, issued_at: impl Into) -> Self { + self.issued_at = Some(issued_at.into()); + self + } + + /// Set the time the token expires. + pub fn expires_at(mut self, expires_at: impl Into) -> Self { + self.expires_at = Some(expires_at.into()); + self + } + + /// Set the time the token starts being valid. + pub fn starts_at(mut self, starts_at: impl Into) -> Self { + self.starts_at = Some(starts_at.into()); + self + } + + /// Set the time the token was last updated. + pub fn updated_at(mut self, updated_at: impl Into) -> Self { + self.updated_at = Some(updated_at.into()); + self + } + + /// Set the extra data. + pub fn extra(mut self, extra: impl Into) -> Self { + self.extra = Some(extra.into()); + self + } + + /// Set the reference. + pub fn reference(mut self, reference: impl Into) -> Self { + self.reference = Some(reference.into()); + self + } + + /// Set the reference hash. + pub fn reference_hash(mut self, reference_hash: impl Into) -> Self { + self.reference_hash = Some(reference_hash.into()); + self + } +} + +/// Error returned when trying to load token metadata that does not exist. +#[derive(Error, Debug)] +#[error("Token metadata does not exist: {0}")] +pub struct TokenMetadataMissingError(pub TokenId); + +impl LoadTokenMetadata for TokenMetadata { + fn load( + contract: &C, + token_id: &TokenId, + metadata: &mut std::collections::HashMap, + ) -> Result<(), Box> { + metadata.insert( + "metadata".to_string(), + near_sdk::serde_json::to_value( + contract + .token_metadata(token_id) + .ok_or_else(|| TokenMetadataMissingError(token_id.to_string()))?, + )?, + ); + Ok(()) + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey<'a> { + ContractMetadata, + TokenMetadata(&'a TokenId), +} + +/// Internal functions for [`Nep177Controller`]. +pub trait Nep177ControllerInternal { + /// Storage root. + fn root() -> Slot<()> { + Slot::root(DefaultStorageKey::Nep177) + } + + /// Storage slot for contract metadata. + fn slot_contract_metadata() -> Slot { + Self::root().field(StorageKey::ContractMetadata) + } + + /// Storage slot for token metadata. + fn slot_token_metadata(token_id: &TokenId) -> Slot { + Self::root().field(StorageKey::TokenMetadata(token_id)) + } +} + +/// Functions for managing non-fungible tokens with attached metadata, NEP-177. +pub trait Nep177Controller { + /// Mint a new token with metadata. + fn mint_with_metadata( + &mut self, + token_id: TokenId, + owner_id: AccountId, + metadata: TokenMetadata, + ) -> Result<(), Nep171MintError>; + + /// Burn a token with metadata. + fn burn_with_metadata( + &mut self, + token_id: TokenId, + current_owner_id: &AccountId, + ) -> Result<(), Nep171BurnError>; + + /// Sets the metadata for a token ID without checking whether the token exists, etc. and emits an [`Nep171Event::NftMetadataUpdate`] event. + fn set_token_metadata_unchecked(&mut self, token_id: TokenId, metadata: Option); + + /// Sets the metadata for a token ID and emits an [`Nep171Event::NftMetadataUpdate`] event. + fn set_token_metadata( + &mut self, + token_id: TokenId, + metadata: TokenMetadata, + ) -> Result<(), UpdateTokenMetadataError>; + + /// Sets the contract metadata and emits an [`Nep171Event::ContractMetadataUpdate`] event. + fn set_contract_metadata(&mut self, metadata: ContractMetadata); + + /// Returns the contract metadata. + fn contract_metadata(&self) -> ContractMetadata; + + /// Returns the metadata for a token ID. + fn token_metadata(&self, token_id: &TokenId) -> Option; +} + +/// Error returned when a token update fails. +#[derive(Error, Debug)] +pub enum UpdateTokenMetadataError { + /// The token does not exist. + #[error(transparent)] + TokenNotFound(#[from] TokenDoesNotExistError), +} + +impl Nep177Controller for T { + fn set_token_metadata( + &mut self, + token_id: TokenId, + metadata: TokenMetadata, + ) -> Result<(), UpdateTokenMetadataError> { + if self.token_owner(&token_id).is_some() { + self.set_token_metadata_unchecked(token_id, Some(metadata)); + Ok(()) + } else { + Err(TokenDoesNotExistError { token_id }.into()) + } + } + + fn set_contract_metadata(&mut self, metadata: ContractMetadata) { + Self::slot_contract_metadata().set(Some(&metadata)); + Nep171Event::ContractMetadataUpdate(vec![NftContractMetadataUpdateLog { memo: None }]) + .emit(); + } + + fn mint_with_metadata( + &mut self, + token_id: TokenId, + owner_id: AccountId, + metadata: TokenMetadata, + ) -> Result<(), Nep171MintError> { + let token_ids = [token_id]; + self.mint(&token_ids, &owner_id, None)?; + let [token_id] = token_ids; + self.set_token_metadata_unchecked(token_id, Some(metadata)); + Ok(()) + } + + fn burn_with_metadata( + &mut self, + token_id: TokenId, + current_owner_id: &AccountId, + ) -> Result<(), Nep171BurnError> { + let token_ids = [token_id]; + self.burn(&token_ids, current_owner_id, None)?; + let [token_id] = token_ids; + self.set_token_metadata_unchecked(token_id, None); + Ok(()) + } + + fn set_token_metadata_unchecked(&mut self, token_id: TokenId, metadata: Option) { + ::slot_token_metadata(&token_id).set(metadata.as_ref()); + nep171::Nep171Event::NftMetadataUpdate(vec![NftMetadataUpdateLog { + token_ids: vec![token_id], + memo: None, + }]) + .emit(); + } + + fn token_metadata(&self, token_id: &TokenId) -> Option { + ::slot_token_metadata(token_id).read() + } + + fn contract_metadata(&self) -> ContractMetadata { + Self::slot_contract_metadata() + .read() + .unwrap_or_else(|| env::panic_str(CONTRACT_METADATA_NOT_INITIALIZED_ERROR)) + } +} + +// separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] +mod ext { + #![allow(missing_docs)] + + use super::*; + + #[near_sdk::ext_contract(ext_nep177)] + pub trait Nep177 { + fn nft_metadata(&self) -> ContractMetadata; + } +} diff --git a/src/standard/nep178.rs b/src/standard/nep178.rs new file mode 100644 index 0000000..50d6416 --- /dev/null +++ b/src/standard/nep178.rs @@ -0,0 +1,470 @@ +//! NEP-178 non-fungible token approval management implementation. +//! +//! Reference: +use std::{collections::HashMap, error::Error}; + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + store::UnorderedMap, + AccountId, BorshStorageKey, +}; +use thiserror::Error; + +use crate::{slot::Slot, standard::nep171::*, DefaultStorageKey}; + +pub use ext::*; + +/// Type for approval IDs. +pub type ApprovalId = u32; +/// Maximum number of approvals per token. +pub const MAX_APPROVALS: ApprovalId = 32; + +/// Non-fungible token metadata. +#[derive(BorshSerialize, BorshDeserialize, Debug)] +pub struct TokenApprovals { + /// The next approval ID to use. Only incremented. + pub next_approval_id: ApprovalId, + + /// The list of approved accounts. + pub accounts: UnorderedMap, +} + +impl LoadTokenMetadata for TokenApprovals { + fn load( + contract: &C, + token_id: &TokenId, + metadata: &mut std::collections::HashMap, + ) -> Result<(), Box> { + metadata.insert( + "approved_account_ids".to_string(), + near_sdk::serde_json::to_value(contract.get_approvals_for(token_id))?, + ); + Ok(()) + } +} + +impl Nep171Hook for TokenApprovals { + type MintState = (); + type NftTransferState = (); + type BurnState = (); + + fn before_mint(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} + + fn after_mint(_contract: &mut C, _token_ids: &[TokenId], _owner_id: &AccountId, _: ()) {} + + fn before_nft_transfer(_contract: &C, _transfer: &Nep171Transfer) {} + + fn after_nft_transfer(contract: &mut C, transfer: &Nep171Transfer, _: ()) { + contract.revoke_all_unchecked(transfer.token_id); + } + + fn before_burn(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} + + fn after_burn(contract: &mut C, token_ids: &[TokenId], _owner_id: &AccountId, _: ()) { + for token_id in token_ids { + contract.revoke_all_unchecked(token_id); + } + } +} + +impl CheckExternalTransfer for TokenApprovals { + fn check_external_transfer( + contract: &C, + transfer: &Nep171Transfer, + ) -> Result { + let normal_check = + DefaultCheckExternalTransfer::check_external_transfer(contract, transfer); + + match (&transfer.authorization, normal_check) { + (_, r @ Ok(_)) => r, + ( + Nep171TransferAuthorization::ApprovalId(approval_id), + Err(Nep171TransferError::SenderNotApproved(s)), + ) => { + let saved_approval = + contract.get_approval_id_for(transfer.token_id, transfer.sender_id); + + if saved_approval == Some(*approval_id) { + Ok(s.owner_id) + } else { + Err(s.into()) + } + } + (_, e @ Err(_)) => e, + } + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey<'a> { + TokenApprovals(&'a TokenId), + TokenApprovalsUnorderedMap(&'a TokenId), +} + +/// Internal functions for [`Nep178Controller`]. +pub trait Nep178ControllerInternal { + /// Storage root. + fn root() -> Slot<()> { + Slot::root(DefaultStorageKey::Nep178) + } + + /// Storage slot for token approvals. + fn slot_token_approvals(token_id: &TokenId) -> Slot { + Self::root().field(StorageKey::TokenApprovals(token_id)) + } + + /// Storage slot for token approvals `UnorderedMap`. + fn slot_token_approvals_unordered_map( + token_id: &TokenId, + ) -> Slot> { + Self::root().field(StorageKey::TokenApprovalsUnorderedMap(token_id)) + } +} + +/// Errors that can occur when managing non-fungible token approvals. +#[derive(Error, Debug)] +pub enum Nep178ApproveError { + /// The account is not authorized to approve the token. + #[error("Account `{account_id}` cannot create approvals for token `{token_id}`.")] + Unauthorized { + /// The token ID. + token_id: TokenId, + /// The unauthorized account ID. + account_id: AccountId, + }, + /// The account is already approved for the token. + #[error("Account {account_id} is already approved for token {token_id}.")] + AccountAlreadyApproved { + /// The token ID. + token_id: TokenId, + /// The account ID that has already been approved. + account_id: AccountId, + }, + /// The token has too many approvals. + #[error( + "Too many approvals for token {token_id}, maximum is {}.", + MAX_APPROVALS + )] + TooManyApprovals { + /// The token ID. + token_id: TokenId, + }, +} + +/// Errors that can occur when revoking non-fungible token approvals. +#[derive(Error, Debug)] +pub enum Nep178RevokeError { + /// The account is not authorized to revoke approvals for the token. + #[error("Account `{account_id}` is cannot revoke approvals for token `{token_id}`.")] + Unauthorized { + /// The token ID. + token_id: TokenId, + /// The unauthorized account ID. + account_id: AccountId, + }, + /// The account is not approved for the token. + #[error("Account {account_id} is not approved for token {token_id}")] + AccountNotApproved { + /// The token ID. + token_id: TokenId, + /// The account ID that is not approved. + account_id: AccountId, + }, +} + +/// Errors that can occur when revoking all approvals for a non-fungible token. +#[derive(Error, Debug)] +pub enum Nep178RevokeAllError { + /// The account is not authorized to revoke approvals for the token. + #[error("Account `{account_id}` is cannot revoke approvals for token `{token_id}`.")] + Unauthorized { + /// The token ID. + token_id: TokenId, + /// The unauthorized account ID. + account_id: AccountId, + }, +} + +/// Functions for managing non-fungible tokens with attached metadata, NEP-178. +pub trait Nep178Controller { + /// Approve a token for transfer by a delegated account. + fn approve( + &mut self, + token_id: &TokenId, + current_owner_id: &AccountId, + account_id: &AccountId, + ) -> Result; + + /// Approve a token without checking if the account is already approved or + /// if it exceeds the maximum number of approvals. + fn approve_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) -> ApprovalId; + + /// Revoke approval for an account to transfer token. + fn revoke( + &mut self, + token_id: &TokenId, + current_owner_id: &AccountId, + account_id: &AccountId, + ) -> Result<(), Nep178RevokeError>; + + /// Revoke approval for an account to transfer token without checking if + /// the account is approved. + fn revoke_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId); + + /// Revoke all approvals for a token. + fn revoke_all( + &mut self, + token_id: &TokenId, + current_owner_id: &AccountId, + ) -> Result<(), Nep178RevokeAllError>; + + /// Revoke all approvals for a token without checking current owner. + fn revoke_all_unchecked(&mut self, token_id: &TokenId); + + /// Get the approval ID for an account, if it is approved for a token. + fn get_approval_id_for(&self, token_id: &TokenId, account_id: &AccountId) + -> Option; + + /// Get the approvals for a token. + fn get_approvals_for(&self, token_id: &TokenId) -> HashMap; +} + +impl Nep178Controller for T { + fn approve_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) -> ApprovalId { + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = slot.read().unwrap_or_else(|| TokenApprovals { + next_approval_id: 0, + accounts: UnorderedMap::new(Self::slot_token_approvals_unordered_map(token_id)), + }); + let approval_id = approvals.next_approval_id; + approvals.accounts.insert(account_id.clone(), approval_id); + approvals.next_approval_id += 1; // overflow unrealistic + slot.write(&approvals); + + approval_id + } + + fn approve( + &mut self, + token_id: &TokenId, + current_owner_id: &AccountId, + account_id: &AccountId, + ) -> Result { + // owner check + if self.token_owner(token_id).as_ref() != Some(current_owner_id) { + return Err(Nep178ApproveError::Unauthorized { + token_id: token_id.clone(), + account_id: account_id.clone(), + }); + } + + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = slot.read().unwrap_or_else(|| TokenApprovals { + next_approval_id: 0, + accounts: UnorderedMap::new(Self::slot_token_approvals_unordered_map(token_id)), + }); + + if approvals.accounts.len() >= MAX_APPROVALS { + return Err(Nep178ApproveError::TooManyApprovals { + token_id: token_id.clone(), + }); + } + + let approval_id = approvals.next_approval_id; + if approvals + .accounts + .insert(account_id.clone(), approval_id) + .is_some() + { + return Err(Nep178ApproveError::AccountAlreadyApproved { + token_id: token_id.clone(), + account_id: account_id.clone(), + }); + } + approvals.next_approval_id += 1; // overflow unrealistic + slot.write(&approvals); + + Ok(approval_id) + } + + fn revoke_unchecked(&mut self, token_id: &TokenId, account_id: &AccountId) { + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = match slot.read() { + Some(approvals) => approvals, + None => return, + }; + + let old = approvals.accounts.remove(account_id); + + if old.is_some() { + slot.write(&approvals); + } + } + + fn revoke( + &mut self, + token_id: &TokenId, + current_owner_id: &AccountId, + account_id: &AccountId, + ) -> Result<(), Nep178RevokeError> { + // owner check + if self.token_owner(token_id).as_ref() != Some(current_owner_id) { + return Err(Nep178RevokeError::Unauthorized { + token_id: token_id.clone(), + account_id: account_id.clone(), + }); + } + + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = slot + .read() + .ok_or_else(|| Nep178RevokeError::AccountNotApproved { + token_id: token_id.clone(), + account_id: account_id.clone(), + })?; + + approvals + .accounts + .remove(account_id) + .ok_or(Nep178RevokeError::AccountNotApproved { + token_id: token_id.clone(), + account_id: account_id.clone(), + })?; + + slot.write(&approvals); + + Ok(()) + } + + fn revoke_all( + &mut self, + token_id: &TokenId, + current_owner_id: &AccountId, + ) -> Result<(), Nep178RevokeAllError> { + // owner check + if self.token_owner(token_id).as_ref() != Some(current_owner_id) { + return Err(Nep178RevokeAllError::Unauthorized { + token_id: token_id.clone(), + account_id: current_owner_id.clone(), + }); + } + + self.revoke_all_unchecked(token_id); + + Ok(()) + } + + fn revoke_all_unchecked(&mut self, token_id: &TokenId) { + let mut slot = Self::slot_token_approvals(token_id); + let mut approvals = match slot.read() { + Some(approvals) => approvals, + None => return, + }; + + if !approvals.accounts.is_empty() { + approvals.accounts.clear(); + slot.write(&approvals); + } + } + + fn get_approval_id_for( + &self, + token_id: &TokenId, + account_id: &AccountId, + ) -> Option { + let slot = Self::slot_token_approvals(token_id); + let approvals = slot.read()?; + + approvals.accounts.get(account_id).copied() + } + + fn get_approvals_for(&self, token_id: &TokenId) -> HashMap { + let slot = Self::slot_token_approvals(token_id); + let approvals = match slot.read() { + Some(approvals) => approvals, + None => return HashMap::default(), + }; + + approvals + .accounts + .into_iter() + .map(|(k, v)| (k.clone(), *v)) + .collect() + } +} + +/// Hooks for NEP-178. +pub trait Nep178Hook { + /// Called before a token is approved for transfer. + fn before_nft_approve(&self, token_id: &TokenId, account_id: &AccountId) -> AState; + + /// Called after a token is approved for transfer. + fn after_nft_approve( + &mut self, + token_id: &TokenId, + account_id: &AccountId, + approval_id: &ApprovalId, + state: AState, + ); + + /// Called before a token approval is revoked. + fn before_nft_revoke(&self, token_id: &TokenId, account_id: &AccountId) -> RState; + + /// Called after a token approval is revoked. + fn after_nft_revoke(&mut self, token_id: &TokenId, account_id: &AccountId, state: RState); + + /// Called before all approvals for a token are revoked. + fn before_nft_revoke_all(&self, token_id: &TokenId) -> RAState; + + /// Called after all approvals for a token are revoked. + fn after_nft_revoke_all(&mut self, token_id: &TokenId, state: RAState); +} + +// separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] +mod ext { + #![allow(missing_docs)] + + use near_sdk::PromiseOrValue; + + use super::*; + + /// NEP-178 external interface. + /// + /// See for more details. + #[near_sdk::ext_contract(ext_nep178)] + pub trait Nep178 { + fn nft_approve( + &mut self, + token_id: TokenId, + account_id: AccountId, + msg: Option, + ) -> PromiseOrValue<()>; + + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); + + fn nft_revoke_all(&mut self, token_id: TokenId); + + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool; + } + + /// NEP-178 receiver interface. + /// + /// Respond to notification that contract has been granted approval for a token. + /// + /// See for more details. + #[near_sdk::ext_contract(ext_nep178_receiver)] + pub trait Nep178Receiver { + fn nft_on_approve( + &mut self, + token_id: TokenId, + owner_id: AccountId, + approval_id: ApprovalId, + msg: String, + ); + } +} diff --git a/src/standard/nep181.rs b/src/standard/nep181.rs new file mode 100644 index 0000000..ff1fe50 --- /dev/null +++ b/src/standard/nep181.rs @@ -0,0 +1,235 @@ +//! NEP-181 non-fungible token contract metadata implementation. +//! +//! Reference: +use std::borrow::Cow; + +use near_sdk::{ + borsh::{self, BorshSerialize}, + env, + store::UnorderedSet, + AccountId, BorshStorageKey, +}; + +use crate::{slot::Slot, standard::nep171::*, DefaultStorageKey}; + +pub use ext::*; + +/// Extension hook for [`Nep171Controller`]. +pub struct TokenEnumeration; + +impl Nep171Hook for TokenEnumeration { + type MintState = (); + type NftTransferState = (); + type BurnState = (); + + fn before_mint(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} + + fn after_mint(contract: &mut C, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { + contract.add_tokens_to_enumeration(token_ids, owner_id); + } + + fn before_nft_transfer(_contract: &C, _transfer: &Nep171Transfer) {} + + fn after_nft_transfer(contract: &mut C, transfer: &Nep171Transfer, _: ()) { + let owner_id = match transfer.authorization { + Nep171TransferAuthorization::Owner => Cow::Borrowed(transfer.sender_id), + Nep171TransferAuthorization::ApprovalId(_) => Cow::Owned(contract.token_owner(transfer.token_id).unwrap_or_else(|| { + env::panic_str(&format!("Inconsistent state: Enumeration reconciliation should only run after a token has been transferred, but token {} does not exist.", transfer.token_id)) + })), + }; + + contract.transfer_token_enumeration( + std::array::from_ref(transfer.token_id), + owner_id.as_ref(), + transfer.receiver_id, + ); + } + + fn before_burn(_contract: &C, _token_ids: &[TokenId], _owner_id: &AccountId) {} + + fn after_burn(contract: &mut C, token_ids: &[TokenId], owner_id: &AccountId, _: ()) { + contract.remove_tokens_from_enumeration(token_ids, owner_id); + } +} + +#[derive(BorshSerialize, BorshStorageKey)] +enum StorageKey<'a> { + Tokens, + OwnerTokens(&'a AccountId), +} + +/// Internal functions for [`Nep181Controller`]. +pub trait Nep181ControllerInternal { + /// Storage root. + fn root() -> Slot<()> { + Slot::root(DefaultStorageKey::Nep181) + } + + /// Storage slot for all tokens. + fn slot_tokens() -> Slot> { + Self::root().field(StorageKey::Tokens) + } + + /// Storage slot for tokens owned by an account. + fn slot_owner_tokens(owner_id: &AccountId) -> Slot> { + Self::root().field(StorageKey::OwnerTokens(owner_id)) + } +} + +/// Functions for managing non-fungible tokens with attached metadata, NEP-181. +pub trait Nep181Controller { + /// Add tokens to enumeration. + /// + /// # Warning + /// + /// Does not perform consistency checks. May cause inconsistent state if + /// the same token ID is added to the enumeration multiple times. + fn add_tokens_to_enumeration(&mut self, token_ids: &[TokenId], owner_id: &AccountId); + + /// Remove tokens from enumeration. + /// + /// # Warning + /// + /// Does not perform consistency checks. May cause inconsistent state if + /// any of the token IDs are not currently enumerated (owned) by `owner_id`. + fn remove_tokens_from_enumeration(&mut self, token_ids: &[TokenId], owner_id: &AccountId); + + /// Transfer tokens between owners. + /// + /// # Warning + /// + /// Does not perform consistency checks. May cause inconsistent state if + /// any of the token IDs are not currently enumerated (owned) by + /// `from_owner_id`, or have not previously been added to enumeration via + /// [`Nep181Controller::add_tokens_to_enumeration`]. + fn transfer_token_enumeration( + &mut self, + token_ids: &[TokenId], + from_owner_id: &AccountId, + to_owner_id: &AccountId, + ); + + /// Total number of tokens in enumeration. + fn total_enumerated_tokens(&self) -> u128; + + /// Execute a function with a reference to the set of all tokens. + fn with_tokens(&self, f: impl FnOnce(&UnorderedSet) -> T) -> T; + + /// Execute a function with a reference to the set of tokens owned by an + /// account. + fn with_tokens_for_owner( + &self, + owner_id: &AccountId, + f: impl FnOnce(&UnorderedSet) -> T, + ) -> T; +} + +impl Nep181Controller for T { + fn add_tokens_to_enumeration(&mut self, token_ids: &[TokenId], owner_id: &AccountId) { + let mut all_tokens_slot = Self::slot_tokens(); + let mut all_tokens = all_tokens_slot + .read() + .unwrap_or_else(|| UnorderedSet::new(StorageKey::Tokens)); + + all_tokens.extend(token_ids.iter().cloned()); + + all_tokens_slot.write(&all_tokens); + + let mut owner_tokens_slot = Self::slot_owner_tokens(owner_id); + let mut owner_tokens = owner_tokens_slot + .read() + .unwrap_or_else(|| UnorderedSet::new(StorageKey::OwnerTokens(owner_id))); + + owner_tokens.extend(token_ids.iter().cloned()); + + owner_tokens_slot.write(&owner_tokens); + } + + fn remove_tokens_from_enumeration(&mut self, token_ids: &[TokenId], owner_id: &AccountId) { + let mut all_tokens_slot = Self::slot_tokens(); + if let Some(mut all_tokens) = all_tokens_slot.read() { + for token_id in token_ids { + all_tokens.remove(token_id); + } + all_tokens_slot.write(&all_tokens); + } + + let mut owner_tokens_slot = Self::slot_owner_tokens(owner_id); + if let Some(mut owner_tokens) = owner_tokens_slot.read() { + for token_id in token_ids { + owner_tokens.remove(token_id); + } + owner_tokens_slot.write(&owner_tokens); + } + } + + fn transfer_token_enumeration( + &mut self, + token_ids: &[TokenId], + from_owner_id: &AccountId, + to_owner_id: &AccountId, + ) { + let mut from_owner_tokens_slot = Self::slot_owner_tokens(from_owner_id); + if let Some(mut from_owner_tokens) = from_owner_tokens_slot.read() { + for token_id in token_ids { + from_owner_tokens.remove(token_id); + } + from_owner_tokens_slot.write(&from_owner_tokens); + } + + let mut to_owner_tokens_slot = Self::slot_owner_tokens(to_owner_id); + let mut to_owner_tokens = to_owner_tokens_slot + .read() + .unwrap_or_else(|| UnorderedSet::new(StorageKey::OwnerTokens(to_owner_id))); + + to_owner_tokens.extend(token_ids.iter().cloned()); + + to_owner_tokens_slot.write(&to_owner_tokens); + } + + fn total_enumerated_tokens(&self) -> u128 { + Self::slot_tokens() + .read() + .map(|tokens| tokens.len()) + .unwrap_or_default() + .into() + } + + fn with_tokens(&self, f: impl FnOnce(&UnorderedSet) -> U) -> U { + f(&Self::slot_tokens() + .read() + .unwrap_or_else(|| UnorderedSet::new(StorageKey::Tokens))) + } + + fn with_tokens_for_owner( + &self, + owner_id: &AccountId, + f: impl FnOnce(&UnorderedSet) -> U, + ) -> U { + f(&Self::slot_owner_tokens(owner_id) + .read() + .unwrap_or_else(|| UnorderedSet::new(StorageKey::OwnerTokens(owner_id)))) + } +} + +// separate module with re-export because ext_contract doesn't play well with #![warn(missing_docs)] +mod ext { + #![allow(missing_docs)] + + use near_sdk::json_types::U128; + + use super::*; + + #[near_sdk::ext_contract(ext_nep181)] + pub trait Nep181 { + fn nft_total_supply(&self) -> U128; + fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec; + fn nft_supply_for_owner(&self, account_id: AccountId) -> U128; + fn nft_tokens_for_owner( + &self, + account_id: AccountId, + from_index: Option, + limit: Option, + ) -> Vec; + } +} diff --git a/src/upgrade/mod.rs b/src/upgrade/mod.rs index da17c2d..0c5b57d 100644 --- a/src/upgrade/mod.rs +++ b/src/upgrade/mod.rs @@ -14,7 +14,10 @@ //! migrated. This behaviour can be changed by providing a //! custom [`PostUpgrade`]. //! -//! The [`raw`] module is included mostly for legacy / compatibility reasons, +//! The +#![cfg_attr(feature = "unstable", doc = "[`raw`]")] +#![cfg_attr(not(feature = "unstable"), doc = "`raw` (feature: `unstable`)")] +//! module is included mostly for legacy / compatibility reasons, //! and for the niche efficiency use-case, since it allows for the most //! efficient binary serialization (though only by a little). However, it is //! more difficult to use and has more sharp edges. diff --git a/src/utils.rs b/src/utils.rs index 8d95c92..614194f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -78,6 +78,14 @@ pub fn apply_storage_fee_and_refund( } } +/// Asserts that the attached deposit is greater than zero. +pub fn assert_nonzero_deposit() { + require!( + env::attached_deposit() > 0, + "Attached deposit must be greater than zero" + ); +} + #[cfg(test)] mod tests { use super::prefix_key; diff --git a/tests/macros/standard/mod.rs b/tests/macros/standard/mod.rs index 5569494..6685e67 100644 --- a/tests/macros/standard/mod.rs +++ b/tests/macros/standard/mod.rs @@ -1,3 +1,4 @@ pub mod fungible_token; pub mod nep141; pub mod nep148; +pub mod nep171; diff --git a/tests/macros/standard/nep171/hooks.rs b/tests/macros/standard/nep171/hooks.rs new file mode 100644 index 0000000..83215b3 --- /dev/null +++ b/tests/macros/standard/nep171/hooks.rs @@ -0,0 +1,26 @@ +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + log, near_bindgen, PanicOnDefault, +}; +use near_sdk_contract_tools::nft::*; + +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, Nep171)] +#[near_bindgen] +pub struct Contract { + transfer_count: u32, +} + +impl SimpleNep171Hook for Contract { + fn before_nft_transfer(&self, transfer: &Nep171Transfer) { + log!( + "{:?} is transferring {} to {}", + transfer.sender_id, + transfer.token_id, + transfer.receiver_id, + ); + } + + fn after_nft_transfer(&mut self, _transfer: &Nep171Transfer) { + self.transfer_count += 1; + } +} diff --git a/tests/macros/standard/nep171/manual_integration.rs b/tests/macros/standard/nep171/manual_integration.rs new file mode 100644 index 0000000..817ce96 --- /dev/null +++ b/tests/macros/standard/nep171/manual_integration.rs @@ -0,0 +1,70 @@ +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, near_bindgen, PanicOnDefault, +}; +use near_sdk_contract_tools::{ + owner::Owner, + pause::Pause, + standard::{ + nep171::*, + nep177::{self, Nep177Controller}, + nep178, nep181, + }, + Nep171, Nep177, Nep178, Nep181, Owner, Pause, +}; + +#[derive( + BorshSerialize, BorshDeserialize, PanicOnDefault, Nep171, Nep177, Nep178, Nep181, Pause, Owner, +)] +#[nep171( + extension_hooks = "(nep178::TokenApprovals, nep181::TokenEnumeration)", + check_external_transfer = "nep178::TokenApprovals", + token_data = "(nep177::TokenMetadata, nep178::TokenApprovals)" +)] +#[nep178(no_hooks)] +#[near_bindgen] +pub struct Contract { + next_token_id: u32, +} + +impl SimpleNep171Hook for Contract { + fn before_nft_transfer(&self, _transfer: &Nep171Transfer) { + Self::require_unpaused(); + } +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new() -> Self { + let mut contract = Self { next_token_id: 0 }; + + contract.set_contract_metadata(nep177::ContractMetadata::new( + "My NFT".to_string(), + "MYNFT".to_string(), + None, + )); + + Owner::init(&mut contract, &env::predecessor_account_id()); + + contract + } + + pub fn mint(&mut self) -> TokenId { + Self::require_unpaused(); + + let token_id = format!("token_{}", self.next_token_id); + self.next_token_id += 1; + Nep177Controller::mint_with_metadata( + self, + token_id.clone(), + env::predecessor_account_id(), + nep177::TokenMetadata::new() + .title(format!("Token {token_id}")) + .description(format!("This is token {token_id}.")), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Minting failed: {e}"))); + + token_id + } +} diff --git a/tests/macros/standard/nep171/mod.rs b/tests/macros/standard/nep171/mod.rs new file mode 100644 index 0000000..1da09a4 --- /dev/null +++ b/tests/macros/standard/nep171/mod.rs @@ -0,0 +1,202 @@ +#![allow(dead_code)] + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, near_bindgen, store, AccountId, +}; +use near_sdk_contract_tools::{ + standard::{ + nep171::*, + nep177::{Nep177Controller, TokenMetadata}, + }, + Nep171, NonFungibleToken, +}; + +mod hooks; +mod manual_integration; +mod no_hooks; +mod non_fungible_token; + +#[derive(BorshDeserialize, BorshSerialize, Debug, Clone, PartialEq, PartialOrd)] +struct TokenRecord { + owner_id: AccountId, + token_id: TokenId, +} + +impl From for TokenRecord { + fn from(token: Token) -> Self { + Self { + owner_id: token.owner_id, + token_id: token.token_id, + } + } +} + +#[derive(NonFungibleToken, BorshDeserialize, BorshSerialize)] +#[non_fungible_token(no_core_hooks, no_approval_hooks)] +#[near_bindgen] +struct NonFungibleTokenNoHooks { + pub before_nft_transfer_balance_record: store::Vector>, + pub after_nft_transfer_balance_record: store::Vector>, +} + +#[test] +fn t() { + let mut n = NonFungibleTokenNoHooks { + before_nft_transfer_balance_record: store::Vector::new(b"a"), + after_nft_transfer_balance_record: store::Vector::new(b"b"), + }; + + let token_id = "token1".to_string(); + + n.mint_with_metadata( + token_id.clone(), + "alice".parse().unwrap(), + TokenMetadata { + title: Some("Title".to_string()), + description: None, + media: None, + media_hash: None, + copies: None, + issued_at: None, + expires_at: None, + starts_at: None, + updated_at: None, + extra: None, + reference: None, + reference_hash: None, + }, + ) + .unwrap(); + + let nft_tok = n.nft_token(token_id); + dbg!(nft_tok); +} + +#[derive(Nep171, BorshDeserialize, BorshSerialize)] +#[near_bindgen] +struct NonFungibleToken { + pub before_nft_transfer_balance_record: store::Vector>, + pub after_nft_transfer_balance_record: store::Vector>, +} + +impl Nep171Hook for NonFungibleToken { + type NftTransferState = Option; + + fn before_nft_transfer(contract: &Self, transfer: &Nep171Transfer) -> Option { + let token = Nep171::nft_token(contract, transfer.token_id.clone()); + token.map(Into::into) + } + + fn after_nft_transfer( + contract: &mut Self, + transfer: &Nep171Transfer, + before_nft_transfer: Option, + ) { + let token = Nep171::nft_token(contract, transfer.token_id.clone()); + contract + .before_nft_transfer_balance_record + .push(before_nft_transfer); + contract + .after_nft_transfer_balance_record + .push(token.map(Into::into)); + } + + type MintState = (); + + fn before_mint(_contract: &Self, _token_ids: &[TokenId], _owner_id: &AccountId) {} + + fn after_mint(_contract: &mut Self, _token_ids: &[TokenId], _owner_id: &AccountId, _: ()) {} + + type BurnState = (); + + fn before_burn(_contract: &Self, _token_ids: &[TokenId], _owner_id: &AccountId) {} + + fn after_burn(_contract: &mut Self, _token_ids: &[TokenId], _owner_id: &AccountId, _: ()) {} +} + +#[near_bindgen] +impl NonFungibleToken { + #[init] + pub fn new() -> Self { + Self { + before_nft_transfer_balance_record: store::Vector::new(b"b"), + after_nft_transfer_balance_record: store::Vector::new(b"a"), + } + } + + pub fn mint(&mut self, token_id: TokenId, owner_id: AccountId) { + Nep171Controller::mint(self, &[token_id], &owner_id, None).unwrap_or_else(|e| { + env::panic_str(&format!("Mint failed: {e:?}")); + }); + } +} + +mod tests { + use near_sdk::{ + test_utils::{get_logs, VMContextBuilder}, + testing_env, AccountId, + }; + use near_sdk_contract_tools::standard::{nep171::Nep171, nep297::Event}; + + use super::NonFungibleToken; + + #[test] + fn hook_execution_success() { + let mut contract = NonFungibleToken::new(); + let token_id = "token1"; + let account_alice: AccountId = "alice.near".parse().unwrap(); + let account_bob: AccountId = "bob.near".parse().unwrap(); + + contract.mint(token_id.to_string(), account_alice.clone()); + + assert_eq!( + contract.before_nft_transfer_balance_record.get(0), + None, + "before_nft_transfer_balance_record should be empty", + ); + assert_eq!( + contract.after_nft_transfer_balance_record.get(0), + None, + "after_nft_transfer_balance_record should be empty", + ); + + testing_env!(VMContextBuilder::new() + .predecessor_account_id(account_alice.clone()) + .attached_deposit(1) + .build()); + + contract.nft_transfer(account_bob.clone(), token_id.to_string(), None, None); + + assert_eq!( + contract.before_nft_transfer_balance_record.get(0), + Some(&Some(super::TokenRecord { + owner_id: account_alice.clone(), + token_id: token_id.to_string(), + })), + "before_nft_transfer_balance_record should contain the token record for the original owner before transferring", + ); + assert_eq!( + contract.after_nft_transfer_balance_record.get(0), + Some(&Some(super::TokenRecord { + owner_id: account_bob.clone(), + token_id: token_id.to_string(), + })), + "after_nft_transfer_balance_record should contain the token record for the new owner after transferring", + ); + + assert_eq!( + get_logs(), + vec![ + super::Nep171Event::NftTransfer(vec![super::event::NftTransferLog { + memo: None, + authorized_id: None, + old_owner_id: account_alice.clone(), + new_owner_id: account_bob.clone(), + token_ids: vec![token_id.to_string()] + }]) + .to_event_string() + ] + ); + } +} diff --git a/tests/macros/standard/nep171/no_hooks.rs b/tests/macros/standard/nep171/no_hooks.rs new file mode 100644 index 0000000..de6e490 --- /dev/null +++ b/tests/macros/standard/nep171/no_hooks.rs @@ -0,0 +1,28 @@ +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, near_bindgen, PanicOnDefault, +}; +use near_sdk_contract_tools::nft::*; + +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, Nep171)] +#[nep171(no_hooks)] +#[near_bindgen] +pub struct Contract { + pub next_token_id: u32, +} + +#[near_bindgen] +impl Contract { + pub fn mint(&mut self) -> TokenId { + let token_id = format!("token_{}", self.next_token_id); + self.next_token_id += 1; + Nep171Controller::mint( + self, + &[token_id.clone()], + &env::predecessor_account_id(), + None, + ) + .unwrap_or_else(|e| env::panic_str(&format!("Minting failed: {e}"))); + token_id + } +} diff --git a/tests/macros/standard/nep171/non_fungible_token.rs b/tests/macros/standard/nep171/non_fungible_token.rs new file mode 100644 index 0000000..72fcb5e --- /dev/null +++ b/tests/macros/standard/nep171/non_fungible_token.rs @@ -0,0 +1,53 @@ +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, near_bindgen, PanicOnDefault, +}; +use near_sdk_contract_tools::{nft::*, owner::Owner, pause::Pause, Owner, Pause}; + +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault, NonFungibleToken, Pause, Owner)] +#[non_fungible_token(no_approval_hooks)] +#[near_bindgen] +pub struct Contract { + next_token_id: u32, +} + +impl SimpleNep171Hook for Contract { + fn before_nft_transfer(&self, _transfer: &Nep171Transfer) { + Self::require_unpaused(); + } +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new() -> Self { + let mut contract = Self { next_token_id: 0 }; + + contract.set_contract_metadata(ContractMetadata::new( + "My NFT".to_string(), + "MYNFT".to_string(), + None, + )); + + Owner::init(&mut contract, &env::predecessor_account_id()); + + contract + } + + pub fn mint(&mut self) -> TokenId { + Self::require_unpaused(); + + let token_id = format!("token_{}", self.next_token_id); + self.next_token_id += 1; + self.mint_with_metadata( + token_id.clone(), + env::predecessor_account_id(), + TokenMetadata::new() + .title(format!("Token {token_id}")) + .description(format!("This is token {token_id}.")), + ) + .unwrap_or_else(|e| env::panic_str(&format!("Minting failed: {e}"))); + + token_id + } +} diff --git a/workspaces-tests-utils/Cargo.toml b/workspaces-tests-utils/Cargo.toml new file mode 100644 index 0000000..2304f9f --- /dev/null +++ b/workspaces-tests-utils/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "workspaces-tests-utils" +version = "0.1.0" +edition = "2021" +publish = false + +[target.'cfg(not(windows))'.dependencies] +near-sdk.workspace = true +workspaces = "0.7" diff --git a/workspaces-tests-utils/src/lib.rs b/workspaces-tests-utils/src/lib.rs new file mode 100644 index 0000000..ef49adf --- /dev/null +++ b/workspaces-tests-utils/src/lib.rs @@ -0,0 +1,57 @@ +#![allow(missing_docs)] +#![cfg(not(windows))] + +use near_sdk::{serde::de::DeserializeOwned, serde_json::json}; +use workspaces::{result::ExecutionFinalResult, Account, Contract}; + +pub async fn nft_token(contract: &Contract, token_id: &str) -> Option { + contract + .view("nft_token") + .args_json(json!({ "token_id": token_id })) + .await + .unwrap() + .json::>() + .unwrap() +} + +pub struct Setup { + pub contract: Contract, + pub accounts: Vec, +} + +/// Setup for individual tests +pub async fn setup(wasm: &[u8], num_accounts: usize) -> Setup { + let worker = workspaces::sandbox().await.unwrap(); + + // Initialize contract + let contract = worker.dev_deploy(wasm).await.unwrap(); + contract.call("new").transact().await.unwrap().unwrap(); + + // Initialize user accounts + let mut accounts = vec![]; + for _ in 0..num_accounts { + accounts.push(worker.dev_create_account().await.unwrap()); + } + + Setup { contract, accounts } +} + +/// For dynamic should_panic messages +pub fn expect_execution_error(result: &ExecutionFinalResult, expected_error: impl AsRef) { + let failures = result.failures(); + + assert_eq!(failures.len(), 1); + + let actual_error_string = failures[0] + .clone() + .into_result() + .unwrap_err() + .into_inner() + .unwrap() + .to_string(); + + assert_eq!( + format!("Action #0: ExecutionError(\"{}\")", expected_error.as_ref()), + actual_error_string + ); +} diff --git a/workspaces-tests/Cargo.toml b/workspaces-tests/Cargo.toml index 308d7b0..216b1c5 100644 --- a/workspaces-tests/Cargo.toml +++ b/workspaces-tests/Cargo.toml @@ -20,6 +20,15 @@ name = "fungible_token" [[bin]] name = "native_multisig" +[[bin]] +name = "non_fungible_token_full" + +[[bin]] +name = "non_fungible_token_nep171" + +[[bin]] +name = "non_fungible_token_receiver" + [[bin]] name = "rbac" @@ -51,11 +60,11 @@ name = "upgrade_old_raw" name = "escrow" [dependencies] -near-sdk = { version = "4.1.1", default-features = false } +near-sdk.workspace = true near-sdk-contract-tools = { path = "../", features = ["unstable"] } strum = "0.24.1" strum_macros = "0.24.3" -thiserror = "1.0.34" +thiserror.workspace = true [dev-dependencies] near-crypto = "0.15.0" @@ -63,3 +72,4 @@ tokio = "1.21.1" [target.'cfg(not(windows))'.dev-dependencies] workspaces = "0.7" +workspaces-tests-utils = { path = "../workspaces-tests-utils" } diff --git a/workspaces-tests/Makefile.toml b/workspaces-tests/Makefile.toml index 3062b23..98919a8 100644 --- a/workspaces-tests/Makefile.toml +++ b/workspaces-tests/Makefile.toml @@ -2,7 +2,7 @@ clear = true script = """ rustup target add wasm32-unknown-unknown -cargo build --target wasm32-unknown-unknown --release --all +cargo build --package workspaces-tests --target wasm32-unknown-unknown --release """ [tasks.test] diff --git a/workspaces-tests/src/bin/non_fungible_token_full.rs b/workspaces-tests/src/bin/non_fungible_token_full.rs new file mode 100644 index 0000000..b3f83ab --- /dev/null +++ b/workspaces-tests/src/bin/non_fungible_token_full.rs @@ -0,0 +1,97 @@ +#![allow(missing_docs)] + +// Ignore +pub fn main() {} + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, log, near_bindgen, AccountId, PanicOnDefault, +}; +use near_sdk_contract_tools::{nft::*, NonFungibleToken}; + +#[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, NonFungibleToken)] +#[near_bindgen] +pub struct Contract {} + +impl Nep178Hook for Contract { + fn before_nft_approve(&self, token_id: &TokenId, _account_id: &AccountId) { + log!("before_nft_approve({})", token_id); + } + + fn after_nft_approve( + &mut self, + token_id: &TokenId, + _account_id: &AccountId, + _approval_id: &ApprovalId, + _state: (), + ) { + log!("after_nft_approve({})", token_id); + } + + fn before_nft_revoke(&self, token_id: &TokenId, _account_id: &AccountId) { + log!("before_nft_revoke({})", token_id); + } + + fn after_nft_revoke(&mut self, token_id: &TokenId, _account_id: &AccountId, _state: ()) { + log!("after_nft_revoke({})", token_id); + } + + fn before_nft_revoke_all(&self, token_id: &TokenId) { + log!("before_nft_revoke_all({})", token_id); + } + + fn after_nft_revoke_all(&mut self, token_id: &TokenId, _state: ()) { + log!("after_nft_revoke_all({})", token_id); + } +} + +impl SimpleNep171Hook for Contract { + fn before_nft_transfer(&self, transfer: &Nep171Transfer) { + log!("before_nft_transfer({})", transfer.token_id); + } + + fn after_nft_transfer(&mut self, transfer: &Nep171Transfer) { + log!("after_nft_transfer({})", transfer.token_id); + } +} + +#[near_sdk::near_bindgen] +impl Contract { + #[init] + pub fn new() -> Self { + let mut contract = Self {}; + + contract.set_contract_metadata(ContractMetadata::new( + "My NFT Smart Contract".to_string(), + "MNSC".to_string(), + None, + )); + + contract + } + + pub fn mint(&mut self, token_ids: Vec) { + let receiver = env::predecessor_account_id(); + for token_id in token_ids { + self.mint_with_metadata( + token_id.clone(), + receiver.clone(), + TokenMetadata { + title: Some(token_id), + description: Some("description".to_string()), + media: None, + media_hash: None, + copies: None, + issued_at: None, + expires_at: None, + starts_at: None, + updated_at: None, + extra: None, + reference: None, + reference_hash: None, + }, + ) + .unwrap_or_else(|e| env::panic_str(&format!("Failed to mint: {:#?}", e))); + } + } +} diff --git a/workspaces-tests/src/bin/non_fungible_token_nep171.rs b/workspaces-tests/src/bin/non_fungible_token_nep171.rs new file mode 100644 index 0000000..0aee4cb --- /dev/null +++ b/workspaces-tests/src/bin/non_fungible_token_nep171.rs @@ -0,0 +1,37 @@ +#![allow(missing_docs)] + +// Ignore +pub fn main() {} + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, log, near_bindgen, PanicOnDefault, +}; +use near_sdk_contract_tools::{standard::nep171::*, Nep171}; + +#[derive(PanicOnDefault, BorshSerialize, BorshDeserialize, Nep171)] +#[near_bindgen] +pub struct Contract {} + +impl SimpleNep171Hook for Contract { + fn before_nft_transfer(&self, transfer: &Nep171Transfer) { + log!("before_nft_transfer({})", transfer.token_id); + } + + fn after_nft_transfer(&mut self, transfer: &Nep171Transfer) { + log!("after_nft_transfer({})", transfer.token_id); + } +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new() -> Self { + Self {} + } + + pub fn mint(&mut self, token_ids: Vec) { + Nep171Controller::mint(self, &token_ids, &env::predecessor_account_id(), None) + .unwrap_or_else(|e| env::panic_str(&format!("Failed to mint: {:#?}", e))); + } +} diff --git a/workspaces-tests/src/bin/non_fungible_token_receiver.rs b/workspaces-tests/src/bin/non_fungible_token_receiver.rs new file mode 100644 index 0000000..bcd7cd0 --- /dev/null +++ b/workspaces-tests/src/bin/non_fungible_token_receiver.rs @@ -0,0 +1,58 @@ +#![allow(missing_docs)] + +// Ignore +pub fn main() {} + +use near_sdk::{ + borsh::{self, BorshDeserialize, BorshSerialize}, + env, log, near_bindgen, AccountId, PanicOnDefault, PromiseOrValue, +}; +use near_sdk_contract_tools::standard::nep171::{ext_nep171, *}; + +#[derive(PanicOnDefault, BorshSerialize, BorshDeserialize)] +#[near_bindgen] +pub struct Contract {} + +#[near_bindgen] +impl Nep171Receiver for Contract { + fn nft_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_id: AccountId, + token_id: TokenId, + msg: String, + ) -> PromiseOrValue { + log!( + "Received {} from {} via {}", + token_id, + previous_owner_id, + sender_id, + ); + + if msg == "panic" { + near_sdk::env::panic_str("panic requested"); + } else if let Some(account_id) = msg.strip_prefix("transfer:") { + log!("Transferring {} to {}", token_id, account_id); + return ext_nep171::ext(env::predecessor_account_id()) + .with_attached_deposit(1) + .nft_transfer(account_id.parse().unwrap(), token_id, None, None) + .then(Contract::ext(env::current_account_id()).return_true()) // ask to return the token even though we don't own it anymore + .into(); + } + + PromiseOrValue::Value(msg == "return") + } +} + +#[near_bindgen] +impl Contract { + #[init] + pub fn new() -> Self { + Self {} + } + + pub fn return_true(&self) -> bool { + log!("returning true"); + true + } +} diff --git a/workspaces-tests/tests/non_fungible_token.rs b/workspaces-tests/tests/non_fungible_token.rs new file mode 100644 index 0000000..84f2c29 --- /dev/null +++ b/workspaces-tests/tests/non_fungible_token.rs @@ -0,0 +1,1080 @@ +#![cfg(not(windows))] + +use std::collections::HashMap; + +use near_sdk::{json_types::U128, serde_json::json}; +use near_sdk_contract_tools::standard::{ + nep171::{self, event::NftTransferLog, Nep171Event, Token}, + nep177::{self, TokenMetadata}, + nep178, + nep297::Event, +}; +use tokio::task::JoinSet; +use workspaces::operations::Function; +use workspaces_tests_utils::{expect_execution_error, nft_token, setup, Setup}; + +const WASM_171_ONLY: &[u8] = + include_bytes!("../../target/wasm32-unknown-unknown/release/non_fungible_token_nep171.wasm"); + +const WASM_FULL: &[u8] = + include_bytes!("../../target/wasm32-unknown-unknown/release/non_fungible_token_full.wasm"); + +const RECEIVER_WASM: &[u8] = + include_bytes!("../../target/wasm32-unknown-unknown/release/non_fungible_token_receiver.wasm"); + +fn token_meta(id: String) -> near_sdk::serde_json::Value { + near_sdk::serde_json::to_value(TokenMetadata { + title: Some(id), + description: Some("description".to_string()), + media: None, + media_hash: None, + copies: None, + issued_at: None, + expires_at: None, + starts_at: None, + updated_at: None, + extra: None, + reference: None, + reference_hash: None, + }) + .unwrap() +} + +async fn setup_balances( + wasm: &[u8], + num_accounts: usize, + token_ids: impl Fn(usize) -> Vec, +) -> Setup { + let s = setup(wasm, num_accounts).await; + + for (i, account) in s.accounts.iter().enumerate() { + account + .call(s.contract.id(), "mint") + .args_json(json!({ "token_ids": token_ids(i) })) + .transact() + .await + .unwrap() + .unwrap(); + } + + s +} + +#[tokio::test] +async fn create_and_mint() { + let Setup { contract, accounts } = + setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + let (token_0, token_1, token_2, token_3) = tokio::join!( + nft_token(&contract, "token_0"), + nft_token(&contract, "token_1"), + nft_token(&contract, "token_2"), + nft_token(&contract, "token_3"), + ); + + // Verify minted tokens + assert_eq!( + token_0, + Some(Token { + token_id: "token_0".to_string(), + owner_id: alice.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); + assert_eq!( + token_1, + Some(Token { + token_id: "token_1".to_string(), + owner_id: bob.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); + assert_eq!( + token_2, + Some(Token { + token_id: "token_2".to_string(), + owner_id: charlie.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); + assert_eq!(token_3, None::); +} + +#[tokio::test] +async fn create_and_mint_with_metadata_and_enumeration() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + let metadata = contract + .view("nft_metadata") + .await + .unwrap() + .json::>() + .unwrap() + .unwrap(); + + assert_eq!( + metadata, + nep177::ContractMetadata { + spec: nep177::ContractMetadata::SPEC.to_string(), + name: "My NFT Smart Contract".to_string(), + symbol: "MNSC".to_string(), + icon: None, + base_uri: None, + reference: None, + reference_hash: None, + }, + ); + + let (token_0, token_1, token_2, token_3) = tokio::join!( + nft_token(&contract, "token_0"), + nft_token(&contract, "token_1"), + nft_token(&contract, "token_2"), + nft_token(&contract, "token_3"), + ); + + // Verify minted tokens + assert_eq!( + token_0, + Some(Token { + token_id: "token_0".to_string(), + owner_id: alice.id().parse().unwrap(), + extensions_metadata: [ + ("metadata".to_string(), token_meta("token_0".to_string())), + ("approved_account_ids".to_string(), json!({}),) + ] + .into(), + }), + ); + assert_eq!( + token_1, + Some(Token { + token_id: "token_1".to_string(), + owner_id: bob.id().parse().unwrap(), + extensions_metadata: [ + ("metadata".to_string(), token_meta("token_1".to_string())), + ("approved_account_ids".to_string(), json!({}),) + ] + .into(), + }), + ); + assert_eq!( + token_2, + Some(Token { + token_id: "token_2".to_string(), + owner_id: charlie.id().parse().unwrap(), + extensions_metadata: [ + ("metadata".to_string(), token_meta("token_2".to_string())), + ("approved_account_ids".to_string(), json!({}),) + ] + .into(), + }), + ); + assert_eq!(token_3, None::); + + // indeterminate order, so hashmap for equality instead of vec + let ( + all_tokens_enumeration, + all_tokens_enumeration_limit, + alice_supply, + alice_tokens_all, + alice_tokens_offset, + nonexistent_account_tokens, + ) = tokio::join!( + async { + contract + .view("nft_tokens") + .args_json(json!({})) + .await + .unwrap() + .json::>() + .unwrap() + .into_iter() + .map(|token| (token.token_id.clone(), token)) + .collect::>() + }, + async { + contract + .view("nft_tokens") + .args_json(json!({ "from_index": "0", "limit": 100 })) + .await + .unwrap() + .json::>() + .unwrap() + .into_iter() + .map(|token| (token.token_id.clone(), token)) + .collect::>() + }, + async { + contract + .view("nft_supply_for_owner") + .args_json(json!({ "account_id": alice.id() })) + .await + .unwrap() + .json::() + .unwrap() + }, + async { + contract + .view("nft_tokens_for_owner") + .args_json(json!({ "account_id": alice.id(), "limit": 100 })) + .await + .unwrap() + .json::>() + .unwrap() + }, + async { + contract + .view("nft_tokens_for_owner") + .args_json(json!({ "account_id": alice.id(), "from_index": "1" })) + .await + .unwrap() + .json::>() + .unwrap() + }, + async { + contract + .view("nft_tokens_for_owner") + .args_json(json!({ "account_id": "0000000000000000000000000000000000000000000000000000000000000000", "from_index": "1" })) + .await + .unwrap() + .json::>() + .unwrap() + }, + ); + + assert_eq!( + all_tokens_enumeration, + [ + token_0.clone().unwrap(), + token_1.clone().unwrap(), + token_2.clone().unwrap(), + ] + .into_iter() + .map(|token| (token.token_id.clone(), token)) + .collect::>(), + ); + + assert_eq!( + all_tokens_enumeration, all_tokens_enumeration_limit, + "only 3 tokens in circulation, so limit:100 should be the same as unlimited" + ); + + assert_eq!( + alice_supply.0, 1, + "alice has one token, so alice's supply (balance) should be 1" + ); + + assert_eq!( + alice_tokens_all, + vec![token_0.clone().unwrap()], + "alice has one token, so it should be returned in the list of all of alice's tokens" + ); + + assert_eq!( + alice_tokens_offset, + vec![], + "alice only has one token so an offset:1 should return empty" + ); + + assert_eq!( + nonexistent_account_tokens, + vec![], + "nonexistent account should return empty", + ); +} + +#[tokio::test] +async fn transfer_success() { + let Setup { contract, accounts } = + setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + let result = alice + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + assert_eq!( + result.logs(), + vec![ + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + old_owner_id: alice.id().parse().unwrap(), + new_owner_id: bob.id().parse().unwrap(), + authorized_id: None, + memo: None, + token_ids: vec!["token_0".to_string()], + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + ], + ); + + let (token_0, token_1, token_2) = tokio::join!( + nft_token(&contract, "token_0"), + nft_token(&contract, "token_1"), + nft_token(&contract, "token_2"), + ); + + assert_eq!( + token_0, + Some(Token { + token_id: "token_0".to_string(), + owner_id: bob.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); + assert_eq!( + token_1, + Some(Token { + token_id: "token_1".to_string(), + owner_id: bob.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); + assert_eq!( + token_2, + Some(Token { + token_id: "token_2".to_string(), + owner_id: charlie.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); +} + +#[tokio::test] +#[should_panic = "Smart contract panicked: Requires attached deposit of exactly 1 yoctoNEAR"] +async fn transfer_fail_no_deposit_full() { + transfer_fail_no_deposit(WASM_FULL).await; +} + +#[tokio::test] +#[should_panic = "Smart contract panicked: Requires attached deposit of exactly 1 yoctoNEAR"] +async fn transfer_fail_no_deposit_171() { + transfer_fail_no_deposit(WASM_171_ONLY).await; +} + +async fn transfer_fail_no_deposit(wasm: &[u8]) { + let Setup { contract, accounts } = + setup_balances(wasm, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + alice + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": bob.id(), + })) + .transact() + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +#[should_panic = "Smart contract panicked: Token `token_5` does not exist"] +async fn transfer_fail_token_dne_full() { + transfer_fail_token_dne(WASM_FULL).await; +} + +#[tokio::test] +#[should_panic = "Smart contract panicked: Token `token_5` does not exist"] +async fn transfer_fail_token_dne_171() { + transfer_fail_token_dne(WASM_171_ONLY).await; +} + +async fn transfer_fail_token_dne(wasm: &[u8]) { + let Setup { contract, accounts } = + setup_balances(wasm, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + alice + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_5", + "receiver_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn transfer_fail_not_owner_full() { + transfer_fail_not_owner(WASM_FULL).await; +} + +#[tokio::test] +async fn transfer_fail_not_owner_171() { + transfer_fail_not_owner(WASM_171_ONLY).await; +} + +async fn transfer_fail_not_owner(wasm: &[u8]) { + let Setup { contract, accounts } = + setup_balances(wasm, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + let result = alice + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_2", // charlie's token + "receiver_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + expect_execution_error( + &result, + format!( + "Smart contract panicked: Token `token_2` is owned by `{}` instead of expected `{}`", + charlie.id(), + alice.id(), + ), + ); +} + +#[tokio::test] +async fn transfer_fail_reflexive_transfer_full() { + transfer_fail_reflexive_transfer(WASM_FULL).await; +} + +#[tokio::test] +async fn transfer_fail_reflexive_transfer_171() { + transfer_fail_reflexive_transfer(WASM_171_ONLY).await; +} + +async fn transfer_fail_reflexive_transfer(wasm: &[u8]) { + let Setup { contract, accounts } = + setup_balances(wasm, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + + let result = alice + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": alice.id(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + expect_execution_error(&result, format!("Smart contract panicked: Receiver must be different from current owner `{}` to transfer token `token_0`", alice.id())); +} + +#[tokio::test] +async fn transfer_call_success() { + let Setup { contract, accounts } = + setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + bob.batch(bob.id()) + .deploy(RECEIVER_WASM) + .call(Function::new("new")) + .transact() + .await + .unwrap() + .unwrap(); + + let result = alice + .call(contract.id(), "nft_transfer_call") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": bob.id(), + "msg": "", + })) + .gas(30_000_000_000_000) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let logs = result.logs(); + + assert_eq!( + vec![ + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: alice.id().parse().unwrap(), + new_owner_id: bob.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + format!("Received token_0 from {} via {}", alice.id(), alice.id()), + ], + logs + ); + + // not returned + assert_eq!( + nft_token(&contract, "token_0").await, + Some(Token { + token_id: "token_0".to_string(), + owner_id: bob.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); +} + +#[tokio::test] +async fn transfer_call_return_success() { + let Setup { contract, accounts } = + setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + bob.batch(bob.id()) + .deploy(RECEIVER_WASM) + .call(Function::new("new")) + .transact() + .await + .unwrap() + .unwrap(); + + let result = alice + .call(contract.id(), "nft_transfer_call") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": bob.id(), + "msg": "return", + })) + .gas(30_000_000_000_000) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let logs = result.logs(); + + assert_eq!( + vec![ + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: alice.id().parse().unwrap(), + new_owner_id: bob.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + format!("Received token_0 from {} via {}", alice.id(), alice.id()), + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: bob.id().parse().unwrap(), + new_owner_id: alice.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + ], + logs + ); + + // returned + assert_eq!( + nft_token(&contract, "token_0").await, + Some(Token { + token_id: "token_0".to_string(), + owner_id: alice.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); +} + +#[tokio::test] +async fn transfer_call_receiver_panic() { + let Setup { contract, accounts } = + setup_balances(WASM_171_ONLY, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + bob.batch(bob.id()) + .deploy(RECEIVER_WASM) + .call(Function::new("new")) + .transact() + .await + .unwrap() + .unwrap(); + + let result = alice + .call(contract.id(), "nft_transfer_call") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": bob.id(), + "msg": "panic", + })) + .gas(30_000_000_000_000) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let logs = result.logs(); + + assert_eq!( + vec![ + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: alice.id().parse().unwrap(), + new_owner_id: bob.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + format!("Received token_0 from {} via {}", alice.id(), alice.id()), + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: bob.id().parse().unwrap(), + new_owner_id: alice.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + ], + logs + ); + + // returned + assert_eq!( + nft_token(&contract, "token_0").await, + Some(Token { + token_id: "token_0".to_string(), + owner_id: alice.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); +} + +#[tokio::test] +async fn transfer_call_receiver_send_return() { + let Setup { contract, accounts } = + setup_balances(WASM_171_ONLY, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + bob.batch(bob.id()) + .deploy(RECEIVER_WASM) + .call(Function::new("new")) + .transact() + .await + .unwrap() + .unwrap(); + + let result = alice + .call(contract.id(), "nft_transfer_call") + .args_json(json!({ + "token_id": "token_0", + "receiver_id": bob.id(), + "msg": format!("transfer:{}", charlie.id()), + })) + .gas(300_000_000_000_000) // xtra gas + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let logs = result.logs(); + + println!("{logs:#?}"); + + assert_eq!( + vec![ + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: alice.id().parse().unwrap(), + new_owner_id: bob.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + format!("Received token_0 from {} via {}", alice.id(), alice.id()), + format!("Transferring token_0 to {}", charlie.id()), + "before_nft_transfer(token_0)".to_string(), + Nep171Event::NftTransfer(vec![NftTransferLog { + token_ids: vec!["token_0".to_string()], + authorized_id: None, + old_owner_id: bob.id().parse().unwrap(), + new_owner_id: charlie.id().parse().unwrap(), + memo: None, + }]) + .to_event_string(), + "after_nft_transfer(token_0)".to_string(), + "returning true".to_string(), + ], + logs + ); + + // not returned + assert_eq!( + nft_token(&contract, "token_0").await, + Some(Token { + token_id: "token_0".to_string(), + owner_id: charlie.id().parse().unwrap(), + extensions_metadata: Default::default(), + }), + ); +} + +#[tokio::test] +async fn transfer_approval_success() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let view_token = nft_token::(&contract, "token_0").await; + + let expected_view_token = Token { + token_id: "token_0".into(), + owner_id: alice.id().parse().unwrap(), + extensions_metadata: [ + ("metadata".to_string(), token_meta("token_0".to_string())), + ( + "approved_account_ids".to_string(), + json!({ + bob.id().to_string(): 0, + }), + ), + ] + .into(), + }; + + assert_eq!(view_token, Some(expected_view_token)); + + let is_approved = contract + .view("nft_is_approved") + .args_json(json!({ + "token_id": "token_0", + "approved_account_id": bob.id().to_string(), + })) + .await + .unwrap() + .json::() + .unwrap(); + + assert!(is_approved); + + bob.call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "approval_id": 0, + "receiver_id": charlie.id().to_string(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + assert_eq!( + nft_token(&contract, "token_0").await, + Some(Token { + token_id: "token_0".to_string(), + owner_id: charlie.id().parse().unwrap(), + extensions_metadata: [ + ("metadata".to_string(), token_meta("token_0".to_string())), + ("approved_account_ids".to_string(), json!({})) + ] + .into(), + }), + ); +} + +#[tokio::test] +async fn transfer_approval_unapproved_fail() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 4, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + let debbie = &accounts[3]; + + alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": debbie.id(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let is_approved = contract + .view("nft_is_approved") + .args_json(json!({ + "token_id": "token_0", + "approved_account_id": bob.id().to_string(), + })) + .await + .unwrap() + .json::() + .unwrap(); + + assert!(!is_approved); + + let result = bob + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "approval_id": 0, + "receiver_id": charlie.id().to_string(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + let expected_error_message = format!( + "Smart contract panicked: {}", + nep171::error::SenderNotApprovedError { + owner_id: alice.id().parse().unwrap(), + sender_id: bob.id().parse().unwrap(), + token_id: "token_0".to_string(), + approval_id: 0, + } + ); + + expect_execution_error(&result, expected_error_message); +} + +#[tokio::test] +#[should_panic = "Attached deposit must be greater than zero"] +async fn transfer_approval_no_deposit_fail() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .transact() + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn transfer_approval_double_approval_fail() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let result = alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + let expected_error = format!( + "Smart contract panicked: {}", + nep178::Nep178ApproveError::AccountAlreadyApproved { + account_id: bob.id().parse().unwrap(), + token_id: "token_0".to_string(), + }, + ); + + expect_execution_error(&result, expected_error); +} + +#[tokio::test] +async fn transfer_approval_unauthorized_approval_fail() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + let _alice = &accounts[0]; + let bob = &accounts[1]; + + let result = bob + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + let expected_error = format!( + "Smart contract panicked: {}", + nep178::Nep178ApproveError::Unauthorized { + account_id: bob.id().parse().unwrap(), + token_id: "token_0".to_string(), + }, + ); + + expect_execution_error(&result, expected_error); +} + +#[tokio::test] +async fn transfer_approval_too_many_approvals_fail() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 2, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + + let mut set = JoinSet::new(); + + for i in 0..32 { + let contract = contract.clone(); + let alice = alice.clone(); + set.spawn(async move { + alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": format!("account_{}", i), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + }); + } + + while (set.join_next().await).is_some() {} + + let result = alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + let expected_error = format!( + "Smart contract panicked: {}", + nep178::Nep178ApproveError::TooManyApprovals { + token_id: "token_0".to_string(), + }, + ); + + expect_execution_error(&result, expected_error); +} + +#[tokio::test] +async fn transfer_approval_approved_but_wrong_approval_id_fail() { + let Setup { contract, accounts } = + setup_balances(WASM_FULL, 3, |i| vec![format!("token_{i}")]).await; + let alice = &accounts[0]; + let bob = &accounts[1]; + let charlie = &accounts[2]; + + alice + .call(contract.id(), "nft_approve") + .args_json(json!({ + "token_id": "token_0", + "account_id": bob.id(), + })) + .deposit(1) + .transact() + .await + .unwrap() + .unwrap(); + + let result = bob + .call(contract.id(), "nft_transfer") + .args_json(json!({ + "token_id": "token_0", + "approval_id": 1, + "receiver_id": charlie.id().to_string(), + })) + .deposit(1) + .transact() + .await + .unwrap(); + + let expected_error = format!( + "Smart contract panicked: {}", + nep171::Nep171TransferError::SenderNotApproved(nep171::error::SenderNotApprovedError { + sender_id: bob.id().parse().unwrap(), + owner_id: alice.id().parse().unwrap(), + token_id: "token_0".to_string(), + approval_id: 1, + }), + ); + + expect_execution_error(&result, expected_error); +} diff --git a/workspaces-tests/tests/upgrade.rs b/workspaces-tests/tests/upgrade.rs index bacf6a5..3726eb1 100644 --- a/workspaces-tests/tests/upgrade.rs +++ b/workspaces-tests/tests/upgrade.rs @@ -114,6 +114,7 @@ async fn upgrade_borsh() { } #[tokio::test] +#[ignore] async fn upgrade_jsonbase64() { // For some reason this test fails only on GitHub Actions due to a running-out-of-gas error. if std::env::var_os("GITHUB_ACTIONS").is_some() {