diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b707dd9..c921c4c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,38 +15,19 @@ jobs: steps: - name: Checkout branch uses: actions/checkout@v2 - - name: Install Node - uses: actions/setup-node@v2 - name: Install Rust uses: actions-rs/toolchain@v1 with: toolchain: stable target: wasm32-unknown-unknown - - name: Build smart contract - run: npm run build + - name: Run integration test + run: cd integration-tests && cargo run --example integration-tests - name: Run unit tests - run: npm run test:unit - integration-tests: - name: Integration tests - strategy: - matrix: - platform: [ubuntu-latest] # , windows-latest, macos-latest] - runs-on: ${{ matrix.platform }} - env: - RUST_BACKTRACE: 1 - steps: - - name: Checkout branch - uses: actions/checkout@v2 - - name: Install Node - uses: actions/setup-node@v2 - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - target: wasm32-unknown-unknown - - name: Build smart contract - run: npm run build - - name: Run Rust integration tests - run: npm run test:integration:rs - - name: Run TS integration tests - run: npm run test:integration:ts + run: cd market-contract && cargo test + run: cd ../nft-contract && cargo test + run: cd ../nft-contract-approval && cargo test + run: cd ../nft-contract-basic && cargo test + run: cd ../nft-contract-events && cargo test + run: cd ../nft-contract-royalty && cargo test + run: cd ../nft-contract-skeleton && cargo test + run: cd ../nft-series && cargo test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9ed1da1..e74ad3c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ notes # misc .DS_Store +.vscode .env.local .env.development.local .env.test.local diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml new file mode 100644 index 0000000..a9659d5 --- /dev/null +++ b/integration-tests/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nonfungible-token-integration-tests" +version = "1.0.0" +publish = false +edition = "2021" + +[dev-dependencies] +near-sdk = { version = "5.1.0", features = ["unit-testing"] } +near-workspaces = { version = "0.10.0", features = ["unstable"] } +tokio = { version = "1.12.0", features = ["full"] } +serde_json = "1" + +[[example]] +name = "integration-tests" +path = "src/tests.rs" \ No newline at end of file diff --git a/integration-tests/rs/Cargo.toml b/integration-tests/rs/Cargo.toml deleted file mode 100644 index c588ca5..0000000 --- a/integration-tests/rs/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "fungible-token-integration-tests" -version = "1.0.0" -publish = false -edition = "2018" - -[dev-dependencies] -near-sdk = "4.1.1" -anyhow = "1.0.79" -borsh = "1.3.1" -maplit = "1.0.2" -near-units = "0.2.0" -# arbitrary_precision enabled for u128 types that workspaces requires for Balance types -serde_json = { version = "1.0.113", features = ["arbitrary_precision"] } -tokio = { version = "1.36.0", features = ["full"] } -tracing = "0.1.40" -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } -workspaces = "0.7.0" -pkg-config = "0.3.29" - -[[example]] -name = "integration-tests" -path = "src/tests.rs" \ No newline at end of file diff --git a/integration-tests/rs/src/tests.rs b/integration-tests/rs/src/tests.rs deleted file mode 100644 index 6837f33..0000000 --- a/integration-tests/rs/src/tests.rs +++ /dev/null @@ -1,415 +0,0 @@ -use near_units::parse_near; -use serde_json::json; -use workspaces::prelude::*; -use workspaces::{network::Sandbox, Account, Contract, Worker}; - -mod helpers; - -const NFT_WASM_FILEPATH: &str = "../../out/main.wasm"; -const MARKET_WASM_FILEPATH: &str = "../../out/market.wasm"; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // initiate environemnt - let worker = workspaces::sandbox().await?; - - // deploy contracts - let nft_wasm = std::fs::read(NFT_WASM_FILEPATH)?; - let nft_contract = worker.dev_deploy(&nft_wasm).await?; - let market_wasm = std::fs::read(MARKET_WASM_FILEPATH)?; - let market_contract = worker.dev_deploy(&market_wasm).await?; - - // create accounts - let owner = worker.root_account(); - let alice = owner - .create_subaccount(&worker, "alice") - .initial_balance(parse_near!("30 N")) - .transact() - .await? - .into_result()?; - let bob = owner - .create_subaccount(&worker, "bob") - .initial_balance(parse_near!("30 N")) - .transact() - .await? - .into_result()?; - let charlie = owner - .create_subaccount(&worker, "charlie") - .initial_balance(parse_near!("30 N")) - .transact() - .await? - .into_result()?; - - // Initialize contracts - nft_contract - .call(&worker, "new_default_meta") - .args_json(serde_json::json!({"owner_id": owner.id()}))? - .transact() - .await?; - market_contract - .call(&worker, "new") - .args_json(serde_json::json!({"owner_id": owner.id()}))? - .transact() - .await?; - - // begin tests - test_nft_metadata_view(&owner, &nft_contract, &worker).await?; - test_nft_mint_call(&owner, &alice, &nft_contract, &worker).await?; - test_nft_approve_call(&bob, &nft_contract, &market_contract, &worker).await?; - test_nft_approve_call_long_msg_string(&alice, &nft_contract, &market_contract, &worker).await?; - test_sell_nft_listed_on_marketplace(&alice, &nft_contract, &market_contract, &bob, &worker).await?; - test_transfer_nft_when_listed_on_marketplace(&alice, &bob, &charlie, &nft_contract, &market_contract, &worker).await?; - test_approval_revoke(&alice, &bob, &nft_contract, &market_contract, &worker).await?; - test_reselling_and_royalties(&alice, &bob, &charlie, &nft_contract, &market_contract, &worker).await?; - - Ok(()) -} - -async fn test_nft_metadata_view( - owner: &Account, - contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let expected = json!({ - "base_uri": serde_json::Value::Null, - "icon": serde_json::Value::Null, - "name": "NFT Tutorial Contract", - "reference": serde_json::Value::Null, - "reference_hash": serde_json::Value::Null, - "spec": "nft-1.0.0", - "symbol": "GOTEAM", - }); - let res: serde_json::Value = owner - .call(&worker, contract.id(), "nft_metadata") - .args_json(json!({ "account_id": owner.id() }))? - .transact() - .await? - .json()?; - assert_eq!(res, expected); - println!(" Passed ✅ test_nft_metadata_view"); - Ok(()) -} - -async fn test_nft_mint_call( - owner: &Account, - user: &Account, - contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let request_payload = json!({ - "token_id": "1", - "receiver_id": user.id(), - "metadata": { - "title": "LEEROYYYMMMJENKINSSS", - "description": "Alright time's up, let's do this.", - "media": "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1" - }, - }); - - user.call(&worker, contract.id(), "nft_mint") - .args_json(request_payload)? - .deposit(parse_near!("0.008 N")) - .transact() - .await?; - - let tokens: serde_json::Value = owner - .call(&worker, contract.id(), "nft_tokens") - .args_json(serde_json::json!({}))? - .transact() - .await? - .json()?; - - let expected = json!([ - { - "approved_account_ids": {}, - "royalty": {}, - "token_id": "1", - "owner_id": user.id(), - "metadata": { - "expires_at": serde_json::Value::Null, - "extra": serde_json::Value::Null, - "issued_at": serde_json::Value::Null, - "copies": serde_json::Value::Null, - "media_hash": serde_json::Value::Null, - "reference": serde_json::Value::Null, - "reference_hash": serde_json::Value::Null, - "starts_at": serde_json::Value::Null, - "updated_at": serde_json::Value::Null, - "title": "LEEROYYYMMMJENKINSSS", - "description": "Alright time's up, let's do this.", - "media": "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1" - } - } - ]); - - assert_eq!(tokens, expected); - println!(" Passed ✅ test_nft_mint_call"); - Ok(()) -} - -async fn test_nft_approve_call( - user: &Account, - nft_contract: &Contract, - market_contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let token_id = "2"; - helpers::mint_nft(user, nft_contract, worker, token_id).await?; - helpers::approve_nft(market_contract, user, nft_contract, worker, token_id).await?; - - let view_payload = json!({ - "token_id": token_id, - "approved_account_id": market_contract.id(), - }); - let result: bool = user - .call(&worker, nft_contract.id(), "nft_is_approved") - .args_json(view_payload)? - .transact() - .await? - .json()?; - - assert_eq!(result, true); - println!(" Passed ✅ test_nft_approve_call"); - Ok(()) -} - -async fn test_nft_approve_call_long_msg_string( - user: &Account, - nft_contract: &Contract, - market_contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let token_id = "3"; - helpers::mint_nft(user, nft_contract, worker, token_id).await?; - helpers::pay_for_storage(user, market_contract, worker, 10000000000000000000000).await?; - - let approve_payload = json!({ - "token_id": token_id, - "account_id": market_contract.id(), - "msg": "sample message".repeat(10240), - }); - - match user.call(&worker, nft_contract.id(), "nft_approve") - .args_json(approve_payload)? - .deposit(helpers::DEFAULT_DEPOSIT) - .gas(helpers::DEFAULT_GAS as u64) - .transact() - .await - { - Ok(_result) => { - panic!("test_nft_approve_call_long_msg_string worked despite insufficient gas") - } - Err(e) => { - let e_string = e.to_string(); - if !e_string - .contains("Not valid SaleArgs") - { - panic!("test_nft_approve_call_long_msg_string displays unexpected error message: {:?}", e_string); - } - - let view_payload = json!({ - "token_id": token_id, - "approved_account_id": market_contract.id(), - }); - let result: bool = user - .call(&worker, nft_contract.id(), "nft_is_approved") - .args_json(view_payload)? - .transact() - .await? - .json()?; - - assert_eq!(result, true); - println!(" Passed ✅ test_nft_approve_call_long_msg_string"); - } - } - Ok(()) -} - -async fn test_sell_nft_listed_on_marketplace( - seller: &Account, - nft_contract: &Contract, - market_contract: &Contract, - buyer: &Account, - worker: &Worker, -) -> anyhow::Result<()> { - let token_id = "4"; - let sale_price = 300000000000000000000000 as u128; // 0.3 NEAR in yoctoNEAR - helpers::mint_nft(seller, nft_contract, worker, token_id).await?; - helpers::pay_for_storage(seller, market_contract, worker, 10000000000000000000000 as u128).await?; - helpers::place_nft_for_sale(seller, market_contract, nft_contract, worker, token_id, sale_price).await?; - - let before_seller_balance: u128 = helpers::get_user_balance(seller, worker).await?; - let before_buyer_balance: u128 = helpers::get_user_balance(buyer, worker).await?; - helpers::purchase_listed_nft(buyer, market_contract, nft_contract, worker, token_id, sale_price).await?; - let after_seller_balance: u128 = helpers::get_user_balance(seller, worker).await?; - let after_buyer_balance: u128 = helpers::get_user_balance(buyer, worker).await?; - - let dp = 1; // being exact requires keeping track of gas usage - assert_eq!(helpers::round_to_near_dp(after_seller_balance, dp), helpers::round_to_near_dp(before_seller_balance + sale_price, dp), "seller did not receive the sale price"); - assert_eq!(helpers::round_to_near_dp(after_buyer_balance, dp), helpers::round_to_near_dp(before_buyer_balance - sale_price, dp), "buyer did not receive the sale price"); - - println!(" Passed ✅ test_sell_nft_listed_on_marketplace"); - Ok(()) -} - -async fn test_transfer_nft_when_listed_on_marketplace( - seller: &Account, - first_buyer: &Account, - second_buyer: &Account, - nft_contract: &Contract, - market_contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let token_id = "5"; - let sale_price = 3000000000000000000000000 as u128; // 3 NEAR in yoctoNEAR - helpers::mint_nft(seller, nft_contract, worker, token_id).await?; - helpers::pay_for_storage(seller, market_contract, worker, 10000000000000000000000 as u128).await?; - helpers::place_nft_for_sale(seller, market_contract, nft_contract, worker, token_id, sale_price).await?; - - helpers::transfer_nft(seller, first_buyer, nft_contract, worker, token_id).await?; - - // attempt purchase NFT - let before_seller_balance: u128 = helpers::get_user_balance(seller, worker).await?; - let before_buyer_balance: u128 = helpers::get_user_balance(second_buyer, worker).await?; - helpers::purchase_listed_nft(second_buyer, market_contract, nft_contract, worker, token_id, sale_price).await?; - let after_seller_balance: u128 = helpers::get_user_balance(seller, worker).await?; - let after_buyer_balance: u128 = helpers::get_user_balance(second_buyer, worker).await?; - - // assert owner remains first_buyer - let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, worker, token_id).await?; - let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); - assert_eq!(owner_id, first_buyer.id().to_string(), "token owner is not first_buyer"); - - // assert balances remain equal - let dp = 1; - assert_eq!(helpers::round_to_near_dp(after_seller_balance, dp), helpers::round_to_near_dp(before_seller_balance, dp), "seller balance changed"); - assert_eq!(helpers::round_to_near_dp(after_buyer_balance, dp), helpers::round_to_near_dp(before_buyer_balance, dp), "buyer balance changed"); - - println!(" Passed ✅ test_transfer_nft_when_listed_on_marketplace"); - - Ok(()) -} - -async fn test_approval_revoke( - first_user: &Account, - second_user: &Account, - nft_contract: &Contract, - market_contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let token_id = "6"; - let sale_price = 3000000000000000000000000 as u128; // 3 NEAR in yoctoNEAR - helpers::mint_nft(first_user, nft_contract, worker, token_id).await?; - helpers::pay_for_storage(first_user, market_contract, worker, 10000000000000000000000 as u128).await?; - helpers::place_nft_for_sale(first_user, market_contract, nft_contract, worker, token_id, sale_price).await?; - - // nft_revoke market_contract call - let revoke_payload = json!({ - "account_id": market_contract.id(), - "token_id": token_id, - }); - first_user.call(&worker, nft_contract.id(), "nft_revoke") - .args_json(revoke_payload)? - .deposit(1) - .transact() - .await?; - - // market_contract attempts to nft_transfer, when second_user tries to purchase NFT on market - let before_seller_balance: u128 = helpers::get_user_balance(first_user, worker).await?; - let before_buyer_balance: u128 = helpers::get_user_balance(second_user, worker).await?; - helpers::purchase_listed_nft( - second_user, market_contract, nft_contract, worker, token_id, sale_price - ).await?; - let after_seller_balance: u128 = helpers::get_user_balance(first_user, worker).await?; - let after_buyer_balance: u128 = helpers::get_user_balance(second_user, worker).await?; - - // assert owner remains first_user - let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, worker, token_id).await?; - let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); - assert_eq!(owner_id, first_user.id().to_string(), "token owner is not first_user"); - - // assert balances unchanged - assert_eq!(helpers::round_to_near_dp(after_seller_balance, 0), helpers::round_to_near_dp(before_seller_balance, 0), "seller balance changed"); - assert_eq!(helpers::round_to_near_dp(after_buyer_balance, 0), helpers::round_to_near_dp(before_buyer_balance, 0), "buyer balance changed"); - - println!(" Passed ✅ test_approval_revoke"); - Ok(()) -} - -async fn test_reselling_and_royalties( - user: &Account, - first_buyer: &Account, - second_buyer: &Account, - nft_contract: &Contract, - market_contract: &Contract, - worker: &Worker, -) -> anyhow::Result<()> { - let token_id = "7"; - let sale_price = 3000000000000000000000000 as u128; // 3 NEAR in yoctoNEAR - - // mint with royalties - let request_payload = json!({ - "token_id": token_id, - "receiver_id": user.id(), - "metadata": { - "title": "Grumpy Cat", - "description": "Not amused.", - "media": "https://www.adamsdrafting.com/wp-content/uploads/2018/06/More-Grumpy-Cat.jpg" - }, - "perpetual_royalties": { - user.id().to_string(): 2000 as u128 - } - }); - user.call(&worker, nft_contract.id(), "nft_mint") - .args_json(request_payload)? - .deposit(helpers::DEFAULT_DEPOSIT) - .transact() - .await?; - - helpers::pay_for_storage(user, market_contract, worker, 10000000000000000000000 as u128).await?; - helpers::place_nft_for_sale(user, market_contract, nft_contract, worker, token_id, sale_price).await?; - - // first_buyer purchases NFT - let mut before_seller_balance: u128 = helpers::get_user_balance(user, worker).await?; - let mut before_buyer_balance: u128 = helpers::get_user_balance(first_buyer, worker).await?; - helpers::purchase_listed_nft(first_buyer, market_contract, nft_contract, worker, token_id, sale_price).await?; - let mut after_seller_balance: u128 = helpers::get_user_balance(user, worker).await?; - let mut after_buyer_balance: u128 = helpers::get_user_balance(first_buyer, worker).await?; - - // assert owner becomes first_buyer - let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, worker, token_id).await?; - let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); - assert_eq!(owner_id, first_buyer.id().to_string(), "token owner is not first_buyer"); - - // assert balances changed - assert_eq!(helpers::round_to_near_dp(after_seller_balance, 0), helpers::round_to_near_dp(before_seller_balance + sale_price, 0), "seller balance unchanged"); - assert_eq!(helpers::round_to_near_dp(after_buyer_balance, 0), helpers::round_to_near_dp(before_buyer_balance - sale_price, 0), "buyer balance unchanged"); - - // first buyer lists nft for sale - helpers::pay_for_storage(first_buyer, market_contract, worker, 10000000000000000000000 as u128).await?; - helpers::place_nft_for_sale(first_buyer, market_contract, nft_contract, worker, token_id, sale_price).await?; - - // second_buyer purchases NFT - let resale_price = sale_price * 5; // 15 NEAR - before_seller_balance = helpers::get_user_balance(first_buyer, worker).await?; - before_buyer_balance = helpers::get_user_balance(second_buyer, worker).await?; - let before_user_balance: u128 = helpers::get_user_balance(user, worker).await?; - helpers::purchase_listed_nft(second_buyer, market_contract, nft_contract, worker, token_id, resale_price).await?; - let after_user_balance: u128 = helpers::get_user_balance(user, worker).await?; - after_seller_balance = helpers::get_user_balance(first_buyer, worker).await?; - after_buyer_balance = helpers::get_user_balance(second_buyer, worker).await?; - - // assert owner changes to second_buyer - let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, worker, token_id).await?; - let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); - assert_eq!(owner_id, second_buyer.id().to_string(), "token owner is not second_buyer"); - - // assert balances changed - let royalty_fee = resale_price / 5; - assert_eq!(helpers::round_to_near_dp(after_seller_balance, 0), helpers::round_to_near_dp(before_seller_balance + resale_price - royalty_fee, 0), "seller balance unchanged"); - assert_eq!(helpers::round_to_near_dp(after_buyer_balance, 0), helpers::round_to_near_dp(before_buyer_balance - resale_price, 0), "buyer balance unchanged"); - assert_eq!(helpers::round_to_near_dp(after_user_balance, 0), helpers::round_to_near_dp(before_user_balance + royalty_fee, 0), "user balance unchanged"); - - println!(" Passed ✅ test_reselling_and_royalties"); - Ok(()) -} diff --git a/integration-tests/rust-toolchain.toml b/integration-tests/rust-toolchain.toml new file mode 100644 index 0000000..97c2073 --- /dev/null +++ b/integration-tests/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] \ No newline at end of file diff --git a/integration-tests/rs/src/helpers.rs b/integration-tests/src/helpers.rs similarity index 53% rename from integration-tests/rs/src/helpers.rs rename to integration-tests/src/helpers.rs index 77665ce..9be15c4 100644 --- a/integration-tests/rs/src/helpers.rs +++ b/integration-tests/src/helpers.rs @@ -1,15 +1,14 @@ use serde_json::json; -use workspaces::{network::Sandbox, Account, Contract, Worker, AccountDetails}; +use near_workspaces::{types::{NearToken, AccountDetails}, Account, Contract}; -pub const DEFAULT_DEPOSIT: u128 = 6760000000000000000000 as u128; -pub const DEFAULT_GAS: u128 = 300000000000000 as u128; +pub const DEFAULT_DEPOSIT: u128 = 10000000000000000000000 as u128; +pub const ONE_YOCTO_NEAR: NearToken = NearToken::from_yoctonear(1); pub async fn mint_nft( user: &Account, nft_contract: &Contract, - worker: &Worker, token_id: &str, -) -> anyhow::Result<()> { +) -> Result<(), Box> { let request_payload = json!({ "token_id": token_id, "receiver_id": user.id(), @@ -20,11 +19,11 @@ pub async fn mint_nft( }, }); - user.call(&worker, nft_contract.id(), "nft_mint") - .args_json(request_payload)? - .deposit(DEFAULT_DEPOSIT) + let _ = user.call(nft_contract.id(), "nft_mint") + .args_json(request_payload) + .deposit(NearToken::from_yoctonear(DEFAULT_DEPOSIT)) .transact() - .await?; + .await; Ok(()) } @@ -33,20 +32,19 @@ pub async fn approve_nft( market_contract: &Contract, user: &Account, nft_contract: &Contract, - worker: &Worker, token_id: &str, -) -> anyhow::Result<()> { +) -> Result<(), Box> { let request_payload = json!({ "token_id": token_id, "account_id": market_contract.id(), "msg": serde_json::Value::Null, }); - user.call(&worker, nft_contract.id(), "nft_approve") - .args_json(request_payload)? - .deposit(DEFAULT_DEPOSIT) + let _ = user.call(nft_contract.id(), "nft_approve") + .args_json(request_payload) + .deposit(NearToken::from_yoctonear(DEFAULT_DEPOSIT)) .transact() - .await?; + .await; Ok(()) } @@ -54,16 +52,15 @@ pub async fn approve_nft( pub async fn pay_for_storage( user: &Account, market_contract: &Contract, - worker: &Worker, - amount: u128, -) -> anyhow::Result<()> { + amount: NearToken, +) -> Result<(), Box> { let request_payload = json!({}); - user.call(&worker, market_contract.id(), "storage_deposit") - .args_json(request_payload)? + let _ = user.call(market_contract.id(), "storage_deposit") + .args_json(request_payload) .deposit(amount) .transact() - .await?; + .await; Ok(()) } @@ -72,52 +69,51 @@ pub async fn place_nft_for_sale( user: &Account, market_contract: &Contract, nft_contract: &Contract, - worker: &Worker, token_id: &str, - price: u128, -) -> anyhow::Result<()> { - let request_payload = json!({ + approval_id: u128, + price: &NearToken, +) -> Result<(), Box> { + let request_payload = json!({ + "nft_contract_id": nft_contract.id(), "token_id": token_id, - "account_id": market_contract.id(), - "msg": format!(r#"{{ "sale_conditions" : "{}" }}"#, price.to_string()), + "approval_id": approval_id, + "sale_conditions": NearToken::as_yoctonear(price).to_string(), }); - - user.call(&worker, nft_contract.id(), "nft_approve") - .args_json(request_payload)? - .deposit(DEFAULT_DEPOSIT) + let _ = user.call(market_contract.id(), "list_nft_for_sale") + .args_json(request_payload) + .max_gas() + .deposit(NearToken::from_yoctonear(DEFAULT_DEPOSIT)) .transact() - .await?; + .await; Ok(()) } pub async fn get_user_balance( user: &Account, - worker: &Worker, -) -> anyhow::Result { - let details: AccountDetails = user.view_account(worker).await?; - Ok(details.balance) +) -> NearToken { + let details: AccountDetails = user.view_account().await.expect("Account has to have some balance"); + details.balance } pub async fn purchase_listed_nft( bidder: &Account, market_contract: &Contract, nft_contract: &Contract, - worker: &Worker, token_id: &str, - offer_price: u128 -) -> anyhow::Result<()> { + offer_price: NearToken +) -> Result<(), Box> { let request_payload = json!({ "token_id": token_id, "nft_contract_id": nft_contract.id(), }); - bidder.call(&worker, market_contract.id(), "offer") - .args_json(request_payload)? - .gas(DEFAULT_GAS as u64) + let _ = bidder.call(market_contract.id(), "offer") + .args_json(request_payload) + .max_gas() .deposit(offer_price) .transact() - .await?; + .await; Ok(()) } @@ -126,36 +122,35 @@ pub async fn transfer_nft( sender: &Account, receiver: &Account, nft_contract: &Contract, - worker: &Worker, token_id: &str, -) -> anyhow::Result<()> { +) -> Result<(), Box> { let request_payload = json!({ "token_id": token_id, "receiver_id": receiver.id(), "approval_id": 1 as u64, }); - sender.call(&worker, nft_contract.id(), "nft_transfer") - .args_json(request_payload)? - .gas(DEFAULT_GAS as u64) - .deposit(1) + let _ = sender.call(nft_contract.id(), "nft_transfer") + .args_json(request_payload) + .max_gas() + .deposit(ONE_YOCTO_NEAR) .transact() - .await?; + .await; Ok(()) } pub async fn get_nft_token_info( nft_contract: &Contract, - worker: &Worker, token_id: &str, -) -> anyhow::Result { +) -> Result> { let token_info: serde_json::Value = nft_contract - .call(&worker, "nft_token") - .args_json(json!({"token_id": token_id}))? + .call("nft_token") + .args_json(json!({"token_id": token_id})) .transact() .await? - .json()?; + .json() + .unwrap(); Ok(token_info) } @@ -166,4 +161,4 @@ pub fn round_to_near_dp( ) -> String { let near_amount = amount as f64 / 1_000_000_000_000_000_000_000_000.0; // yocto in 1 NEAR return format!("{:.1$}", near_amount, sf as usize); -} +} \ No newline at end of file diff --git a/integration-tests/src/tests.rs b/integration-tests/src/tests.rs new file mode 100644 index 0000000..3021568 --- /dev/null +++ b/integration-tests/src/tests.rs @@ -0,0 +1,367 @@ +use near_workspaces::{types::NearToken, Account, Contract}; +use serde_json::json; + +mod helpers; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // initiate environemnt + let worker = near_workspaces::sandbox().await?; + + // deploy contracts + let nft_wasm = near_workspaces::compile_project("../nft-contract-royalty/.").await?; + let nft_contract = worker.dev_deploy(&nft_wasm).await?; + + let market_wasm = near_workspaces::compile_project("../market-contract/.").await?; + let market_contract = worker.dev_deploy(&market_wasm).await?; + + // create accounts + let owner = worker.root_account().unwrap(); + let alice = owner + .create_subaccount("alice") + .initial_balance(NearToken::from_near(30)) + .transact() + .await? + .into_result()?; + let bob = owner + .create_subaccount("bob") + .initial_balance(NearToken::from_near(30)) + .transact() + .await? + .into_result()?; + let charlie = owner + .create_subaccount("charlie") + .initial_balance(NearToken::from_near(30)) + .transact() + .await? + .into_result()?; + + // Initialize contracts + let _ = nft_contract + .call("new_default_meta") + .args_json(serde_json::json!({"owner_id": owner.id()})) + .transact() + .await?; + let _ = market_contract + .call("new") + .args_json(serde_json::json!({"owner_id": owner.id()})) + .transact() + .await?; + + // begin tests + test_nft_metadata_view(&owner, &nft_contract).await?; + test_nft_mint_call(&owner, &alice, &nft_contract).await?; + test_nft_approve_call(&bob, &nft_contract, &market_contract).await?; + test_sell_nft_listed_on_marketplace(&alice, &nft_contract, &market_contract, &bob).await?; + test_transfer_nft_when_listed_on_marketplace(&alice, &bob, &charlie, &nft_contract, &market_contract).await?; + test_approval_revoke(&alice, &bob, &nft_contract, &market_contract).await?; + test_reselling_and_royalties(&alice, &bob, &charlie, &nft_contract, &market_contract).await?; + + Ok(()) +} + +async fn test_nft_metadata_view( + owner: &Account, + contract: &Contract, +) -> Result<(), Box> { + let expected = json!({ + "base_uri": serde_json::Value::Null, + "icon": serde_json::Value::Null, + "name": "NFT Tutorial Contract", + "reference": serde_json::Value::Null, + "reference_hash": serde_json::Value::Null, + "spec": "nft-1.0.0", + "symbol": "GOTEAM", + }); + let res: serde_json::Value = owner + .call(contract.id(), "nft_metadata") + .args_json(json!({ "account_id": owner.id() })) + .transact() + .await? + .json()?; + assert_eq!(res, expected); + println!(" Passed ✅ test_nft_metadata_view"); + Ok(()) +} + +async fn test_nft_mint_call( + owner: &Account, + user: &Account, + contract: &Contract, +) -> Result<(), Box> { + let request_payload = json!({ + "token_id": "1", + "receiver_id": user.id(), + "metadata": { + "title": "LEEROYYYMMMJENKINSSS", + "description": "Alright time's up, let's do this.", + "media": "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1" + }, + }); + + let _ = user.call(contract.id(), "nft_mint") + .args_json(request_payload) + .deposit(NearToken::from_millinear(80)) + .transact() + .await; + + let tokens: serde_json::Value = owner + .call(contract.id(), "nft_tokens") + .args_json(serde_json::json!({})) + .transact() + .await? + .json()?; + + let expected = json!([ + { + "approved_account_ids": {}, + "royalty": {}, + "token_id": "1", + "owner_id": user.id(), + "metadata": { + "expires_at": serde_json::Value::Null, + "extra": serde_json::Value::Null, + "issued_at": serde_json::Value::Null, + "copies": serde_json::Value::Null, + "media_hash": serde_json::Value::Null, + "reference": serde_json::Value::Null, + "reference_hash": serde_json::Value::Null, + "starts_at": serde_json::Value::Null, + "updated_at": serde_json::Value::Null, + "title": "LEEROYYYMMMJENKINSSS", + "description": "Alright time's up, let's do this.", + "media": "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1" + } + } + ]); + + assert_eq!(tokens, expected); + println!(" Passed ✅ test_nft_mint_call"); + Ok(()) +} + +async fn test_nft_approve_call( + user: &Account, + nft_contract: &Contract, + market_contract: &Contract, +) -> Result<(), Box> { + let token_id = "2"; + helpers::mint_nft(user, nft_contract, token_id).await?; + helpers::approve_nft(market_contract, user, nft_contract, token_id).await?; + + let view_payload = json!({ + "token_id": token_id, + "approved_account_id": market_contract.id(), + }); + let result: bool = user + .call(nft_contract.id(), "nft_is_approved") + .args_json(view_payload) + .transact() + .await? + .json()?; + + assert_eq!(result, true); + println!(" Passed ✅ test_nft_approve_call"); + Ok(()) +} + +async fn test_sell_nft_listed_on_marketplace( + seller: &Account, + nft_contract: &Contract, + market_contract: &Contract, + buyer: &Account, +) -> Result<(), Box> { + let token_id = "4"; + let approval_id = 0; + let sale_price: NearToken = NearToken::from_yoctonear(10000000000000000000000000); + + helpers::mint_nft(seller, nft_contract, token_id).await?; + helpers::pay_for_storage(seller, market_contract, NearToken::from_yoctonear(1000000000000000000000000)).await?; + helpers::approve_nft(market_contract, seller, nft_contract, token_id).await?; + helpers::place_nft_for_sale(seller, market_contract, nft_contract, token_id, approval_id, &sale_price).await?; + + let before_seller_balance: NearToken = helpers::get_user_balance(seller).await; + let before_buyer_balance: NearToken = helpers::get_user_balance(buyer).await; + + helpers::purchase_listed_nft(buyer, market_contract, nft_contract, token_id, sale_price).await?; + + let after_seller_balance: NearToken = helpers::get_user_balance(seller).await; + let after_buyer_balance: NearToken = helpers::get_user_balance(buyer).await; + + let dp = 1; // being exact requires keeping track of gas usage + assert_eq!(helpers::round_to_near_dp(after_seller_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_seller_balance.saturating_add(sale_price).as_yoctonear(), dp), "seller did not receive the sale price"); + assert_eq!(helpers::round_to_near_dp(after_buyer_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_buyer_balance.saturating_sub(sale_price).as_yoctonear(), dp), "buyer did not send the sale price"); + + println!(" Passed ✅ test_sell_nft_listed_on_marketplace"); + Ok(()) +} + +async fn test_transfer_nft_when_listed_on_marketplace( + seller: &Account, + first_buyer: &Account, + second_buyer: &Account, + nft_contract: &Contract, + market_contract: &Contract, +) -> Result<(), Box> { + let token_id = "5"; + let approval_id = 0; + let sale_price = NearToken::from_near(3); + + helpers::mint_nft(seller, nft_contract, token_id).await?; + helpers::pay_for_storage(seller, market_contract, NearToken::from_millinear(10)).await?; + helpers::approve_nft(market_contract, seller, nft_contract, token_id).await?; + helpers::place_nft_for_sale(seller, market_contract, nft_contract, token_id, approval_id, &sale_price).await?; + + helpers::transfer_nft(seller, first_buyer, nft_contract, token_id).await?; + + // attempt purchase NFT + let before_seller_balance: NearToken = helpers::get_user_balance(seller).await; + let before_buyer_balance: NearToken = helpers::get_user_balance(second_buyer).await; + helpers::purchase_listed_nft(second_buyer, market_contract, nft_contract, token_id, sale_price).await?; + let after_seller_balance: NearToken = helpers::get_user_balance(seller).await; + let after_buyer_balance: NearToken = helpers::get_user_balance(second_buyer).await; + + // assert owner remains first_buyer + let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, token_id).await?; + let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); + assert_eq!(owner_id, first_buyer.id().to_string(), "token owner is not first_buyer"); + + // assert balances remain equal + let dp = 1; // being exact requires keeping track of gas usage + assert_eq!(helpers::round_to_near_dp(after_seller_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_seller_balance.as_yoctonear(), dp), "seller balance changed"); + assert_eq!(helpers::round_to_near_dp(after_buyer_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_buyer_balance.as_yoctonear(), dp), "buyer balance changed"); + + println!(" Passed ✅ test_transfer_nft_when_listed_on_marketplace"); + + Ok(()) +} + +async fn test_approval_revoke( + first_user: &Account, + second_user: &Account, + nft_contract: &Contract, + market_contract: &Contract, +) -> Result<(), Box> { + let token_id = "6"; + let approval_id = 0; + let sale_price = NearToken::from_near(3); + + helpers::mint_nft(first_user, nft_contract, token_id).await?; + helpers::pay_for_storage(first_user, market_contract, NearToken::from_millinear(10)).await?; + helpers::place_nft_for_sale(first_user, market_contract, nft_contract, token_id, approval_id, &sale_price).await?; + + // nft_revoke market_contract call + let revoke_payload = json!({ + "account_id": market_contract.id(), + "token_id": token_id, + }); + let _ = first_user.call(nft_contract.id(), "nft_revoke") + .args_json(revoke_payload) + .deposit(helpers::ONE_YOCTO_NEAR) + .transact() + .await?; + + // market_contract attempts to nft_transfer, when second_user tries to purchase NFT on market + let before_seller_balance: NearToken = helpers::get_user_balance(first_user).await; + let before_buyer_balance: NearToken = helpers::get_user_balance(second_user).await; + helpers::purchase_listed_nft( + second_user, market_contract, nft_contract, token_id, sale_price + ).await?; + let after_seller_balance: NearToken = helpers::get_user_balance(first_user).await; + let after_buyer_balance: NearToken = helpers::get_user_balance(second_user).await; + + // assert owner remains first_user + let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, token_id).await?; + let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); + assert_eq!(owner_id, first_user.id().to_string(), "token owner is not first_user"); + + // assert balances unchanged + let dp = 1; // being exact requires keeping track of gas usage + assert_eq!(helpers::round_to_near_dp(after_seller_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_seller_balance.as_yoctonear(), dp), "seller balance changed"); + assert_eq!(helpers::round_to_near_dp(after_buyer_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_buyer_balance.as_yoctonear(), dp), "buyer balance changed"); + + println!(" Passed ✅ test_approval_revoke"); + Ok(()) +} + +async fn test_reselling_and_royalties( + user: &Account, + first_buyer: &Account, + second_buyer: &Account, + nft_contract: &Contract, + market_contract: &Contract, +) -> Result<(), Box> { + let token_id = "7"; + let approval_id = 0; + let sale_price: NearToken = NearToken::from_near(1); // 1 NEAR in yoctoNEAR + + // mint with royalties + let request_payload = json!({ + "token_id": token_id, + "receiver_id": user.id(), + "metadata": { + "title": "Grumpy Cat", + "description": "Not amused.", + "media": "https://www.adamsdrafting.com/wp-content/uploads/2018/06/More-Grumpy-Cat.jpg" + }, + "perpetual_royalties": { + user.id().to_string(): 2000 as u128 + } + }); + let _ = user.call(nft_contract.id(), "nft_mint") + .args_json(request_payload) + .deposit(NearToken::from_yoctonear(helpers::DEFAULT_DEPOSIT)) + .transact() + .await; + + helpers::pay_for_storage(user, market_contract, NearToken::from_millinear(10)).await?; + helpers::approve_nft(market_contract, user, nft_contract, token_id).await?; + helpers::place_nft_for_sale(user, market_contract, nft_contract, token_id, approval_id, &sale_price).await?; + + // first_buyer purchases NFT + let mut before_seller_balance: NearToken = helpers::get_user_balance(user).await; + let mut before_buyer_balance: NearToken = helpers::get_user_balance(first_buyer).await; + helpers::purchase_listed_nft(first_buyer, market_contract, nft_contract, token_id, sale_price).await?; + let mut after_seller_balance: NearToken = helpers::get_user_balance(user).await; + let mut after_buyer_balance: NearToken = helpers::get_user_balance(first_buyer).await; + + // assert owner becomes first_buyer + let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, token_id).await?; + let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); + assert_eq!(owner_id, first_buyer.id().to_string(), "token owner is not first_buyer"); + + // assert balances changed + let dp = 1; // being exact requires keeping track of gas usage + assert_eq!(helpers::round_to_near_dp(after_seller_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_seller_balance.saturating_add(sale_price).as_yoctonear(), dp), "seller balance unchanged"); + assert_eq!(helpers::round_to_near_dp(after_buyer_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_buyer_balance.saturating_sub(sale_price).as_yoctonear(), dp), "buyer balance unchanged"); + + // first buyer lists nft for sale + let approval_id = 1; + helpers::pay_for_storage(first_buyer, market_contract, NearToken::from_millinear(10)).await?; + helpers::approve_nft(market_contract, first_buyer, nft_contract, token_id).await?; + helpers::place_nft_for_sale(first_buyer, market_contract, nft_contract, token_id, approval_id, &sale_price).await?; + + // second_buyer purchases NFT + let resale_price = sale_price.saturating_mul(5); // 15 NEAR + before_seller_balance = helpers::get_user_balance(first_buyer).await; + before_buyer_balance = helpers::get_user_balance(second_buyer).await; + let before_user_balance: NearToken = helpers::get_user_balance(user).await; + helpers::purchase_listed_nft(second_buyer, market_contract, nft_contract, token_id, resale_price).await?; + let after_user_balance: NearToken = helpers::get_user_balance(user).await; + after_seller_balance = helpers::get_user_balance(first_buyer).await; + after_buyer_balance = helpers::get_user_balance(second_buyer).await; + + // assert owner changes to second_buyer + let token_info: serde_json::Value = helpers::get_nft_token_info(nft_contract, token_id).await?; + let owner_id: String = token_info["owner_id"].as_str().unwrap().to_string(); + assert_eq!(owner_id, second_buyer.id().to_string(), "token owner is not second_buyer"); + + // assert balances changed + let royalty_fee = resale_price.saturating_div(5); + assert_eq!(helpers::round_to_near_dp(after_seller_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_seller_balance.saturating_add(resale_price).saturating_sub(royalty_fee).as_yoctonear(), dp), "seller balance unchanged"); + assert_eq!(helpers::round_to_near_dp(after_buyer_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_buyer_balance.saturating_sub(resale_price).as_yoctonear(), dp), "buyer balance unchanged"); + assert_eq!(helpers::round_to_near_dp(after_user_balance.as_yoctonear(), dp), helpers::round_to_near_dp(before_user_balance.saturating_add(royalty_fee).as_yoctonear(), dp), "user balance unchanged"); + + println!(" Passed ✅ test_reselling_and_royalties"); + Ok(()) +} diff --git a/integration-tests/ts/ava.config.cjs b/integration-tests/ts/ava.config.cjs deleted file mode 100644 index 07ee8c4..0000000 --- a/integration-tests/ts/ava.config.cjs +++ /dev/null @@ -1,9 +0,0 @@ -require("util").inspect.defaultOptions.depth = 5; // Increase AVA's printing depth - -module.exports = { - timeout: "300000", - files: ["./src/main.ava.ts"], - failWithoutAssertions: false, - extensions: ["ts"], - require: ["ts-node/register"], -}; diff --git a/integration-tests/ts/package.json b/integration-tests/ts/package.json deleted file mode 100644 index 0eebcc0..0000000 --- a/integration-tests/ts/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "nft-tutorial-integration-tests-ts", - "version": "0.1.0", - "license": "(MIT AND Apache-2.0)", - "scripts": { - "test": "ava" - }, - "devDependencies": { - "ava": "^4.3.3", - "near-workspaces": "^3.2.2", - "typescript": "^4.6.4", - "ts-node": "^10.8.0", - "@types/bn.js": "^5.1.0" - }, - "dependencies": {} -} diff --git a/integration-tests/ts/src/main.ava.ts b/integration-tests/ts/src/main.ava.ts deleted file mode 100644 index 0418a1c..0000000 --- a/integration-tests/ts/src/main.ava.ts +++ /dev/null @@ -1,358 +0,0 @@ -import anyTest, { TestFn } from "ava"; -import { NEAR, NearAccount, Worker, BN } from "near-workspaces"; -import path from "path"; -import { - approveNFT, defaultCallOptions, DEFAULT_GAS, mintNFT, payForStorage, - placeNFTForSale, purchaseListedNFT, transferNFT -} from "./utils"; - -const test = anyTest as TestFn<{ - worker: Worker; - accounts: Record; -}>; - -test.beforeEach(async (t) => { - const worker = await Worker.init(); - const root = worker.rootAccount; - - const nftContractLocation = path.join(__dirname, "../../../out/main.wasm"); - const nft_contract = await root.devDeploy( - nftContractLocation, - { - method: "new_default_meta", - args: { owner_id: root }, - initialBalance: NEAR.parse("100 N").toJSON() - } - ); - - const marketContractLocation = path.join(__dirname, "../../../out/market.wasm"); - const market_contract = await root.devDeploy( - marketContractLocation, - { - method: "new", - args: { owner_id: root }, - initialBalance: NEAR.parse("100 N").toJSON() - } - ); - - const alice = await root.createSubAccount("alice", { - initialBalance: NEAR.parse("100 N").toJSON(), - }); - - const bob = await root.createSubAccount("bob", { - initialBalance: NEAR.parse("100 N").toJSON(), - }); - - const charlie = await root.createSubAccount("charlie", { - initialBalance: NEAR.parse("100 N").toJSON(), - }); - - t.context.worker = worker; - t.context.accounts = { root, nft_contract, market_contract, alice, bob, charlie }; -}); - -test.afterEach.always(async (t) => { - await t.context.worker.tearDown().catch((error) => { - console.log("Failed to tear down the worker:", error); - }); -}); - -test("nft contract: nft metadata view", async (t) => { - const { root, nft_contract } = t.context.accounts; - const expected = { - base_uri: null, - icon: null, - name: "NFT Tutorial Contract", - reference: null, - reference_hash: null, - spec: "nft-1.0.0", - symbol: "GOTEAM", - }; - t.deepEqual( - await nft_contract.view("nft_metadata", { account_id: root }), - expected - ); -}); - -test("nft contract: nft mint call", async (t) => { - const { alice, nft_contract } = t.context.accounts; - const request_payload = { - token_id: "TEST123", - metadata: { - title: "LEEROYYYMMMJENKINSSS", - description: "Alright time's up, let's do this.", - media: - "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1", - }, - receiver_id: alice, - }; - await alice.call( - nft_contract, - "nft_mint", - request_payload, - defaultCallOptions() - ); - - const tokens = await nft_contract.view("nft_tokens"); - const expected = [ - { - approved_account_ids: {}, - metadata: { - copies: null, - description: "Alright time's up, let's do this.", - expires_at: null, - extra: null, - issued_at: null, - media: - "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1", - media_hash: null, - reference: null, - reference_hash: null, - starts_at: null, - title: "LEEROYYYMMMJENKINSSS", - updated_at: null, - }, - owner_id: alice.accountId, - royalty: {}, - token_id: "TEST123", - }, - ]; - t.deepEqual(tokens, expected, "Expected to find one minted NFT"); -}); - -test("nft contract: nft approve call", async (t) => { - const { alice, nft_contract, market_contract } = t.context.accounts; - await mintNFT(alice, nft_contract); - await approveNFT(market_contract, alice, nft_contract); - - // test if approved - const view_payload = { - token_id: "TEST123", - approved_account_id: market_contract, - }; - const approved = await nft_contract.view("nft_is_approved", view_payload); - t.true(approved, "Failed to approve NFT"); -}); - -test("nft contract: nft approve call long msg string", async (t) => { - const { alice, nft_contract, market_contract } = t.context.accounts; - await mintNFT(alice, nft_contract); - await payForStorage(alice, market_contract); - - // approve NFT - const approve_payload = { - token_id: "TEST123", - account_id: market_contract, - msg: "sample message".repeat(10 * 1024), - }; - const result = await alice.callRaw( - nft_contract, - "nft_approve", - approve_payload, - defaultCallOptions() - ); - t.regex(result.receiptFailureMessages.join("\n"), /Not valid SaleArgs+/); - - // test if approved - const view_payload = { - token_id: "TEST123", - approved_account_id: market_contract, - }; - const approved = await nft_contract.view("nft_is_approved", view_payload); - t.true(approved, "NFT approval apss without sale args"); -}); - -test("cross contract: sell NFT listed on marketplace", async (t) => { - const { alice, nft_contract, market_contract, bob } = t.context.accounts; - await mintNFT(alice, nft_contract); - await payForStorage(alice, market_contract); - - const sale_price = "300000000000000000000000"; // sale price string in yoctoNEAR is 0.3 NEAR - await placeNFTForSale(market_contract, alice, nft_contract, sale_price); - - const alice_balance_before = await alice.availableBalance(); - const bob_balance_before = await bob.availableBalance(); - await purchaseListedNFT(nft_contract, bob, market_contract, sale_price); - const alice_balance_after = await alice.availableBalance(); - const bob_balance_after = await bob.availableBalance(); - - // assert alice balance increased by sale price - const test_precision_dp_near = 1; - const slice_val = test_precision_dp_near - 24; - t.is( - alice_balance_after.toString().slice(0, slice_val), - alice_balance_before.add(NEAR.from(sale_price)).toString().slice(0, slice_val), - "Alice balance should increase by sale price" - ); - // bob balance should decrease by sale price - t.is( - bob_balance_after.toString().slice(0, slice_val), - bob_balance_before.sub(NEAR.from(sale_price)).toString().slice(0, slice_val), - "Bob balance should decrease by sale price" - ); - - // NFT has new owner - const view_payload = { - token_id: "TEST123", - }; - const token_info: any = await nft_contract.view("nft_token", view_payload); - t.is(token_info.owner_id, bob.accountId, "NFT should have been sold"); - // nothing left for sale on market - const sale_supply = await market_contract.view("get_supply_sales"); - t.is(sale_supply, "0", "Expected no sales to be left on market"); -}); - -test("cross contract: transfer NFT when listed on marketplace", async (t) => { - const { alice, nft_contract, market_contract, bob, charlie } = t.context.accounts; - await mintNFT(alice, nft_contract); - await payForStorage(alice, market_contract); - - const sale_price = "300000000000000000000000"; // sale price string in yoctoNEAR is 0.3 NEAR - await placeNFTForSale(market_contract, alice, nft_contract, sale_price); - - await transferNFT(bob, market_contract, nft_contract); - - // purchase NFT - const offer_payload = { - nft_contract_id: nft_contract, - token_id: "TEST123", - }; - const result = await charlie.callRaw( - market_contract, - "offer", - offer_payload, - defaultCallOptions( - DEFAULT_GAS + "0", // 10X default amount for XCC - sale_price // Attached deposit must be greater than or equal to the current price - ) - ); - - // assert expectations - // NFT has same owner - const view_payload = { - token_id: "TEST123", - }; - const token_info: any = await nft_contract.view("nft_token", view_payload); - t.is( - token_info.owner_id, - bob.accountId, // NFT was transferred to bob - "NFT should have bob as owner" - ); - // Unauthorized error should be found - t.regex(result.receiptFailureMessages.join("\n"), /Unauthorized+/); -}); - -test("cross contract: approval revoke", async (t) => { - const { alice, nft_contract, market_contract, bob } = t.context.accounts; - await mintNFT(alice, nft_contract); - await payForStorage(alice, market_contract); - await placeNFTForSale( - market_contract, - alice, - nft_contract, - "300000000000000000000000" - ); - - // revoke approval - const revoke_payload = { - token_id: "TEST123", - account_id: market_contract, // revoke market contract authorization - }; - await alice.call( - nft_contract, - "nft_revoke", - revoke_payload, - defaultCallOptions(DEFAULT_GAS, "1") // Requires attached deposit of exactly 1 yoctoNEAR - ); - - // transfer NFT - const transfer_payload = { - receiver_id: bob, - token_id: "TEST123", - approval_id: 1, - }; - const result = await market_contract.callRaw( - nft_contract, - "nft_transfer", - transfer_payload, - defaultCallOptions(DEFAULT_GAS, "1") - ); - - // assert expectations - // Unauthorized error should be found - t.regex(result.receiptFailureMessages.join("\n"), /Unauthorized+/); -}); - -test("cross contract: reselling and royalties", async (t) => { - const { alice, nft_contract, market_contract, bob, charlie } = t.context.accounts; - const royalties_string = `{"${alice.accountId}":2000}`; - const royalties = JSON.parse(royalties_string); - await mintNFT(alice, nft_contract, royalties); - await payForStorage(alice, market_contract); - const ask_price = "300000000000000000000000"; - await placeNFTForSale(market_contract, alice, nft_contract, ask_price); - - const bid_price = ask_price + "0"; - - const alice_balance_before = await alice.availableBalance(); - const bob_balance_before = await bob.availableBalance(); - await purchaseListedNFT(nft_contract, bob, market_contract, bid_price); - const alice_balance_after = await alice.availableBalance(); - const bob_balance_after = await bob.availableBalance(); - - const test_precision_dp_near = 1; - const slice_val = test_precision_dp_near - 24; - t.is( - alice_balance_after.toString().slice(0, slice_val), - alice_balance_before.add(NEAR.from(bid_price)).toString().slice(0, slice_val), - "Alice balance should increase by sale price" - ); - t.is( - bob_balance_after.toString().slice(0, slice_val), - bob_balance_before.sub(NEAR.from(bid_price)).toString().slice(0, slice_val), - "Bob balance should decrease by sale price" - ); - - // bob relists NFT for higher price - await payForStorage(bob, market_contract); - const resell_ask_price = bid_price + "0"; - await placeNFTForSale(market_contract, bob, nft_contract, resell_ask_price); - - // bob updates price to lower ask - const lowered_resell_ask_price = "600000000000000000000000"; - const update_price_payload = { - nft_contract_id: nft_contract, - token_id: "TEST123", - price: lowered_resell_ask_price, - }; - await bob.call( - market_contract, - "update_price", - update_price_payload, - defaultCallOptions(DEFAULT_GAS, "1") - ); - - const alice_balance_before_2 = await alice.availableBalance(); - const bob_balance_before_2 = await bob.availableBalance(); - const charlie_balance_before_2 = await charlie.availableBalance(); - await purchaseListedNFT(nft_contract, charlie, market_contract, resell_ask_price); - const alice_balance_after_2 = await alice.availableBalance(); - const bob_balance_after_2 = await bob.availableBalance(); - const charlie_balance_after_2 = await charlie.availableBalance(); - - t.is( - alice_balance_after_2.sub(alice_balance_before_2).toHuman(), - "6 N", - "Alice balance should increase by royalty fee of 20% of sale price" - ) - t.is( - bob_balance_after_2.sub(bob_balance_before_2).toHuman(), - "24.00031 N", - "Bob balance should decrease by sale price minus royalty fee of 20% of sale price" - ) - t.is( - charlie_balance_before_2.sub(charlie_balance_after_2).toHuman().slice(0, 2), - "30", - "Charlie balance should decrease by sale price" - ) -}); diff --git a/integration-tests/ts/src/utils.ts b/integration-tests/ts/src/utils.ts deleted file mode 100644 index fbf6465..0000000 --- a/integration-tests/ts/src/utils.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { BN, NearAccount } from "near-workspaces"; - -export const DEFAULT_GAS: string = "30000000000000"; -export const DEFAULT_DEPOSIT: string = "9050000000000000000000"; - - -export async function purchaseListedNFT( - nft_contract: NearAccount, - bidder_account: NearAccount, - market_contract: NearAccount, - bid_price: string -) { - const offer_payload = { - nft_contract_id: nft_contract, - token_id: "TEST123", - }; - await bidder_account.callRaw( - market_contract, - "offer", - offer_payload, - defaultCallOptions(DEFAULT_GAS + "0", bid_price) - ); -} - -export async function placeNFTForSale( - market_contract: NearAccount, - owner: NearAccount, - nft_contract: NearAccount, - ask_price: string // sale price string in yoctoNEAR -) { - await approveNFT( - market_contract, - owner, - nft_contract, - '{"sale_conditions": ' + `"${ask_price}"` + " }" // msg string trigger XCC - ); -} - -export function defaultCallOptions( - gas: string = DEFAULT_GAS, - deposit: string = DEFAULT_DEPOSIT -) { - return { - gas: new BN(gas), - attachedDeposit: new BN(deposit), - }; -} -export async function approveNFT( - account_to_approve: NearAccount, - owner: NearAccount, - nft_contract: NearAccount, - message?: string -) { - const approve_payload = { - token_id: "TEST123", - account_id: account_to_approve, - msg: message, - }; - await owner.call( - nft_contract, - "nft_approve", - approve_payload, - defaultCallOptions() - ); -} - -export async function mintNFT( - user: NearAccount, - nft_contract: NearAccount, - royalties?: object -) { - const mint_payload = { - token_id: "TEST123", - metadata: { - title: "LEEROYYYMMMJENKINSSS", - description: "Alright time's up, let's do this.", - media: - "https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse3.mm.bing.net%2Fth%3Fid%3DOIP.Fhp4lHufCdTzTeGCAblOdgHaF7%26pid%3DApi&f=1", - }, - receiver_id: user, - perpetual_royalties: royalties, - }; - await user.call(nft_contract, "nft_mint", mint_payload, defaultCallOptions()); -} - -export async function payForStorage( - alice: NearAccount, - market_contract: NearAccount -) { - await alice.call( - market_contract, - "storage_deposit", - {}, - defaultCallOptions(DEFAULT_GAS, "10000000000000000000000") // Requires minimum deposit of 10000000000000000000000 - ); -} - -export async function transferNFT( - receiver: NearAccount, - sender: NearAccount, - nft_contract: NearAccount -) { - const transfer_payload = { - receiver_id: receiver, - token_id: "TEST123", - approval_id: 0, // first and only approval done in line 224 - }; - await sender.call( - nft_contract, - "nft_transfer", - transfer_payload, - defaultCallOptions(DEFAULT_GAS, "1") - ); -} diff --git a/market-contract/Cargo.toml b/market-contract/Cargo.toml index 27d85ca..55b4a83 100644 --- a/market-contract/Cargo.toml +++ b/market-contract/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nft_simple" +name = "nft_market_contract" version = "0.1.0" authors = ["Near Inc "] edition = "2021" @@ -8,15 +8,20 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -near-sdk = "4.1.1" +near-sdk = { version = "5.1.0", features = ["legacy"] } -[patch.crates-io] -parity-secp256k1 = { git = 'https://github.com/paritytech/rust-secp256k1.git' } +[dev-dependencies] +near-sdk = { version = "5.1.0", features = ["unit-testing"] } +near-workspaces = { version = "0.10.0", features = ["unstable"] } +tokio = { version = "1.12.0", features = ["full"] } +serde_json = "1" [profile.release] -codegen-units=1 +codegen-units = 1 +# Tell `rustc` to optimize for small code size. opt-level = "z" lto = true debug = false panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 overflow-checks = true diff --git a/market-contract/build.sh b/market-contract/build.sh deleted file mode 100755 index 485dbfd..0000000 --- a/market-contract/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -set -e && RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release && mkdir -p ../out && cp target/wasm32-unknown-unknown/release/*.wasm ../out/market.wasm \ No newline at end of file diff --git a/market-contract/res/nft_simple.wasm b/market-contract/res/nft_simple.wasm deleted file mode 100755 index f68c929..0000000 Binary files a/market-contract/res/nft_simple.wasm and /dev/null differ diff --git a/market-contract/src/external.rs b/market-contract/src/external.rs index 5d806c2..7700642 100644 --- a/market-contract/src/external.rs +++ b/market-contract/src/external.rs @@ -2,10 +2,10 @@ use crate::*; /// external contract calls -//initiate a cross contract call to the nft contract. This will transfer the token to the buyer and return -//a payout object used for the market to distribute funds to the appropriate accounts. +//initiate a cross contract call to the nft contract #[ext_contract(ext_contract)] trait ExtContract { + //This will transfer the token to the buyer and return a payout object used for the market to distribute funds to the appropriate accounts fn nft_transfer_payout( &mut self, receiver_id: AccountId, //purchaser (person to transfer the NFT to) @@ -18,6 +18,13 @@ trait ExtContract { */ balance: U128, //the maximum amount of accounts the market can payout at once (this is limited by GAS) - max_len_payout: u32, + max_len_payout: u32, + ); + fn nft_token(&self, token_id: TokenId); + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: u64, ); } \ No newline at end of file diff --git a/market-contract/src/internal.rs b/market-contract/src/internal.rs index 0fd04e0..eb32359 100644 --- a/market-contract/src/internal.rs +++ b/market-contract/src/internal.rs @@ -9,6 +9,10 @@ pub(crate) fn hash_account_id(account_id: &AccountId) -> CryptoHash { hash } +pub(crate) fn storage_per_sale() -> NearToken { + env::storage_byte_cost().saturating_mul(1000) +} + impl Contract { //internal method for removing a sale from the market. This returns the previously removed sale object pub(crate) fn internal_remove_sale( diff --git a/market-contract/src/lib.rs b/market-contract/src/lib.rs index 1fbad9a..fbf9cc1 100644 --- a/market-contract/src/lib.rs +++ b/market-contract/src/lib.rs @@ -1,17 +1,16 @@ -use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::borsh::{BorshDeserialize, BorshSerialize}; use near_sdk::collections::{LookupMap, UnorderedMap, UnorderedSet}; use near_sdk::json_types::{U128, U64}; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::{ - assert_one_yocto, env, ext_contract, near_bindgen, AccountId, Balance, Gas, PanicOnDefault, - Promise, CryptoHash, BorshStorageKey, + assert_one_yocto, env, ext_contract, near_bindgen, AccountId, BorshStorageKey, CryptoHash, Gas, + NearToken, PanicOnDefault, Promise, }; use std::collections::HashMap; use crate::external::*; use crate::internal::*; use crate::sale::*; -use near_sdk::env::STORAGE_PRICE_PER_BYTE; mod external; mod internal; @@ -20,42 +19,44 @@ mod sale; mod sale_views; //GAS constants to attach to calls -const GAS_FOR_RESOLVE_PURCHASE: Gas = Gas(115_000_000_000_000); -const GAS_FOR_NFT_TRANSFER: Gas = Gas(15_000_000_000_000); +const GAS_FOR_RESOLVE_PURCHASE: Gas = Gas::from_tgas(115); +const GAS_FOR_NFT_TRANSFER: Gas = Gas::from_tgas(15); -//the minimum storage to have a sale on the contract. -const STORAGE_PER_SALE: u128 = 1000 * STORAGE_PRICE_PER_BYTE; +//Basic NEAR amounts as constants +const ZERO_NEAR: NearToken = NearToken::from_yoctonear(0); +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); //every sale will have a unique ID which is `CONTRACT + DELIMITER + TOKEN_ID` static DELIMETER: &str = "."; -//Creating custom types to use within the contract. This makes things more readable. -pub type SalePriceInYoctoNear = U128; +//Creating custom types to use within the contract. This makes things more readable. +pub type SalePriceInYoctoNear = NearToken; pub type TokenId = String; pub type FungibleTokenId = AccountId; pub type ContractAndTokenId = String; + //defines the payout type we'll be parsing from the NFT contract as a part of the royalty standard. #[derive(Serialize, Deserialize)] #[serde(crate = "near_sdk::serde")] pub struct Payout { pub payout: HashMap, -} - +} //main contract struct to store all the information #[near_bindgen] #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] pub struct Contract { //keep track of the owner of the contract pub owner_id: AccountId, - + /* - to keep track of the sales, we map the ContractAndTokenId to a Sale. + to keep track of the sales, we map the ContractAndTokenId to a Sale. the ContractAndTokenId is the unique identifier for every sale. It is made up of the `contract ID + DELIMITER + token ID` */ pub sales: UnorderedMap, - + //keep track of all the Sale IDs for every account ID pub by_owner_id: LookupMap>, @@ -63,11 +64,12 @@ pub struct Contract { pub by_nft_contract_id: LookupMap>, //keep track of the storage that accounts have payed - pub storage_deposits: LookupMap, + pub storage_deposits: LookupMap, } /// Helper structure to for keys of the persistent collections. #[derive(BorshStorageKey, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] pub enum StorageKey { Sales, ByOwnerId, @@ -90,7 +92,7 @@ impl Contract { #[init] pub fn new(owner_id: AccountId) -> Self { let this = Self { - //set the owner_id field equal to the passed in owner_id. + //set the owner_id field equal to the passed in owner_id. owner_id, //Storage keys are simply the prefixes used for the collections. This helps avoid data collision @@ -109,7 +111,7 @@ impl Contract { #[payable] pub fn storage_deposit(&mut self, account_id: Option) { //get the account ID to pay for storage for - let storage_account_id = account_id + let storage_account_id = account_id //convert the valid account ID into an account ID .map(|a| a.into()) //if we didn't specify an account ID, we simply use the caller of the function @@ -118,53 +120,56 @@ impl Contract { //get the deposit value which is how much the user wants to add to their storage let deposit = env::attached_deposit(); - //make sure the deposit is greater than or equal to the minimum storage for a sale + //make sure the deposit is greater than or equal to the minimum storage for a sale (which computes like env::storage_byte_cost().saturating_mul(1000)) assert!( - deposit >= STORAGE_PER_SALE, + deposit.ge(&storage_per_sale()), "Requires minimum deposit of {}", - STORAGE_PER_SALE + storage_per_sale() ); //get the balance of the account (if the account isn't in the map we default to a balance of 0) - let mut balance: u128 = self.storage_deposits.get(&storage_account_id).unwrap_or(0); + let mut balance: NearToken = self + .storage_deposits + .get(&storage_account_id) + .unwrap_or(ZERO_NEAR); //add the deposit to their balance - balance += deposit; + balance = balance.saturating_add(deposit); //insert the balance back into the map for that account ID self.storage_deposits.insert(&storage_account_id, &balance); } //Allows users to withdraw any excess storage that they're not using. Say Bob pays 0.01N for 1 sale //Alice then buys Bob's token. This means bob has paid 0.01N for a sale that's no longer on the marketplace - //Bob could then withdraw this 0.01N back into his account. + //Bob could then withdraw this 0.01N back into his account. #[payable] pub fn storage_withdraw(&mut self) { //make sure the user attaches exactly 1 yoctoNEAR for security purposes. - //this will redirect them to the NEAR wallet (or requires a full access key). + //this will redirect them to the NEAR wallet (or requires a full access key). assert_one_yocto(); //the account to withdraw storage to is always the function caller let owner_id = env::predecessor_account_id(); //get the amount that the user has by removing them from the map. If they're not in the map, default to 0 - let mut amount = self.storage_deposits.remove(&owner_id).unwrap_or(0); - + let mut amount = self.storage_deposits.remove(&owner_id).unwrap_or(ZERO_NEAR); + //how many sales is that user taking up currently. This returns a set let sales = self.by_owner_id.get(&owner_id); - //get the length of that set. + //get the length of that set. let len = sales.map(|s| s.len()).unwrap_or_default(); - //how much NEAR is being used up for all the current sales on the account - let diff = u128::from(len) * STORAGE_PER_SALE; + //how much NEAR is being used up for all the current sales on the account + let diff = storage_per_sale().saturating_mul(u128::from(len)); //the excess to withdraw is the total storage paid - storage being used up. - amount -= diff; + amount = amount.saturating_sub(diff); //if that excess to withdraw is > 0, we transfer the amount to the user. - if amount > 0 { + if amount.gt(&ZERO_NEAR) { Promise::new(owner_id.clone()).transfer(amount); } //we need to add back the storage being used up into the map if it's greater than 0. //this is so that if the user had 500 sales on the market, we insert that value here so //if those sales get taken down, the user can then go and withdraw 500 sales worth of storage. - if diff > 0 { + if diff.gt(&ZERO_NEAR) { self.storage_deposits.insert(&owner_id, &diff); } } @@ -172,12 +177,12 @@ impl Contract { /// views //return the minimum storage for 1 sale pub fn storage_minimum_balance(&self) -> U128 { - U128(STORAGE_PER_SALE) + U128(storage_per_sale().as_yoctonear()) } //return how much storage an account has paid for pub fn storage_balance_of(&self, account_id: AccountId) -> U128 { - U128(self.storage_deposits.get(&account_id).unwrap_or(0)) + U128(self.storage_deposits.get(&account_id).unwrap_or(ZERO_NEAR).as_yoctonear()) } } diff --git a/market-contract/src/nft_callbacks.rs b/market-contract/src/nft_callbacks.rs index ece44b4..02ab75e 100644 --- a/market-contract/src/nft_callbacks.rs +++ b/market-contract/src/nft_callbacks.rs @@ -2,13 +2,6 @@ use crate::*; /// approval callbacks from NFT Contracts -//struct for keeping track of the sale conditions for a Sale -#[derive(Serialize, Deserialize)] -#[serde(crate = "near_sdk::serde")] -pub struct SaleArgs { - pub sale_conditions: SalePriceInYoctoNear, -} - /* trait that will be used as the callback from the NFT contract. When nft_approve is called, it will fire a cross contract call to this marketplace and this is the function @@ -27,8 +20,6 @@ trait NonFungibleTokenApprovalsReceiver { //implementation of the trait #[near_bindgen] impl NonFungibleTokenApprovalsReceiver for Contract { - /// where we add the sale because we know nft owner can only call nft_approve - fn nft_on_approve( &mut self, token_id: TokenId, @@ -36,100 +27,8 @@ impl NonFungibleTokenApprovalsReceiver for Contract { approval_id: u64, msg: String, ) { - // get the contract ID which is the predecessor - let nft_contract_id = env::predecessor_account_id(); - //get the signer which is the person who initiated the transaction - let signer_id = env::signer_account_id(); - - //make sure that the signer isn't the predecessor. This is so that we're sure - //this was called via a cross-contract call - assert_ne!( - nft_contract_id, - signer_id, - "nft_on_approve should only be called via cross-contract call" - ); - //make sure the owner ID is the signer. - assert_eq!( - owner_id, - signer_id, - "owner_id should be signer_id" - ); - - //we need to enforce that the user has enough storage for 1 EXTRA sale. - - //get the storage for a sale. dot 0 converts from U128 to u128 - let storage_amount = self.storage_minimum_balance().0; - //get the total storage paid by the owner - let owner_paid_storage = self.storage_deposits.get(&signer_id).unwrap_or(0); - //get the storage required which is simply the storage for the number of sales they have + 1 - let signer_storage_required = (self.get_supply_by_owner_id(signer_id).0 + 1) as u128 * storage_amount; - - //make sure that the total paid is >= the required storage - assert!( - owner_paid_storage >= signer_storage_required, - "Insufficient storage paid: {}, for {} sales at {} rate of per sale", - owner_paid_storage, signer_storage_required / STORAGE_PER_SALE, STORAGE_PER_SALE - ); - - //if all these checks pass we can create the sale conditions object. - let SaleArgs { sale_conditions } = - //the sale conditions come from the msg field. The market assumes that the user passed - //in a proper msg. If they didn't, it panics. - near_sdk::serde_json::from_str(&msg).expect("Not valid SaleArgs"); - - //create the unique sale ID which is the contract + DELIMITER + token ID - let contract_and_token_id = format!("{}{}{}", nft_contract_id, DELIMETER, token_id); - - //insert the key value pair into the sales map. Key is the unique ID. value is the sale object - self.sales.insert( - &contract_and_token_id, - &Sale { - owner_id: owner_id.clone(), //owner of the sale / token - approval_id, //approval ID for that token that was given to the market - nft_contract_id: nft_contract_id.to_string(), //NFT contract the token was minted on - token_id: token_id.clone(), //the actual token ID - sale_conditions, //the sale conditions - }, - ); - - //Extra functionality that populates collections necessary for the view calls - - //get the sales by owner ID for the given owner. If there are none, we create a new empty set - let mut by_owner_id = self.by_owner_id.get(&owner_id).unwrap_or_else(|| { - UnorderedSet::new( - StorageKey::ByOwnerIdInner { - //we get a new unique prefix for the collection by hashing the owner - account_id_hash: hash_account_id(&owner_id), - } - .try_to_vec() - .unwrap(), - ) - }); - - //insert the unique sale ID into the set - by_owner_id.insert(&contract_and_token_id); - //insert that set back into the collection for the owner - self.by_owner_id.insert(&owner_id, &by_owner_id); - - //get the token IDs for the given nft contract ID. If there are none, we create a new empty set - let mut by_nft_contract_id = self - .by_nft_contract_id - .get(&nft_contract_id) - .unwrap_or_else(|| { - UnorderedSet::new( - StorageKey::ByNFTContractIdInner { - //we get a new unique prefix for the collection by hashing the owner - account_id_hash: hash_account_id(&nft_contract_id), - } - .try_to_vec() - .unwrap(), - ) - }); - - //insert the token ID into the set - by_nft_contract_id.insert(&token_id); - //insert the set back into the collection for the given nft contract ID - self.by_nft_contract_id - .insert(&nft_contract_id, &by_nft_contract_id); + /* + YOU CAN PUT SOME INTERNAL MARKETPLACE LOGIC HERE + */ } } diff --git a/market-contract/src/sale.rs b/market-contract/src/sale.rs index 0991bf0..051fe0e 100644 --- a/market-contract/src/sale.rs +++ b/market-contract/src/sale.rs @@ -1,8 +1,10 @@ use crate::*; -use near_sdk::promise_result_as_success; +use near_sdk::{log, promise_result_as_success, NearSchema, PromiseError}; +use near_sdk::serde_json::json; //struct that holds important information about each sale on the market -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] #[serde(crate = "near_sdk::serde")] pub struct Sale { //owner of the sale @@ -17,9 +19,60 @@ pub struct Sale { pub sale_conditions: SalePriceInYoctoNear, } +//The Json token is what will be returned from view calls. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct JsonToken { + //owner of the token + pub owner_id: AccountId, +} + #[near_bindgen] impl Contract { - + // lists a nft for sale on the market + #[payable] + pub fn list_nft_for_sale( + &mut self, + nft_contract_id: AccountId, + token_id: TokenId, + approval_id: u64, + sale_conditions: SalePriceInYoctoNear, + ) { + let owner_id = env::predecessor_account_id(); + + //we need to enforce that the user has enough storage for 1 EXTRA sale. + + //get the storage for a sale + let storage_amount = self.storage_minimum_balance().0; + //get the total storage paid by the owner + let owner_paid_storage = self.storage_deposits.get(&owner_id).unwrap_or(ZERO_NEAR); + //get the storage required which is simply the storage for the number of sales they have + 1 + let signer_storage_required = storage_amount.saturating_mul((self.get_supply_by_owner_id(owner_id.clone()).0 + 1).into()); + + //make sure that the total paid is >= the required storage + assert!( + owner_paid_storage.ge(&NearToken::from_yoctonear(signer_storage_required)), + "Insufficient storage paid: {}, for {} sales at {} rate of per sale", + owner_paid_storage, signer_storage_required.saturating_div(storage_per_sale().as_yoctonear()), storage_per_sale() + ); + + let nft_token_promise = Promise::new(nft_contract_id.clone()).function_call( + "nft_token".to_owned(), + json!({ "token_id": token_id }).to_string().into_bytes(), + ZERO_NEAR, + Gas::from_gas(10u64.pow(13)) + ); + let nft_is_approved_promise = Promise::new(nft_contract_id.clone()).function_call( + "nft_is_approved".to_owned(), + json!({ "token_id": token_id, "approved_account_id": env::current_account_id(), "approval_id": approval_id }).to_string().into_bytes(), + ZERO_NEAR, + Gas::from_gas(10u64.pow(13)) + ); + nft_token_promise + .and(nft_is_approved_promise) + .then(Self::ext(env::current_account_id()).process_listing(owner_id.clone(), nft_contract_id, token_id, approval_id, sale_conditions)); + } + //removes a sale from the market. #[payable] pub fn remove_sale(&mut self, nft_contract_id: AccountId, token_id: String) { @@ -59,7 +112,7 @@ impl Contract { ); //set the sale conditions equal to the passed in price - sale.sale_conditions = price; + sale.sale_conditions = NearToken::from_yoctonear(price.0); //insert the sale back into the map for the unique sale ID self.sales.insert(&contract_and_token_id, &sale); } @@ -69,7 +122,7 @@ impl Contract { pub fn offer(&mut self, nft_contract_id: AccountId, token_id: String) { //get the attached deposit and make sure it's greater than 0 let deposit = env::attached_deposit(); - assert!(deposit > 0, "Attached deposit must be greater than 0"); + assert!(!deposit.is_zero(), "Attached deposit must be greater than 0"); //convert the nft_contract_id from a AccountId to an AccountId let contract_id: AccountId = nft_contract_id.into(); @@ -82,18 +135,17 @@ impl Contract { //get the buyer ID which is the person who called the function and make sure they're not the owner of the sale let buyer_id = env::predecessor_account_id(); assert_ne!(sale.owner_id, buyer_id, "Cannot bid on your own sale."); - - //get the u128 price of the token (dot 0 converts from U128 to u128) - let price = sale.sale_conditions.0; + + let price = sale.sale_conditions; //make sure the deposit is greater than the price - assert!(deposit >= price, "Attached deposit must be greater than or equal to the current price: {:?}", price); + assert!(deposit.ge(&price), "Attached deposit must be greater than or equal to the current price: {:?}. Your deposit: {:?}", price, deposit); //process the purchase (which will remove the sale, transfer and get the payout from the nft contract, and then distribute royalties) self.process_purchase( contract_id, token_id, - U128(deposit), + U128(deposit.as_yoctonear()), buyer_id, ); } @@ -115,7 +167,7 @@ impl Contract { //a payout object used for the market to distribute funds to the appropriate accounts. ext_contract::ext(nft_contract_id) // Attach 1 yoctoNEAR with static GAS equal to the GAS for nft transfer. Also attach an unused GAS weight of 1 by default. - .with_attached_deposit(1) + .with_attached_deposit(ONE_YOCTONEAR) .with_static_gas(GAS_FOR_NFT_TRANSFER) .nft_transfer_payout( buyer_id.clone(), //purchaser (person to transfer the NFT to) @@ -195,19 +247,101 @@ impl Contract { payout_option //if the payout option was None, we refund the buyer for the price they payed and return } else { - Promise::new(buyer_id).transfer(u128::from(price)); + Promise::new(buyer_id).transfer(NearToken::from_yoctonear(u128::from(price))); // leave function and return the price that was refunded return price; }; // NEAR payouts for (receiver_id, amount) in payout { - Promise::new(receiver_id).transfer(amount.0); + Promise::new(receiver_id).transfer(NearToken::from_yoctonear(amount.0)); } //return the price payout out price } + + #[private] + pub fn process_listing( + &mut self, + owner_id: AccountId, + nft_contract_id: AccountId, + token_id: TokenId, + approval_id: u64, + sale_conditions: SalePriceInYoctoNear, + #[callback_result] nft_token_result: Result, + #[callback_result] nft_is_approved_result: Result, + ) { + if let Ok(result) = nft_token_result { + assert_eq!( + result.owner_id, + owner_id, + "Signer is not NFT owner", + ) + } else { + log!("nft_is_approved call failed"); + } + if let Ok(result) = nft_is_approved_result { + assert_eq!( + result, + true, + "Marketplace contract is not approved", + ) + } else { + log!("nft_is_approved call failed"); + } + + //create the unique sale ID which is the contract + DELIMITER + token ID + let contract_and_token_id = format!("{}{}{}", nft_contract_id, DELIMETER, token_id); + + //insert the key value pair into the sales map. Key is the unique ID. value is the sale object + self.sales.insert( + &contract_and_token_id, + &Sale { + owner_id: owner_id.clone(), //owner of the sale / token + approval_id, //approval ID for that token that was given to the market + nft_contract_id: nft_contract_id.to_string(), //NFT contract the token was minted on + token_id: token_id.clone(), //the actual token ID + sale_conditions, //the sale conditions + }, + ); + + //Extra functionality that populates collections necessary for the view calls + + //get the sales by owner ID for the given owner. If there are none, we create a new empty set + let mut by_owner_id = self.by_owner_id.get(&owner_id).unwrap_or_else(|| { + UnorderedSet::new( + StorageKey::ByOwnerIdInner { + //we get a new unique prefix for the collection by hashing the owner + account_id_hash: hash_account_id(&owner_id), + } + ) + }); + + //insert the unique sale ID into the set + by_owner_id.insert(&contract_and_token_id); + //insert that set back into the collection for the owner + self.by_owner_id.insert(&owner_id, &by_owner_id); + + //get the token IDs for the given nft contract ID. If there are none, we create a new empty set + let mut by_nft_contract_id = self + .by_nft_contract_id + .get(&nft_contract_id) + .unwrap_or_else(|| { + UnorderedSet::new( + StorageKey::ByNFTContractIdInner { + //we get a new unique prefix for the collection by hashing the owner + account_id_hash: hash_account_id(&nft_contract_id), + } + ) + }); + + //insert the token ID into the set + by_nft_contract_id.insert(&token_id); + //insert the set back into the collection for the given nft contract ID + self.by_nft_contract_id + .insert(&nft_contract_id, &by_nft_contract_id); + } } //this is the cross contract call that we call on our own contract. @@ -221,6 +355,6 @@ trait ExtSelf { fn resolve_purchase( &mut self, buyer_id: AccountId, - price: U128, + price: NearToken, ) -> Promise; -} +} \ No newline at end of file diff --git a/market-contract/src/sale_views.rs b/market-contract/src/sale_views.rs index 43c76e3..9dcf7b4 100644 --- a/market-contract/src/sale_views.rs +++ b/market-contract/src/sale_views.rs @@ -8,7 +8,7 @@ impl Contract { pub fn get_supply_sales( &self, ) -> U64 { - //returns the sales object length wrapped as a U64 + //returns the sales object length U64(self.sales.len()) } @@ -32,7 +32,7 @@ impl Contract { pub fn get_sales_by_owner_id( &self, account_id: AccountId, - from_index: Option, + from_index: Option, limit: Option, ) -> Vec { //get the set of token IDs for sale for the given account ID @@ -48,7 +48,7 @@ impl Contract { let keys = sales.as_vector(); //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through the keys vector keys.iter() @@ -82,7 +82,7 @@ impl Contract { pub fn get_sales_by_nft_contract_id( &self, nft_contract_id: AccountId, - from_index: Option, + from_index: Option, limit: Option, ) -> Vec { //get the set of token IDs for sale for the given contract ID @@ -99,7 +99,7 @@ impl Contract { let keys = sales.as_vector(); //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through the keys vector keys.iter() diff --git a/market-contract/src/tests.rs b/market-contract/src/tests.rs index b66360c..6adc0f7 100644 --- a/market-contract/src/tests.rs +++ b/market-contract/src/tests.rs @@ -4,14 +4,17 @@ use crate::sale::Sale; use crate::Contract; use near_sdk::{ collections::UnorderedSet, + json_types::U128, env, - json_types::{U128, U64}, + NearToken, test_utils::{accounts, VMContextBuilder}, testing_env, AccountId, }; -const MIN_REQUIRED_APPROVAL_YOCTO: u128 = 170000000000000000000; -const MIN_REQUIRED_STORAGE_YOCTO: u128 = 10000000000000000000000; +const MIN_REQUIRED_APPROVAL_YOCTO: NearToken = NearToken::from_yoctonear(170000000000000000000); +const MIN_REQUIRED_STORAGE_YOCTO: NearToken = NearToken::from_millinear(100); + +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); fn get_context(predecessor: AccountId) -> VMContextBuilder { let mut builder = VMContextBuilder::new(); @@ -28,7 +31,7 @@ fn test_default() { } #[test] -#[should_panic(expected = "Requires minimum deposit of 10000000000000000000000")] +#[should_panic(expected = "Requires minimum deposit of 0.010 NEAR")] fn test_storage_deposit_insufficient_deposit() { let mut context = get_context(accounts(0)); testing_env!(context.build()); @@ -69,7 +72,7 @@ fn test_storage_balance_of() { .build()); contract.storage_deposit(Some(accounts(0))); let balance = contract.storage_balance_of(accounts(0)); - assert_eq!(balance, U128(MIN_REQUIRED_STORAGE_YOCTO)); + assert_eq!(balance, U128(MIN_REQUIRED_STORAGE_YOCTO.as_yoctonear())); } #[test] @@ -89,7 +92,7 @@ fn test_storage_withdraw() { // withdraw amount testing_env!(context .storage_usage(env::storage_usage()) - .attached_deposit(U128(1).0) // below func requires a min of 1 yocto attached + .attached_deposit(ONE_YOCTONEAR) // below func requires a min of 1 yocto attached .predecessor_account_id(accounts(0)) .build()); contract.storage_withdraw(); @@ -116,10 +119,10 @@ fn test_remove_sale() { let token_id = String::from("0n3C0ntr4ctT0Rul3Th3m4ll"); let sale = Sale { owner_id: accounts(0).clone(), //owner of the sale / token - approval_id: U64(1).0, //approval ID for that token that was given to the market + approval_id: 1, //approval ID for that token that was given to the market nft_contract_id: env::predecessor_account_id().to_string(), //NFT contract the token was minted on token_id: token_id.clone(), //the actual token ID - sale_conditions: U128(100), //the sale conditions -- price in YOCTO NEAR + sale_conditions: NearToken::from_yoctonear(100), //the sale conditions -- price in YOCTO NEAR }; let nft_contract_id = env::predecessor_account_id(); let contract_and_token_id = format!("{}{}{}", nft_contract_id, ".", token_id); @@ -137,7 +140,7 @@ fn test_remove_sale() { // remove sale testing_env!(context .storage_usage(env::storage_usage()) - .attached_deposit(U128(1).0) // below func requires a min of 1 yocto attached + .attached_deposit(ONE_YOCTONEAR) // below func requires a min of 1 yocto attached .predecessor_account_id(accounts(0)) .build()); contract.remove_sale(nft_contract_id, token_id); @@ -164,10 +167,10 @@ fn test_update_price() { // add sale let token_id = String::from("0n3C0ntr4ctT0Rul3Th3m4ll"); - let nft_bid_yocto = U128(100); + let nft_bid_yocto = NearToken::from_yoctonear(100); let sale = Sale { owner_id: accounts(0).clone(), //owner of the sale / token - approval_id: U64(1).0, //approval ID for that token that was given to the market + approval_id: 1, //approval ID for that token that was given to the market nft_contract_id: env::predecessor_account_id().to_string(), //NFT contract the token was minted on token_id: token_id.clone(), //the actual token ID sale_conditions: nft_bid_yocto, //the sale conditions -- price in YOCTO NEAR @@ -186,13 +189,13 @@ fn test_update_price() { assert_eq!(contract.sales.len(), 1, "Failed to insert sale to contract"); // update price - let new_price = U128(150); + let new_price = NearToken::from_yoctonear(150); testing_env!(context .storage_usage(env::storage_usage()) - .attached_deposit(U128(1).0) + .attached_deposit(ONE_YOCTONEAR) .predecessor_account_id(accounts(0)) // bob to buy NFT from alice .build()); - contract.update_price(nft_contract_id, token_id, new_price); + contract.update_price(nft_contract_id, token_id, U128(new_price.as_yoctonear())); // test update price success let sale = contract.sales.get(&contract_and_token_id).expect("No sale"); diff --git a/nft-contract/Cargo.toml b/nft-contract-approval/Cargo.toml similarity index 51% rename from nft-contract/Cargo.toml rename to nft-contract-approval/Cargo.toml index 17c1958..b1b459a 100644 --- a/nft-contract/Cargo.toml +++ b/nft-contract-approval/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nft_simple" +name = "nft_contract_skeleton" version = "0.1.0" authors = ["Near Inc "] edition = "2021" @@ -8,16 +8,15 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -near-sdk = "4.1.1" +near-sdk = { version = "5.1.0", features = ["legacy"] } serde_json = "1.0.113" -[patch.crates-io] -parity-secp256k1 = { git = 'https://github.com/paritytech/rust-secp256k1.git' } - [profile.release] -codegen-units=1 +codegen-units = 1 +# Tell `rustc` to optimize for small code size. opt-level = "z" lto = true debug = false panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 overflow-checks = true diff --git a/nft-contract/README.md b/nft-contract-approval/README.md similarity index 100% rename from nft-contract/README.md rename to nft-contract-approval/README.md diff --git a/nft-contract/src/approval.rs b/nft-contract-approval/src/approval.rs similarity index 97% rename from nft-contract/src/approval.rs rename to nft-contract-approval/src/approval.rs index 51faa94..3dda71a 100644 --- a/nft-contract/src/approval.rs +++ b/nft-contract-approval/src/approval.rs @@ -6,7 +6,7 @@ pub trait NonFungibleTokenCore { fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option); //check if the passed in account has access to approve the token ID - fn nft_is_approved( + fn nft_is_approved( &self, token_id: TokenId, approved_account_id: AccountId, @@ -103,10 +103,10 @@ impl NonFungibleTokenCore for Contract { ) -> bool { //get the token object from the token_id let token = self.tokens_by_id.get(&token_id).expect("No token"); - + //get the approval number for the passed in account ID - let approval = token.approved_account_ids.get(&approved_account_id); - + let approval = token.approved_account_ids.get(&approved_account_id); + //if there was some approval ID found for the account ID if let Some(approval) = approval { //if a specific approval_id was passed into the function @@ -117,7 +117,7 @@ impl NonFungibleTokenCore for Contract { } else { true } - //if there was no approval ID found for the account ID, we simply return false + //if there was no approval ID found for the account ID, we simply return false } else { false } diff --git a/nft-contract/src/enumeration.rs b/nft-contract-approval/src/enumeration.rs similarity index 85% rename from nft-contract/src/enumeration.rs rename to nft-contract-approval/src/enumeration.rs index 5d1e0de..5926561 100644 --- a/nft-contract/src/enumeration.rs +++ b/nft-contract-approval/src/enumeration.rs @@ -3,15 +3,15 @@ use crate::*; #[near_bindgen] impl Contract { //Query for the total supply of NFTs on the contract - pub fn nft_total_supply(&self) -> U128 { + pub fn nft_total_supply(&self) -> U64 { //return the length of the token metadata by ID - U128(self.token_metadata_by_id.len() as u128) + U64(self.token_metadata_by_id.len()) } //Query for nft tokens on the contract regardless of the owner using pagination - pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { + pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through each token using an iterator self.token_metadata_by_id.keys() @@ -27,18 +27,18 @@ impl Contract { //get the total supply of NFTs for a given owner pub fn nft_supply_for_owner( - &self, - account_id: AccountId, - ) -> U128 { + &self, + account_id: AccountId, + ) -> U64 { //get the set of tokens for the passed in owner let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); - //if there is some set of tokens, we'll return the length as a U128 + //if there is some set of tokens, we'll return the length if let Some(tokens_for_owner_set) = tokens_for_owner_set { - U128(tokens_for_owner_set.len() as u128) + U64(tokens_for_owner_set.len()) } else { //if there isn't a set of tokens for the passed in account ID, we'll return 0 - U128(0) + U64(0) } } @@ -46,7 +46,7 @@ impl Contract { pub fn nft_tokens_for_owner( &self, account_id: AccountId, - from_index: Option, + from_index: Option, limit: Option, ) -> Vec { //get the set of tokens for the passed in owner @@ -60,7 +60,7 @@ impl Contract { }; //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through the keys vector tokens.iter() @@ -73,4 +73,4 @@ impl Contract { //since we turned the keys into an iterator, we need to turn it back into a vector to return .collect() } -} +} \ No newline at end of file diff --git a/nft-contract/src/events.rs b/nft-contract-approval/src/events.rs similarity index 100% rename from nft-contract/src/events.rs rename to nft-contract-approval/src/events.rs diff --git a/nft-contract-approval/src/internal.rs b/nft-contract-approval/src/internal.rs new file mode 100644 index 0000000..d1695d8 --- /dev/null +++ b/nft-contract-approval/src/internal.rs @@ -0,0 +1,228 @@ +use crate::*; +use near_sdk::{CryptoHash}; +use std::mem::size_of; + +//calculate how many bytes the account ID is taking up +pub(crate) fn bytes_for_approved_account_id(account_id: &AccountId) -> u128 { + // The extra 4 bytes are coming from Borsh serialization to store the length of the string. + account_id.as_str().len() as u128 + 4 + size_of::() as u128 +} + +//refund the storage taken up by passed in approved account IDs and send the funds to the passed in account ID. +pub(crate) fn refund_approved_account_ids_iter<'a, I>( + account_id: AccountId, + approved_account_ids: I, //the approved account IDs must be passed in as an iterator +) -> Promise where I: Iterator { + //get the storage total by going through and summing all the bytes for each approved account IDs + let storage_released = approved_account_ids.map(bytes_for_approved_account_id).sum(); + //transfer the account the storage that is released + Promise::new(account_id).transfer(env::storage_byte_cost().saturating_mul(storage_released)) +} + +//refund a map of approved account IDs and send the funds to the passed in account ID +pub(crate) fn refund_approved_account_ids( + account_id: AccountId, + approved_account_ids: &HashMap, +) -> Promise { + //call the refund_approved_account_ids_iter with the approved account IDs as keys + refund_approved_account_ids_iter(account_id, approved_account_ids.keys()) +} + +//used to generate a unique prefix in our storage collections (this is to avoid data collisions) +pub(crate) fn hash_account_id(account_id: &AccountId) -> CryptoHash { + //get the default hash + let mut hash = CryptoHash::default(); + //we hash the account ID and return it + hash.copy_from_slice(&env::sha256(account_id.as_bytes())); + hash +} + +//used to make sure the user attached exactly 1 yoctoNEAR +pub(crate) fn assert_one_yocto() { + assert_eq!( + env::attached_deposit(), + NearToken::from_yoctonear(1), + "Requires attached deposit of exactly 1 yoctoNEAR", + ) +} + +//Assert that the user has attached at least 1 yoctoNEAR (for security reasons and to pay for storage) +pub(crate) fn assert_at_least_one_yocto() { + assert!( + env::attached_deposit() >= NearToken::from_yoctonear(1), + "Requires attached deposit of at least 1 yoctoNEAR", + ) +} + +//refund the initial deposit based on the amount of storage that was used up +pub(crate) fn refund_deposit(storage_used: u128) { + //get how much it would cost to store the information + let required_cost = env::storage_byte_cost().saturating_mul(storage_used); + //get the attached deposit + let attached_deposit = env::attached_deposit(); + + //make sure that the attached deposit is greater than or equal to the required cost + assert!( + required_cost <= attached_deposit, + "Must attach {} yoctoNEAR to cover storage", + required_cost, + ); + + //get the refund amount from the attached deposit - required cost + let refund = attached_deposit.saturating_sub(required_cost); + + //if the refund is greater than 1 yocto NEAR, we refund the predecessor that amount + if refund.gt(&ONE_YOCTONEAR) { + Promise::new(env::predecessor_account_id()).transfer(refund); + } +} + +impl Contract { + //add a token to the set of tokens an owner has + pub(crate) fn internal_add_token_to_owner( + &mut self, + account_id: &AccountId, + token_id: &TokenId, + ) { + //get the set of tokens for the given account + let mut tokens_set = self.tokens_per_owner.get(account_id).unwrap_or_else(|| { + //if the account doesn't have any tokens, we create a new unordered set + UnorderedSet::new( + StorageKey::TokenPerOwnerInner { + //we get a new unique prefix for the collection + account_id_hash: hash_account_id(&account_id), + }, + ) + }); + + //we insert the token ID into the set + tokens_set.insert(token_id); + + //we insert that set for the given account ID. + self.tokens_per_owner.insert(account_id, &tokens_set); + } + + //remove a token from an owner (internal method and can't be called directly via CLI). + pub(crate) fn internal_remove_token_from_owner( + &mut self, + account_id: &AccountId, + token_id: &TokenId, + ) { + //we get the set of tokens that the owner has + let mut tokens_set = self + .tokens_per_owner + .get(account_id) + //if there is no set of tokens for the owner, we panic with the following message: + .expect("Token should be owned by the sender"); + + //we remove the the token_id from the set of tokens + tokens_set.remove(token_id); + + //if the token set is now empty, we remove the owner from the tokens_per_owner collection + if tokens_set.is_empty() { + self.tokens_per_owner.remove(account_id); + } else { + //if the token set is not empty, we simply insert it back for the account ID. + self.tokens_per_owner.insert(account_id, &tokens_set); + } + } + + //transfers the NFT to the receiver_id (internal method and can't be called directly via CLI). + pub(crate) fn internal_transfer( + &mut self, + sender_id: &AccountId, + receiver_id: &AccountId, + token_id: &TokenId, + //we introduce an approval ID so that people with that approval ID can transfer the token + approval_id: Option, + memo: Option, + ) -> Token { + //get the token object by passing in the token_id + let token = self.tokens_by_id.get(token_id).expect("No token"); + + //if the sender doesn't equal the owner, we check if the sender is in the approval list + if sender_id != &token.owner_id { + //if the token's approved account IDs doesn't contain the sender, we panic + if !token.approved_account_ids.contains_key(sender_id) { + env::panic_str("Unauthorized"); + } + + // If they included an approval_id, check if the sender's actual approval_id is the same as the one included + if let Some(enforced_approval_id) = approval_id { + //get the actual approval ID + let actual_approval_id = token + .approved_account_ids + .get(sender_id) + //if the sender isn't in the map, we panic + .expect("Sender is not approved account"); + + //make sure that the actual approval ID is the same as the one provided + assert_eq!( + actual_approval_id, &enforced_approval_id, + "The actual approval_id {} is different from the given approval_id {}", + actual_approval_id, enforced_approval_id, + ); + } + } + + //we make sure that the sender isn't sending the token to themselves + assert_ne!( + &token.owner_id, receiver_id, + "The token owner and the receiver should be different" + ); + + //we remove the token from it's current owner's set + self.internal_remove_token_from_owner(&token.owner_id, token_id); + //we then add the token to the receiver_id's set + self.internal_add_token_to_owner(receiver_id, token_id); + + //we create a new token struct + let new_token = Token { + owner_id: receiver_id.clone(), + //reset the approval account IDs + approved_account_ids: Default::default(), + next_approval_id: token.next_approval_id, + }; + //insert that new token into the tokens_by_id, replacing the old entry + self.tokens_by_id.insert(token_id, &new_token); + + //if there was some memo attached, we log it. + if let Some(memo) = memo.as_ref() { + env::log_str(&format!("Memo: {}", memo).to_string()); + } + + // Default the authorized ID to be None for the logs. + let mut authorized_id = None; + //if the approval ID was provided, set the authorized ID equal to the sender + if approval_id.is_some() { + authorized_id = Some(sender_id.to_string()); + } + + // Construct the transfer log as per the events standard. + let nft_transfer_log: EventLog = EventLog { + // Standard name ("nep171"). + standard: NFT_STANDARD_NAME.to_string(), + // Version of the standard ("nft-1.0.0"). + version: NFT_METADATA_SPEC.to_string(), + // The data related with the event stored in a vector. + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + // The optional authorized account ID to transfer the token on behalf of the old owner. + authorized_id, + // The old owner's account ID. + old_owner_id: token.owner_id.to_string(), + // The account ID of the new owner of the token. + new_owner_id: receiver_id.to_string(), + // A vector containing the token IDs as strings. + token_ids: vec![token_id.to_string()], + // An optional memo to include. + memo, + }]), + }; + + // Log the serialized json. + env::log_str(&nft_transfer_log.to_string()); + + //return the previous token object that was transferred. + token + } +} \ No newline at end of file diff --git a/nft-contract/src/lib.rs b/nft-contract-approval/src/lib.rs similarity index 81% rename from nft-contract/src/lib.rs rename to nft-contract-approval/src/lib.rs index a870e6a..309e8f1 100644 --- a/nft-contract/src/lib.rs +++ b/nft-contract-approval/src/lib.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; -use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::borsh::{BorshSerialize, BorshDeserialize}; use near_sdk::collections::{LazyOption, LookupMap, UnorderedMap, UnorderedSet}; -use near_sdk::json_types::{Base64VecU8, U128}; +use near_sdk::json_types::{Base64VecU8, U64, U128}; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::{ - env, near_bindgen, AccountId, Balance, CryptoHash, PanicOnDefault, Promise, PromiseOrValue, + near_bindgen, env, AccountId, NearToken, CryptoHash, PanicOnDefault, Promise, PromiseOrValue, BorshStorageKey, NearSchema }; use crate::internal::*; @@ -16,12 +16,12 @@ pub use crate::royalty::*; pub use crate::events::*; mod internal; -mod approval; mod enumeration; mod metadata; mod mint; mod nft_core; -mod royalty; +mod approval; +mod royalty; mod events; /// This spec can be treated like a version of the standard. @@ -29,8 +29,12 @@ pub const NFT_METADATA_SPEC: &str = "1.0.0"; /// This is the name of the NFT standard we're using pub const NFT_STANDARD_NAME: &str = "nep171"; +//Basic NEAR amounts as constants +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); + #[near_bindgen] -#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] pub struct Contract { //contract owner pub owner_id: AccountId, @@ -49,7 +53,8 @@ pub struct Contract { } /// Helper structure for keys of the persistent collections. -#[derive(BorshSerialize)] +#[derive(BorshSerialize, BorshStorageKey)] +#[borsh(crate = "near_sdk::borsh")] pub enum StorageKey { TokensPerOwner, TokenPerOwnerInner { account_id_hash: CryptoHash }, @@ -95,15 +100,13 @@ impl Contract { //create a variable of type Self with all the fields initialized. let this = Self { //Storage keys are simply the prefixes used for the collections. This helps avoid data collision - tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner.try_to_vec().unwrap()), - tokens_by_id: LookupMap::new(StorageKey::TokensById.try_to_vec().unwrap()), - token_metadata_by_id: UnorderedMap::new( - StorageKey::TokenMetadataById.try_to_vec().unwrap(), - ), + tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner), + tokens_by_id: LookupMap::new(StorageKey::TokensById), + token_metadata_by_id: UnorderedMap::new(StorageKey::TokenMetadataById), //set the owner_id field equal to the passed in owner_id. owner_id, metadata: LazyOption::new( - StorageKey::NFTContractMetadata.try_to_vec().unwrap(), + StorageKey::NFTContractMetadata, Some(&metadata), ), }; @@ -111,7 +114,4 @@ impl Contract { //return the Contract object this } -} - -#[cfg(test)] -mod tests; \ No newline at end of file +} \ No newline at end of file diff --git a/nft-contract-approval/src/metadata.rs b/nft-contract-approval/src/metadata.rs new file mode 100644 index 0000000..1c9ab53 --- /dev/null +++ b/nft-contract-approval/src/metadata.rs @@ -0,0 +1,76 @@ +use crate::*; +pub type TokenId = String; +//defines the payout type we'll be returning as a part of the royalty standards. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct Payout { + pub payout: HashMap, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct NFTContractMetadata { + pub spec: String, // required, essentially a version like "nft-1.0.0" + pub name: String, // required, ex. "Mosaics" + pub symbol: String, // required, ex. "MOSAIC" + pub icon: Option, // Data URL + pub base_uri: Option, // Centralized gateway known to have reliable access to decentralized storage assets referenced by `reference` or `media` URLs + pub reference: Option, // URL to a JSON file with more info + pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct TokenMetadata { + pub title: Option, // ex. "Arch Nemesis: Mail Carrier" or "Parcel #5055" + pub description: Option, // free-form description + pub media: Option, // URL to associated media, preferably to decentralized, content-addressed storage + pub media_hash: Option, // Base64-encoded sha256 hash of content referenced by the `media` field. Required if `media` is included. + pub copies: Option, // number of copies of this set of metadata in existence when token was minted. + pub issued_at: Option, // When token was issued or minted, Unix epoch in milliseconds + pub expires_at: Option, // When token expires, Unix epoch in milliseconds + pub starts_at: Option, // When token starts being valid, Unix epoch in milliseconds + pub updated_at: Option, // When token was last updated, Unix epoch in milliseconds + pub extra: Option, // anything extra the NFT wants to store on-chain. Can be stringified JSON. + pub reference: Option, // URL to an off-chain JSON file with more info. + pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. +} + +#[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Token { + //owner of the token + pub owner_id: AccountId, + //list of approved account IDs that have access to transfer the token. This maps an account ID to an approval ID + pub approved_account_ids: HashMap, + //the next approval ID to give out. + pub next_approval_id: u64, +} + +//The Json token is what will be returned from view calls. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct JsonToken { + //token ID + pub token_id: TokenId, + //owner of the token + pub owner_id: AccountId, + //token metadata + pub metadata: TokenMetadata, + //list of approved account IDs that have access to transfer the token. This maps an account ID to an approval ID + pub approved_account_ids: HashMap, +} + +pub trait NonFungibleTokenMetadata { + //view call for returning the contract metadata + fn nft_metadata(&self) -> NFTContractMetadata; +} + +#[near_bindgen] +impl NonFungibleTokenMetadata for Contract { + fn nft_metadata(&self) -> NFTContractMetadata { + self.metadata.get().unwrap() + } +} \ No newline at end of file diff --git a/nft-contract-approval/src/mint.rs b/nft-contract-approval/src/mint.rs new file mode 100644 index 0000000..cb053e7 --- /dev/null +++ b/nft-contract-approval/src/mint.rs @@ -0,0 +1,78 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + #[payable] + pub fn nft_mint( + &mut self, + token_id: TokenId, + metadata: TokenMetadata, + receiver_id: AccountId, + //we add an optional parameter for perpetual royalties + perpetual_royalties: Option>, + ) { + //measure the initial storage being used on the contract + let initial_storage_usage = env::storage_usage(); + + // create a royalty map to store in the token + let mut royalty = HashMap::new(); + + // if perpetual royalties were passed into the function: + if let Some(perpetual_royalties) = perpetual_royalties { + //make sure that the length of the perpetual royalties is below 7 since we won't have enough GAS to pay out that many people + assert!(perpetual_royalties.len() < 7, "Cannot add more than 6 perpetual royalty amounts"); + + //iterate through the perpetual royalties and insert the account and amount in the royalty map + for (account, amount) in perpetual_royalties { + royalty.insert(account, amount); + } + } + + //specify the token struct that contains the owner ID + let token = Token { + owner_id: receiver_id, + //we set the approved account IDs to the default value (an empty map) + approved_account_ids: Default::default(), + //the next approval ID is set to 0 + next_approval_id: 0, + }; + + //insert the token ID and token struct and make sure that the token doesn't exist + assert!( + self.tokens_by_id.insert(&token_id, &token).is_none(), + "Token already exists" + ); + + //insert the token ID and metadata + self.token_metadata_by_id.insert(&token_id, &metadata); + + //call the internal method for adding the token to the owner + self.internal_add_token_to_owner(&token.owner_id, &token_id); + + // Construct the mint log as per the events standard. + let nft_mint_log: EventLog = EventLog { + // Standard name ("nep171"). + standard: NFT_STANDARD_NAME.to_string(), + // Version of the standard ("nft-1.0.0"). + version: NFT_METADATA_SPEC.to_string(), + // The data related with the event stored in a vector. + event: EventLogVariant::NftMint(vec![NftMintLog { + // Owner of the token. + owner_id: token.owner_id.to_string(), + // Vector of token IDs that were minted. + token_ids: vec![token_id.to_string()], + // An optional memo to include. + memo: None, + }]), + }; + + // Log the serialized json. + env::log_str(&nft_mint_log.to_string()); + + //calculate the required storage which was the used - initial + let required_storage_in_bytes = env::storage_usage() - initial_storage_usage; + + //refund any excess storage if the user attached too much. Panic if they didn't attach enough to cover the required. + refund_deposit(required_storage_in_bytes.into()); + } +} \ No newline at end of file diff --git a/nft-contract-approval/src/nft_core.rs b/nft-contract-approval/src/nft_core.rs new file mode 100644 index 0000000..1be044e --- /dev/null +++ b/nft-contract-approval/src/nft_core.rs @@ -0,0 +1,280 @@ +use crate::*; +use near_sdk::{ext_contract, Gas, log, PromiseResult}; + +const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(10); +const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas::from_tgas(25); + +pub trait NonFungibleTokenCore { + //transfers an NFT to a receiver ID + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + //we introduce an approval ID so that people with that approval ID can transfer the token + approval_id: Option, + memo: Option, + ); + + // Transfers an NFT to a receiver and calls the + // function `nft_on_transfer` on their contract. + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + //we introduce an approval ID so that people with that approval ID can transfer the token + approval_id: Option, + memo: Option, + msg: String, + ) -> PromiseOrValue; + + //get information about the NFT token passed in + fn nft_token(&self, token_id: TokenId) -> Option; +} + +#[ext_contract(ext_non_fungible_token_receiver)] +trait NonFungibleTokenReceiver { + //Method stored on the receiver contract that is called via cross contract call when nft_transfer_call is called + /// Returns `true` if the token should be returned back to the sender. + fn nft_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_id: AccountId, + token_id: TokenId, + msg: String, + ) -> Promise; +} + +#[ext_contract(ext_self)] +trait NonFungibleTokenResolver { + /* + resolves the promise of the cross contract call to the receiver contract + this is stored on THIS contract and is meant to analyze what happened in the cross contract call when nft_on_transfer was called + as part of the nft_transfer_call method + */ + fn nft_resolve_transfer( + &mut self, + //we introduce an authorized ID for logging the transfer event + authorized_id: Option, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + //we introduce the approval map so we can keep track of what the approvals were before the transfer + approved_account_ids: HashMap, + //we introduce a memo for logging the transfer event + memo: Option, + ) -> bool; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //implementation of the nft_transfer method. This transfers the NFT from the current owner to the receiver. + #[payable] + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + //we introduce an approval ID so that people with that approval ID can transfer the token + approval_id: Option, + memo: Option, + ) { + //assert that the user attached exactly 1 yoctoNEAR. This is for security and so that the user will be redirected to the NEAR wallet. + assert_one_yocto(); + //get the sender to transfer the token from the sender to the receiver + let sender_id = env::predecessor_account_id(); + + //call the internal transfer method and get back the previous token so we can refund the approved account IDs + let previous_token = self.internal_transfer( + &sender_id, + &receiver_id, + &token_id, + approval_id, + memo, + ); + + //we refund the owner for releasing the storage used up by the approved account IDs + refund_approved_account_ids( + previous_token.owner_id.clone(), + &previous_token.approved_account_ids, + ); + } + + //implementation of the transfer call method. This will transfer the NFT and call a method on the receiver_id contract + #[payable] + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + //we introduce an approval ID so that people with that approval ID can transfer the token + approval_id: Option, + memo: Option, + msg: String, + ) -> PromiseOrValue { + //assert that the user attached exactly 1 yocto for security reasons. + assert_one_yocto(); + + //get the sender ID + let sender_id = env::predecessor_account_id(); + + //transfer the token and get the previous token object + let previous_token = self.internal_transfer( + &sender_id, + &receiver_id, + &token_id, + approval_id, + memo.clone(), + ); + + //default the authorized_id to none + let mut authorized_id = None; + //if the sender isn't the owner of the token, we set the authorized ID equal to the sender. + if sender_id != previous_token.owner_id { + authorized_id = Some(sender_id.to_string()); + } + + // Initiating receiver's call and the callback + // Defaulting GAS weight to 1, no attached deposit, and static GAS equal to the GAS for nft on transfer. + ext_non_fungible_token_receiver::ext(receiver_id.clone()) + .with_static_gas(GAS_FOR_NFT_ON_TRANSFER) + .nft_on_transfer( + sender_id, + previous_token.owner_id.clone(), + token_id.clone(), + msg + ) + // We then resolve the promise and call nft_resolve_transfer on our own contract + .then( + // Defaulting GAS weight to 1, no attached deposit, and static GAS equal to the GAS for resolve transfer + Self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) + .nft_resolve_transfer( + authorized_id, // we introduce an authorized ID so that we can log the transfer + previous_token.owner_id, + receiver_id, + token_id, + previous_token.approved_account_ids, + memo, // we introduce a memo for logging in the events standard + ) + ).into() + } + + //get the information for a specific token ID + fn nft_token(&self, token_id: TokenId) -> Option { + //if there is some token ID in the tokens_by_id collection + if let Some(token) = self.tokens_by_id.get(&token_id) { + //we'll get the metadata for that token + let metadata = self.token_metadata_by_id.get(&token_id).unwrap(); + //we return the JsonToken (wrapped by Some since we return an option) + Some(JsonToken { + token_id, + owner_id: token.owner_id, + metadata, + approved_account_ids: token.approved_account_ids, + }) + } else { //if there wasn't a token ID in the tokens_by_id collection, we return None + None + } + } +} + +#[near_bindgen] +impl NonFungibleTokenResolver for Contract { + //resolves the cross contract call when calling nft_on_transfer in the nft_transfer_call method + //returns true if the token was successfully transferred to the receiver_id + #[private] + fn nft_resolve_transfer( + &mut self, + //we introduce an authorized ID for logging the transfer event + authorized_id: Option, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + //we introduce the approval map so we can keep track of what the approvals were before the transfer + approved_account_ids: HashMap, + //we introduce a memo for logging the transfer event + memo: Option, + ) -> bool { + // Whether receiver wants to return token back to the sender, based on `nft_on_transfer` + // call result. + if let PromiseResult::Successful(value) = env::promise_result(0) { + //As per the standard, the nft_on_transfer should return whether we should return the token to it's owner or not + if let Ok(return_token) = near_sdk::serde_json::from_slice::(&value) { + //if we need don't need to return the token, we simply return true meaning everything went fine + if !return_token { + /* + since we've already transferred the token and nft_on_transfer returned false, we don't have to + revert the original transfer and thus we can just return true since nothing went wrong. + */ + //we refund the owner for releasing the storage used up by the approved account IDs + refund_approved_account_ids(owner_id, &approved_account_ids); + return true; + } + } + } + + //get the token object if there is some token object + let mut token = if let Some(token) = self.tokens_by_id.get(&token_id) { + if token.owner_id != receiver_id { + //we refund the owner for releasing the storage used up by the approved account IDs + refund_approved_account_ids(owner_id, &approved_account_ids); + // The token is not owner by the receiver anymore. Can't return it. + return true; + } + token + //if there isn't a token object, it was burned and so we return true + } else { + //we refund the owner for releasing the storage used up by the approved account IDs + refund_approved_account_ids(owner_id, &approved_account_ids); + return true; + }; + + //if at the end, we haven't returned true, that means that we should return the token to it's original owner + log!("Return {} from @{} to @{}", token_id, receiver_id, owner_id); + + //we remove the token from the receiver + self.internal_remove_token_from_owner(&receiver_id.clone(), &token_id); + //we add the token to the original owner + self.internal_add_token_to_owner(&owner_id, &token_id); + + //we change the token struct's owner to be the original owner + token.owner_id = owner_id.clone(); + + //we refund the receiver any approved account IDs that they may have set on the token + refund_approved_account_ids(receiver_id.clone(), &token.approved_account_ids); + //reset the approved account IDs to what they were before the transfer + token.approved_account_ids = approved_account_ids; + + //we inset the token back into the tokens_by_id collection + self.tokens_by_id.insert(&token_id, &token); + + /* + We need to log that the NFT was reverted back to the original owner. + The old_owner_id will be the receiver and the new_owner_id will be the + original owner of the token since we're reverting the transfer. + */ + let nft_transfer_log: EventLog = EventLog { + // Standard name ("nep171"). + standard: NFT_STANDARD_NAME.to_string(), + // Version of the standard ("nft-1.0.0"). + version: NFT_METADATA_SPEC.to_string(), + // The data related with the event stored in a vector. + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + // The optional authorized account ID to transfer the token on behalf of the old owner. + authorized_id, + // The old owner's account ID. + old_owner_id: receiver_id.to_string(), + // The account ID of the new owner of the token. + new_owner_id: owner_id.to_string(), + // A vector containing the token IDs as strings. + token_ids: vec![token_id.to_string()], + // An optional memo to include. + memo, + }]), + }; + + //we perform the actual logging + env::log_str(&nft_transfer_log.to_string()); + + //return false + false + } +} diff --git a/nft-contract-approval/src/royalty.rs b/nft-contract-approval/src/royalty.rs new file mode 100644 index 0000000..7646f9e --- /dev/null +++ b/nft-contract-approval/src/royalty.rs @@ -0,0 +1,46 @@ +use crate::*; + +pub trait NonFungibleTokenCore { + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout; + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + #[payable] + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} diff --git a/nft-contract-basic/Cargo.toml b/nft-contract-basic/Cargo.toml new file mode 100644 index 0000000..6a5b5c8 --- /dev/null +++ b/nft-contract-basic/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "nft_contract_skeleton" +version = "0.1.0" +authors = ["Near Inc "] +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk = { version = "5.1.0", features = ["legacy"] } + +[profile.release] +codegen-units = 1 +# Tell `rustc` to optimize for small code size. +opt-level = "z" +lto = true +debug = false +panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 +overflow-checks = true diff --git a/nft-contract-basic/README.md b/nft-contract-basic/README.md new file mode 100644 index 0000000..7064491 --- /dev/null +++ b/nft-contract-basic/README.md @@ -0,0 +1 @@ +# TBD diff --git a/nft-contract-basic/src/approval.rs b/nft-contract-basic/src/approval.rs new file mode 100644 index 0000000..9d72d6c --- /dev/null +++ b/nft-contract-basic/src/approval.rs @@ -0,0 +1,73 @@ +use crate::*; +use near_sdk::{ext_contract}; + +pub trait NonFungibleTokenCore { + //approve an account ID to transfer a token on your behalf + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option); + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool; + + //revoke a specific account from transferring the token on your behalf + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); + + //revoke all accounts from transferring the token on your behalf + fn nft_revoke_all(&mut self, token_id: TokenId); +} + +#[ext_contract(ext_non_fungible_approval_receiver)] +trait NonFungibleTokenApprovalsReceiver { + //cross contract call to an external contract that is initiated during nft_approve + fn nft_on_approve( + &mut self, + token_id: TokenId, + owner_id: AccountId, + approval_id: u64, + msg: String, + ); +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //allow a specific account ID to approve a token on your behalf + #[payable] + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option) { + /* + FILL THIS IN + */ + } + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //revoke a specific account from transferring the token on your behalf + #[payable] + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId) { + /* + FILL THIS IN + */ + } + + //revoke all accounts from transferring the token on your behalf + #[payable] + fn nft_revoke_all(&mut self, token_id: TokenId) { + /* + FILL THIS IN + */ + } +} \ No newline at end of file diff --git a/nft-contract-basic/src/enumeration.rs b/nft-contract-basic/src/enumeration.rs new file mode 100644 index 0000000..5926561 --- /dev/null +++ b/nft-contract-basic/src/enumeration.rs @@ -0,0 +1,76 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + //Query for the total supply of NFTs on the contract + pub fn nft_total_supply(&self) -> U64 { + //return the length of the token metadata by ID + U64(self.token_metadata_by_id.len()) + } + + //Query for nft tokens on the contract regardless of the owner using pagination + pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { + //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index + let start = from_index.unwrap_or(0); + + //iterate through each token using an iterator + self.token_metadata_by_id.keys() + //skip to the index we specified in the start variable + .skip(start as usize) + //take the first "limit" elements in the vector. If we didn't specify a limit, use 50 + .take(limit.unwrap_or(50) as usize) + //we'll map the token IDs which are strings into Json Tokens + .map(|token_id| self.nft_token(token_id.clone()).unwrap()) + //since we turned the keys into an iterator, we need to turn it back into a vector to return + .collect() + } + + //get the total supply of NFTs for a given owner + pub fn nft_supply_for_owner( + &self, + account_id: AccountId, + ) -> U64 { + //get the set of tokens for the passed in owner + let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); + + //if there is some set of tokens, we'll return the length + if let Some(tokens_for_owner_set) = tokens_for_owner_set { + U64(tokens_for_owner_set.len()) + } else { + //if there isn't a set of tokens for the passed in account ID, we'll return 0 + U64(0) + } + } + + //Query for all the tokens for an owner + pub fn nft_tokens_for_owner( + &self, + account_id: AccountId, + from_index: Option, + limit: Option, + ) -> Vec { + //get the set of tokens for the passed in owner + let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); + //if there is some set of tokens, we'll set the tokens variable equal to that set + let tokens = if let Some(tokens_for_owner_set) = tokens_for_owner_set { + tokens_for_owner_set + } else { + //if there is no set of tokens, we'll simply return an empty vector. + return vec![]; + }; + + //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index + let start = from_index.unwrap_or(0); + + //iterate through the keys vector + tokens.iter() + //skip to the index we specified in the start variable + .skip(start as usize) + //take the first "limit" elements in the vector. If we didn't specify a limit, use 50 + .take(limit.unwrap_or(50) as usize) + //we'll map the token IDs which are strings into Json Tokens + .map(|token_id| self.nft_token(token_id.clone()).unwrap()) + //since we turned the keys into an iterator, we need to turn it back into a vector to return + .collect() + } +} \ No newline at end of file diff --git a/nft-contract-basic/src/internal.rs b/nft-contract-basic/src/internal.rs new file mode 100644 index 0000000..f2bb2da --- /dev/null +++ b/nft-contract-basic/src/internal.rs @@ -0,0 +1,133 @@ +use crate::*; +use near_sdk::{CryptoHash}; +use std::mem::size_of; + +//used to generate a unique prefix in our storage collections (this is to avoid data collisions) +pub(crate) fn hash_account_id(account_id: &AccountId) -> CryptoHash { + //get the default hash + let mut hash = CryptoHash::default(); + //we hash the account ID and return it + hash.copy_from_slice(&env::sha256(account_id.as_bytes())); + hash +} + +//used to make sure the user attached exactly 1 yoctoNEAR +pub(crate) fn assert_one_yocto() { + assert_eq!( + env::attached_deposit(), + NearToken::from_yoctonear(1), + "Requires attached deposit of exactly 1 yoctoNEAR", + ) +} + +//refund the initial deposit based on the amount of storage that was used up +pub(crate) fn refund_deposit(storage_used: u128) { + //get how much it would cost to store the information + let required_cost = env::storage_byte_cost().saturating_mul(storage_used); + //get the attached deposit + let attached_deposit = env::attached_deposit(); + + //make sure that the attached deposit is greater than or equal to the required cost + assert!( + required_cost <= attached_deposit, + "Must attach {} yoctoNEAR to cover storage", + required_cost, + ); + + //get the refund amount from the attached deposit - required cost + let refund = attached_deposit.saturating_sub(required_cost); + + //if the refund is greater than 1 yocto NEAR, we refund the predecessor that amount + if refund.gt(&ONE_YOCTONEAR) { + Promise::new(env::predecessor_account_id()).transfer(refund); + } +} + +impl Contract { + //add a token to the set of tokens an owner has + pub(crate) fn internal_add_token_to_owner( + &mut self, + account_id: &AccountId, + token_id: &TokenId, + ) { + //get the set of tokens for the given account + let mut tokens_set = self.tokens_per_owner.get(account_id).unwrap_or_else(|| { + //if the account doesn't have any tokens, we create a new unordered set + UnorderedSet::new( + StorageKey::TokenPerOwnerInner { + //we get a new unique prefix for the collection + account_id_hash: hash_account_id(&account_id), + }, + ) + }); + + //we insert the token ID into the set + tokens_set.insert(token_id); + + //we insert that set for the given account ID. + self.tokens_per_owner.insert(account_id, &tokens_set); + } + + //remove a token from an owner (internal method and can't be called directly via CLI). + pub(crate) fn internal_remove_token_from_owner( + &mut self, + account_id: &AccountId, + token_id: &TokenId, + ) { + //we get the set of tokens that the owner has + let mut tokens_set = self + .tokens_per_owner + .get(account_id) + //if there is no set of tokens for the owner, we panic with the following message: + .expect("Token should be owned by the sender"); + + //we remove the the token_id from the set of tokens + tokens_set.remove(token_id); + + //if the token set is now empty, we remove the owner from the tokens_per_owner collection + if tokens_set.is_empty() { + self.tokens_per_owner.remove(account_id); + } else { + //if the token set is not empty, we simply insert it back for the account ID. + self.tokens_per_owner.insert(account_id, &tokens_set); + } + } + + //transfers the NFT to the receiver_id (internal method and can't be called directly via CLI). + pub(crate) fn internal_transfer( + &mut self, + sender_id: &AccountId, + receiver_id: &AccountId, + token_id: &TokenId, + memo: Option, + ) -> Token { + //get the token object by passing in the token_id + let token = self.tokens_by_id.get(token_id).expect("No token"); + + //we make sure that the sender isn't sending the token to themselves + assert_ne!( + &token.owner_id, receiver_id, + "The token owner and the receiver should be different" + ); + + //we remove the token from it's current owner's set + self.internal_remove_token_from_owner(&token.owner_id, token_id); + //we then add the token to the receiver_id's set + self.internal_add_token_to_owner(receiver_id, token_id); + + //we create a new token struct + let new_token = Token { + owner_id: receiver_id.clone(), + }; + //insert that new token into the tokens_by_id, replacing the old entry + self.tokens_by_id.insert(token_id, &new_token); + + //if there was some memo attached, we log it. + if let Some(memo) = memo.as_ref() { + env::log_str(&format!("Memo: {}", memo).to_string()); + } + + //return the previous token object that was transferred. + token + } +} \ No newline at end of file diff --git a/nft-contract-basic/src/lib.rs b/nft-contract-basic/src/lib.rs new file mode 100644 index 0000000..06f2f8b --- /dev/null +++ b/nft-contract-basic/src/lib.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; +use near_sdk::borsh::{BorshSerialize, BorshDeserialize}; +use near_sdk::collections::{LazyOption, LookupMap, UnorderedMap, UnorderedSet}; +use near_sdk::json_types::{Base64VecU8, U64, U128}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::{ + near_bindgen, env, AccountId, NearToken, CryptoHash, PanicOnDefault, Promise, PromiseOrValue, BorshStorageKey, NearSchema +}; + +use crate::internal::*; +pub use crate::metadata::*; +pub use crate::mint::*; +pub use crate::nft_core::*; +pub use crate::approval::*; +pub use crate::royalty::*; + +mod internal; +mod enumeration; +mod metadata; +mod mint; +mod nft_core; +mod approval; +mod royalty; + +/// This spec can be treated like a version of the standard. +pub const NFT_METADATA_SPEC: &str = "1.0.0"; +/// This is the name of the NFT standard we're using +pub const NFT_STANDARD_NAME: &str = "nep171"; + +//Basic NEAR amounts as constants +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); + +#[near_bindgen] +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Contract { + //contract owner + pub owner_id: AccountId, + + //keeps track of all the token IDs for a given account + pub tokens_per_owner: LookupMap>, + + //keeps track of the token struct for a given token ID + pub tokens_by_id: LookupMap, + + //keeps track of the token metadata for a given token ID + pub token_metadata_by_id: UnorderedMap, + + //keeps track of the metadata for the contract + pub metadata: LazyOption, +} + +/// Helper structure for keys of the persistent collections. +#[derive(BorshSerialize, BorshStorageKey)] +#[borsh(crate = "near_sdk::borsh")] +pub enum StorageKey { + TokensPerOwner, + TokenPerOwnerInner { account_id_hash: CryptoHash }, + TokensById, + TokenMetadataById, + NFTContractMetadata, + TokensPerType, + TokensPerTypeInner { token_type_hash: CryptoHash }, + TokenTypesLocked, +} + +#[near_bindgen] +impl Contract { + /* + initialization function (can only be called once). + this initializes the contract with default metadata so the + user doesn't have to manually type metadata. + */ + #[init] + pub fn new_default_meta(owner_id: AccountId) -> Self { + //calls the other function "new: with some default metadata and the owner_id passed in + Self::new( + owner_id, + NFTContractMetadata { + spec: "nft-1.0.0".to_string(), + name: "NFT Tutorial Contract".to_string(), + symbol: "GOTEAM".to_string(), + icon: None, + base_uri: None, + reference: None, + reference_hash: None, + }, + ) + } + + /* + initialization function (can only be called once). + this initializes the contract with metadata that was passed in and + the owner_id. + */ + #[init] + pub fn new(owner_id: AccountId, metadata: NFTContractMetadata) -> Self { + //create a variable of type Self with all the fields initialized. + let this = Self { + //Storage keys are simply the prefixes used for the collections. This helps avoid data collision + tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner), + tokens_by_id: LookupMap::new(StorageKey::TokensById), + token_metadata_by_id: UnorderedMap::new(StorageKey::TokenMetadataById), + //set the owner_id field equal to the passed in owner_id. + owner_id, + metadata: LazyOption::new( + StorageKey::NFTContractMetadata, + Some(&metadata), + ), + }; + + //return the Contract object + this + } +} \ No newline at end of file diff --git a/nft-contract-basic/src/metadata.rs b/nft-contract-basic/src/metadata.rs new file mode 100644 index 0000000..41de708 --- /dev/null +++ b/nft-contract-basic/src/metadata.rs @@ -0,0 +1,70 @@ +use crate::*; +pub type TokenId = String; +//defines the payout type we'll be returning as a part of the royalty standards. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct Payout { + pub payout: HashMap, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct NFTContractMetadata { + pub spec: String, // required, essentially a version like "nft-1.0.0" + pub name: String, // required, ex. "Mosaics" + pub symbol: String, // required, ex. "MOSAIC" + pub icon: Option, // Data URL + pub base_uri: Option, // Centralized gateway known to have reliable access to decentralized storage assets referenced by `reference` or `media` URLs + pub reference: Option, // URL to a JSON file with more info + pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct TokenMetadata { + pub title: Option, // ex. "Arch Nemesis: Mail Carrier" or "Parcel #5055" + pub description: Option, // free-form description + pub media: Option, // URL to associated media, preferably to decentralized, content-addressed storage + pub media_hash: Option, // Base64-encoded sha256 hash of content referenced by the `media` field. Required if `media` is included. + pub copies: Option, // number of copies of this set of metadata in existence when token was minted. + pub issued_at: Option, // When token was issued or minted, Unix epoch in milliseconds + pub expires_at: Option, // When token expires, Unix epoch in milliseconds + pub starts_at: Option, // When token starts being valid, Unix epoch in milliseconds + pub updated_at: Option, // When token was last updated, Unix epoch in milliseconds + pub extra: Option, // anything extra the NFT wants to store on-chain. Can be stringified JSON. + pub reference: Option, // URL to an off-chain JSON file with more info. + pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. +} + +#[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Token { + //owner of the token + pub owner_id: AccountId, +} + +//The Json token is what will be returned from view calls. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct JsonToken { + //token ID + pub token_id: TokenId, + //owner of the token + pub owner_id: AccountId, + //token metadata + pub metadata: TokenMetadata, +} + +pub trait NonFungibleTokenMetadata { + //view call for returning the contract metadata + fn nft_metadata(&self) -> NFTContractMetadata; +} + +#[near_bindgen] +impl NonFungibleTokenMetadata for Contract { + fn nft_metadata(&self) -> NFTContractMetadata { + self.metadata.get().unwrap() + } +} \ No newline at end of file diff --git a/nft-contract-basic/src/mint.rs b/nft-contract-basic/src/mint.rs new file mode 100644 index 0000000..ae0b238 --- /dev/null +++ b/nft-contract-basic/src/mint.rs @@ -0,0 +1,39 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + #[payable] + pub fn nft_mint( + &mut self, + token_id: TokenId, + metadata: TokenMetadata, + receiver_id: AccountId, + ) { + //measure the initial storage being used on the contract + let initial_storage_usage = env::storage_usage(); + + //specify the token struct that contains the owner ID + let token = Token { + //set the owner ID equal to the receiver ID passed into the function + owner_id: receiver_id, + }; + + //insert the token ID and token struct and make sure that the token doesn't exist + assert!( + self.tokens_by_id.insert(&token_id, &token).is_none(), + "Token already exists" + ); + + //insert the token ID and metadata + self.token_metadata_by_id.insert(&token_id, &metadata); + + //call the internal method for adding the token to the owner + self.internal_add_token_to_owner(&token.owner_id, &token_id); + + //calculate the required storage which was the used - initial + let required_storage_in_bytes = env::storage_usage() - initial_storage_usage; + + //refund any excess storage if the user attached too much. Panic if they didn't attach enough to cover the required. + refund_deposit(required_storage_in_bytes.into()); + } +} \ No newline at end of file diff --git a/nft-contract-basic/src/nft_core.rs b/nft-contract-basic/src/nft_core.rs new file mode 100644 index 0000000..1c91a14 --- /dev/null +++ b/nft-contract-basic/src/nft_core.rs @@ -0,0 +1,201 @@ +use crate::*; +use near_sdk::{ext_contract, Gas, log, PromiseResult}; + +const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(10); +const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas::from_tgas(25); + +pub trait NonFungibleTokenCore { + //transfers an NFT to a receiver ID + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + ); + + //transfers an NFT to a receiver and calls a function on the receiver ID's contract + /// Returns `true` if the token was transferred from the sender's account. + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + msg: String, + ) -> PromiseOrValue; + + //get information about the NFT token passed in + fn nft_token(&self, token_id: TokenId) -> Option; +} + +#[ext_contract(ext_non_fungible_token_receiver)] +trait NonFungibleTokenReceiver { + //Method stored on the receiver contract that is called via cross contract call when nft_transfer_call is called + /// Returns `true` if the token should be returned back to the sender. + fn nft_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_id: AccountId, + token_id: TokenId, + msg: String, + ) -> Promise; +} + +#[ext_contract(ext_self)] +trait NonFungibleTokenResolver { + /* + resolves the promise of the cross contract call to the receiver contract + this is stored on THIS contract and is meant to analyze what happened in the cross contract call when nft_on_transfer was called + as part of the nft_transfer_call method + */ + fn nft_resolve_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + ) -> bool; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //implementation of the nft_transfer method. This transfers the NFT from the current owner to the receiver. + #[payable] + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + ) { + //assert that the user attached exactly 1 yoctoNEAR. This is for security and so that the user will be redirected to the NEAR wallet. + assert_one_yocto(); + //get the sender to transfer the token from the sender to the receiver + let sender_id = env::predecessor_account_id(); + + //call the internal transfer method + self.internal_transfer( + &sender_id, + &receiver_id, + &token_id, + memo + ); + } + + //implementation of the transfer call method. This will transfer the NFT and call a method on the receiver_id contract + #[payable] + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + msg: String, + ) -> PromiseOrValue { + //assert that the user attached exactly 1 yocto for security reasons. + assert_one_yocto(); + + //get the sender ID + let sender_id = env::predecessor_account_id(); + + //transfer the token and get the previous token object + let previous_token = self.internal_transfer( + &sender_id, + &receiver_id, + &token_id, + memo.clone(), + ); + + // Initiating receiver's call and the callback + // Defaulting GAS weight to 1, no attached deposit, and static GAS equal to the GAS for nft on transfer. + ext_non_fungible_token_receiver::ext(receiver_id.clone()) + .with_static_gas(GAS_FOR_NFT_ON_TRANSFER) + .nft_on_transfer( + sender_id, + previous_token.owner_id.clone(), + token_id.clone(), + msg + ) + // We then resolve the promise and call nft_resolve_transfer on our own contract + .then( + // Defaulting GAS weight to 1, no attached deposit, and static GAS equal to the GAS for resolve transfer + Self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) + .nft_resolve_transfer( + previous_token.owner_id, + receiver_id, + token_id, + ) + ).into() + } + + //get the information for a specific token ID + fn nft_token(&self, token_id: TokenId) -> Option { + //if there is some token ID in the tokens_by_id collection + if let Some(token) = self.tokens_by_id.get(&token_id) { + //we'll get the metadata for that token + let metadata = self.token_metadata_by_id.get(&token_id).unwrap(); + //we return the JsonToken (wrapped by Some since we return an option) + Some(JsonToken { + token_id, + owner_id: token.owner_id, + metadata, + }) + } else { //if there wasn't a token ID in the tokens_by_id collection, we return None + None + } + } +} + +#[near_bindgen] +impl NonFungibleTokenResolver for Contract { + //resolves the cross contract call when calling nft_on_transfer in the nft_transfer_call method + //returns true if the token was successfully transferred to the receiver_id + #[private] + fn nft_resolve_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + ) -> bool { + // Whether receiver wants to return token back to the sender, based on `nft_on_transfer` + // call result. + if let PromiseResult::Successful(value) = env::promise_result(0) { + //As per the standard, the nft_on_transfer should return whether we should return the token to it's owner or not + if let Ok(return_token) = near_sdk::serde_json::from_slice::(&value) { + //if we need don't need to return the token, we simply return true meaning everything went fine + if !return_token { + /* + since we've already transferred the token and nft_on_transfer returned false, we don't have to + revert the original transfer and thus we can just return true since nothing went wrong. + */ + return true; + } + } + } + + //get the token object if there is some token object + let mut token = if let Some(token) = self.tokens_by_id.get(&token_id) { + if token.owner_id != receiver_id { + // The token is not owner by the receiver anymore. Can't return it. + return true; + } + token + //if there isn't a token object, it was burned and so we return true + } else { + return true; + }; + + //if at the end, we haven't returned true, that means that we should return the token to it's original owner + log!("Return {} from @{} to @{}", token_id, receiver_id, owner_id); + + //we remove the token from the receiver + self.internal_remove_token_from_owner(&receiver_id, &token_id); + //we add the token to the original owner + self.internal_add_token_to_owner(&owner_id, &token_id); + + //we change the token struct's owner to be the original owner + token.owner_id = owner_id.clone(); + //we inset the token back into the tokens_by_id collection + self.tokens_by_id.insert(&token_id, &token); + + //return false + false + } +} diff --git a/nft-contract-basic/src/royalty.rs b/nft-contract-basic/src/royalty.rs new file mode 100644 index 0000000..7646f9e --- /dev/null +++ b/nft-contract-basic/src/royalty.rs @@ -0,0 +1,46 @@ +use crate::*; + +pub trait NonFungibleTokenCore { + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout; + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + #[payable] + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} diff --git a/nft-contract-events/Cargo.toml b/nft-contract-events/Cargo.toml new file mode 100644 index 0000000..b1b459a --- /dev/null +++ b/nft-contract-events/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nft_contract_skeleton" +version = "0.1.0" +authors = ["Near Inc "] +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk = { version = "5.1.0", features = ["legacy"] } +serde_json = "1.0.113" + +[profile.release] +codegen-units = 1 +# Tell `rustc` to optimize for small code size. +opt-level = "z" +lto = true +debug = false +panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 +overflow-checks = true diff --git a/nft-contract-events/README.md b/nft-contract-events/README.md new file mode 100644 index 0000000..7064491 --- /dev/null +++ b/nft-contract-events/README.md @@ -0,0 +1 @@ +# TBD diff --git a/nft-contract-events/src/approval.rs b/nft-contract-events/src/approval.rs new file mode 100644 index 0000000..9d72d6c --- /dev/null +++ b/nft-contract-events/src/approval.rs @@ -0,0 +1,73 @@ +use crate::*; +use near_sdk::{ext_contract}; + +pub trait NonFungibleTokenCore { + //approve an account ID to transfer a token on your behalf + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option); + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool; + + //revoke a specific account from transferring the token on your behalf + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); + + //revoke all accounts from transferring the token on your behalf + fn nft_revoke_all(&mut self, token_id: TokenId); +} + +#[ext_contract(ext_non_fungible_approval_receiver)] +trait NonFungibleTokenApprovalsReceiver { + //cross contract call to an external contract that is initiated during nft_approve + fn nft_on_approve( + &mut self, + token_id: TokenId, + owner_id: AccountId, + approval_id: u64, + msg: String, + ); +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //allow a specific account ID to approve a token on your behalf + #[payable] + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option) { + /* + FILL THIS IN + */ + } + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //revoke a specific account from transferring the token on your behalf + #[payable] + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId) { + /* + FILL THIS IN + */ + } + + //revoke all accounts from transferring the token on your behalf + #[payable] + fn nft_revoke_all(&mut self, token_id: TokenId) { + /* + FILL THIS IN + */ + } +} \ No newline at end of file diff --git a/nft-contract-events/src/enumeration.rs b/nft-contract-events/src/enumeration.rs new file mode 100644 index 0000000..5926561 --- /dev/null +++ b/nft-contract-events/src/enumeration.rs @@ -0,0 +1,76 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + //Query for the total supply of NFTs on the contract + pub fn nft_total_supply(&self) -> U64 { + //return the length of the token metadata by ID + U64(self.token_metadata_by_id.len()) + } + + //Query for nft tokens on the contract regardless of the owner using pagination + pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { + //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index + let start = from_index.unwrap_or(0); + + //iterate through each token using an iterator + self.token_metadata_by_id.keys() + //skip to the index we specified in the start variable + .skip(start as usize) + //take the first "limit" elements in the vector. If we didn't specify a limit, use 50 + .take(limit.unwrap_or(50) as usize) + //we'll map the token IDs which are strings into Json Tokens + .map(|token_id| self.nft_token(token_id.clone()).unwrap()) + //since we turned the keys into an iterator, we need to turn it back into a vector to return + .collect() + } + + //get the total supply of NFTs for a given owner + pub fn nft_supply_for_owner( + &self, + account_id: AccountId, + ) -> U64 { + //get the set of tokens for the passed in owner + let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); + + //if there is some set of tokens, we'll return the length + if let Some(tokens_for_owner_set) = tokens_for_owner_set { + U64(tokens_for_owner_set.len()) + } else { + //if there isn't a set of tokens for the passed in account ID, we'll return 0 + U64(0) + } + } + + //Query for all the tokens for an owner + pub fn nft_tokens_for_owner( + &self, + account_id: AccountId, + from_index: Option, + limit: Option, + ) -> Vec { + //get the set of tokens for the passed in owner + let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); + //if there is some set of tokens, we'll set the tokens variable equal to that set + let tokens = if let Some(tokens_for_owner_set) = tokens_for_owner_set { + tokens_for_owner_set + } else { + //if there is no set of tokens, we'll simply return an empty vector. + return vec![]; + }; + + //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index + let start = from_index.unwrap_or(0); + + //iterate through the keys vector + tokens.iter() + //skip to the index we specified in the start variable + .skip(start as usize) + //take the first "limit" elements in the vector. If we didn't specify a limit, use 50 + .take(limit.unwrap_or(50) as usize) + //we'll map the token IDs which are strings into Json Tokens + .map(|token_id| self.nft_token(token_id.clone()).unwrap()) + //since we turned the keys into an iterator, we need to turn it back into a vector to return + .collect() + } +} \ No newline at end of file diff --git a/nft-contract-events/src/events.rs b/nft-contract-events/src/events.rs new file mode 100644 index 0000000..f65a3f4 --- /dev/null +++ b/nft-contract-events/src/events.rs @@ -0,0 +1,138 @@ +use std::fmt; + +use near_sdk::serde::{Deserialize, Serialize}; + +/// Enum that represents the data type of the EventLog. +/// The enum can either be an NftMint or an NftTransfer. +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "event", content = "data")] +#[serde(rename_all = "snake_case")] +#[serde(crate = "near_sdk::serde")] +#[non_exhaustive] +pub enum EventLogVariant { + NftMint(Vec), + NftTransfer(Vec), +} + +/// Interface to capture data about an event +/// +/// Arguments: +/// * `standard`: name of standard e.g. nep171 +/// * `version`: e.g. 1.0.0 +/// * `event`: associate event data +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct EventLog { + pub standard: String, + pub version: String, + + // `flatten` to not have "event": {} in the JSON, just have the contents of {}. + #[serde(flatten)] + pub event: EventLogVariant, +} + +impl fmt::Display for EventLog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!( + "EVENT_JSON:{}", + &serde_json::to_string(self).map_err(|_| fmt::Error)? + )) + } +} + +/// An event log to capture token minting +/// +/// Arguments +/// * `owner_id`: "account.near" +/// * `token_ids`: ["1", "abc"] +/// * `memo`: optional message +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct NftMintLog { + pub owner_id: String, + pub token_ids: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// An event log to capture token transfer +/// +/// Arguments +/// * `authorized_id`: approved account to transfer +/// * `old_owner_id`: "owner.near" +/// * `new_owner_id`: "receiver.near" +/// * `token_ids`: ["1", "12345abc"] +/// * `memo`: optional message +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct NftTransferLog { + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized_id: Option, + + pub old_owner_id: String, + pub new_owner_id: String, + pub token_ids: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nep_format_vector() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"foundation.near","token_ids":["aurora","proximitylabs"]},{"owner_id":"user1.near","token_ids":["meme"]}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftMint(vec![ + NftMintLog { + owner_id: "foundation.near".to_owned(), + token_ids: vec!["aurora".to_string(), "proximitylabs".to_string()], + memo: None, + }, + NftMintLog { + owner_id: "user1.near".to_owned(), + token_ids: vec!["meme".to_string()], + memo: None, + }, + ]), + }; + assert_eq!(expected, log.to_string()); + } + + #[test] + fn nep_format_mint() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"foundation.near","token_ids":["aurora","proximitylabs"]}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftMint(vec![NftMintLog { + owner_id: "foundation.near".to_owned(), + token_ids: vec!["aurora".to_string(), "proximitylabs".to_string()], + memo: None, + }]), + }; + assert_eq!(expected, log.to_string()); + } + + #[test] + fn nep_format_transfer_all_fields() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_transfer","data":[{"authorized_id":"market.near","old_owner_id":"user1.near","new_owner_id":"user2.near","token_ids":["token"],"memo":"Go Team!"}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + authorized_id: Some("market.near".to_string()), + old_owner_id: "user1.near".to_string(), + new_owner_id: "user2.near".to_string(), + token_ids: vec!["token".to_string()], + memo: Some("Go Team!".to_owned()), + }]), + }; + assert_eq!(expected, log.to_string()); + } +} \ No newline at end of file diff --git a/nft-contract-events/src/internal.rs b/nft-contract-events/src/internal.rs new file mode 100644 index 0000000..56de327 --- /dev/null +++ b/nft-contract-events/src/internal.rs @@ -0,0 +1,160 @@ +use crate::*; +use near_sdk::{CryptoHash}; +use std::mem::size_of; + +//used to generate a unique prefix in our storage collections (this is to avoid data collisions) +pub(crate) fn hash_account_id(account_id: &AccountId) -> CryptoHash { + //get the default hash + let mut hash = CryptoHash::default(); + //we hash the account ID and return it + hash.copy_from_slice(&env::sha256(account_id.as_bytes())); + hash +} + +//used to make sure the user attached exactly 1 yoctoNEAR +pub(crate) fn assert_one_yocto() { + assert_eq!( + env::attached_deposit(), + NearToken::from_yoctonear(1), + "Requires attached deposit of exactly 1 yoctoNEAR", + ) +} + +//refund the initial deposit based on the amount of storage that was used up +pub(crate) fn refund_deposit(storage_used: u128) { + //get how much it would cost to store the information + let required_cost = env::storage_byte_cost().saturating_mul(storage_used); + //get the attached deposit + let attached_deposit = env::attached_deposit(); + + //make sure that the attached deposit is greater than or equal to the required cost + assert!( + required_cost <= attached_deposit, + "Must attach {} yoctoNEAR to cover storage", + required_cost, + ); + + //get the refund amount from the attached deposit - required cost + let refund = attached_deposit.saturating_sub(required_cost); + + //if the refund is greater than 1 yocto NEAR, we refund the predecessor that amount + if refund.gt(&ONE_YOCTONEAR) { + Promise::new(env::predecessor_account_id()).transfer(refund); + } +} + +impl Contract { + //add a token to the set of tokens an owner has + pub(crate) fn internal_add_token_to_owner( + &mut self, + account_id: &AccountId, + token_id: &TokenId, + ) { + //get the set of tokens for the given account + let mut tokens_set = self.tokens_per_owner.get(account_id).unwrap_or_else(|| { + //if the account doesn't have any tokens, we create a new unordered set + UnorderedSet::new( + StorageKey::TokenPerOwnerInner { + //we get a new unique prefix for the collection + account_id_hash: hash_account_id(&account_id), + }, + ) + }); + + //we insert the token ID into the set + tokens_set.insert(token_id); + + //we insert that set for the given account ID. + self.tokens_per_owner.insert(account_id, &tokens_set); + } + + //remove a token from an owner (internal method and can't be called directly via CLI). + pub(crate) fn internal_remove_token_from_owner( + &mut self, + account_id: &AccountId, + token_id: &TokenId, + ) { + //we get the set of tokens that the owner has + let mut tokens_set = self + .tokens_per_owner + .get(account_id) + //if there is no set of tokens for the owner, we panic with the following message: + .expect("Token should be owned by the sender"); + + //we remove the the token_id from the set of tokens + tokens_set.remove(token_id); + + //if the token set is now empty, we remove the owner from the tokens_per_owner collection + if tokens_set.is_empty() { + self.tokens_per_owner.remove(account_id); + } else { + //if the token set is not empty, we simply insert it back for the account ID. + self.tokens_per_owner.insert(account_id, &tokens_set); + } + } + + //transfers the NFT to the receiver_id (internal method and can't be called directly via CLI). + pub(crate) fn internal_transfer( + &mut self, + sender_id: &AccountId, + receiver_id: &AccountId, + token_id: &TokenId, + memo: Option, + ) -> Token { + //get the token object by passing in the token_id + let token = self.tokens_by_id.get(token_id).expect("No token"); + + //we make sure that the sender isn't sending the token to themselves + assert_ne!( + &token.owner_id, receiver_id, + "The token owner and the receiver should be different" + ); + + //we remove the token from it's current owner's set + self.internal_remove_token_from_owner(&token.owner_id, token_id); + //we then add the token to the receiver_id's set + self.internal_add_token_to_owner(receiver_id, token_id); + + //we create a new token struct + let new_token = Token { + owner_id: receiver_id.clone(), + }; + //insert that new token into the tokens_by_id, replacing the old entry + self.tokens_by_id.insert(token_id, &new_token); + + //if there was some memo attached, we log it. + if let Some(memo) = memo.as_ref() { + env::log_str(&format!("Memo: {}", memo).to_string()); + } + + // Default the authorized ID to be None for the logs. // We will return here in the future when we study the approval functionality + let mut authorized_id = None; + + // Construct the transfer log as per the events standard. + let nft_transfer_log: EventLog = EventLog { + // Standard name ("nep171"). + standard: NFT_STANDARD_NAME.to_string(), + // Version of the standard ("nft-1.0.0"). + version: NFT_METADATA_SPEC.to_string(), + // The data related with the event stored in a vector. + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + // The optional authorized account ID to transfer the token on behalf of the old owner. + authorized_id, + // The old owner's account ID. + old_owner_id: token.owner_id.to_string(), + // The account ID of the new owner of the token. + new_owner_id: receiver_id.to_string(), + // A vector containing the token IDs as strings. + token_ids: vec![token_id.to_string()], + // An optional memo to include. + memo, + }]), + }; + + // Log the serialized json. + env::log_str(&nft_transfer_log.to_string()); + + //return the previous token object that was transferred. + token + } +} \ No newline at end of file diff --git a/nft-contract-events/src/lib.rs b/nft-contract-events/src/lib.rs new file mode 100644 index 0000000..309e8f1 --- /dev/null +++ b/nft-contract-events/src/lib.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use near_sdk::borsh::{BorshSerialize, BorshDeserialize}; +use near_sdk::collections::{LazyOption, LookupMap, UnorderedMap, UnorderedSet}; +use near_sdk::json_types::{Base64VecU8, U64, U128}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::{ + near_bindgen, env, AccountId, NearToken, CryptoHash, PanicOnDefault, Promise, PromiseOrValue, BorshStorageKey, NearSchema +}; + +use crate::internal::*; +pub use crate::metadata::*; +pub use crate::mint::*; +pub use crate::nft_core::*; +pub use crate::approval::*; +pub use crate::royalty::*; +pub use crate::events::*; + +mod internal; +mod enumeration; +mod metadata; +mod mint; +mod nft_core; +mod approval; +mod royalty; +mod events; + +/// This spec can be treated like a version of the standard. +pub const NFT_METADATA_SPEC: &str = "1.0.0"; +/// This is the name of the NFT standard we're using +pub const NFT_STANDARD_NAME: &str = "nep171"; + +//Basic NEAR amounts as constants +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); + +#[near_bindgen] +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Contract { + //contract owner + pub owner_id: AccountId, + + //keeps track of all the token IDs for a given account + pub tokens_per_owner: LookupMap>, + + //keeps track of the token struct for a given token ID + pub tokens_by_id: LookupMap, + + //keeps track of the token metadata for a given token ID + pub token_metadata_by_id: UnorderedMap, + + //keeps track of the metadata for the contract + pub metadata: LazyOption, +} + +/// Helper structure for keys of the persistent collections. +#[derive(BorshSerialize, BorshStorageKey)] +#[borsh(crate = "near_sdk::borsh")] +pub enum StorageKey { + TokensPerOwner, + TokenPerOwnerInner { account_id_hash: CryptoHash }, + TokensById, + TokenMetadataById, + NFTContractMetadata, + TokensPerType, + TokensPerTypeInner { token_type_hash: CryptoHash }, + TokenTypesLocked, +} + +#[near_bindgen] +impl Contract { + /* + initialization function (can only be called once). + this initializes the contract with default metadata so the + user doesn't have to manually type metadata. + */ + #[init] + pub fn new_default_meta(owner_id: AccountId) -> Self { + //calls the other function "new: with some default metadata and the owner_id passed in + Self::new( + owner_id, + NFTContractMetadata { + spec: "nft-1.0.0".to_string(), + name: "NFT Tutorial Contract".to_string(), + symbol: "GOTEAM".to_string(), + icon: None, + base_uri: None, + reference: None, + reference_hash: None, + }, + ) + } + + /* + initialization function (can only be called once). + this initializes the contract with metadata that was passed in and + the owner_id. + */ + #[init] + pub fn new(owner_id: AccountId, metadata: NFTContractMetadata) -> Self { + //create a variable of type Self with all the fields initialized. + let this = Self { + //Storage keys are simply the prefixes used for the collections. This helps avoid data collision + tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner), + tokens_by_id: LookupMap::new(StorageKey::TokensById), + token_metadata_by_id: UnorderedMap::new(StorageKey::TokenMetadataById), + //set the owner_id field equal to the passed in owner_id. + owner_id, + metadata: LazyOption::new( + StorageKey::NFTContractMetadata, + Some(&metadata), + ), + }; + + //return the Contract object + this + } +} \ No newline at end of file diff --git a/nft-contract-events/src/metadata.rs b/nft-contract-events/src/metadata.rs new file mode 100644 index 0000000..41de708 --- /dev/null +++ b/nft-contract-events/src/metadata.rs @@ -0,0 +1,70 @@ +use crate::*; +pub type TokenId = String; +//defines the payout type we'll be returning as a part of the royalty standards. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct Payout { + pub payout: HashMap, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct NFTContractMetadata { + pub spec: String, // required, essentially a version like "nft-1.0.0" + pub name: String, // required, ex. "Mosaics" + pub symbol: String, // required, ex. "MOSAIC" + pub icon: Option, // Data URL + pub base_uri: Option, // Centralized gateway known to have reliable access to decentralized storage assets referenced by `reference` or `media` URLs + pub reference: Option, // URL to a JSON file with more info + pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct TokenMetadata { + pub title: Option, // ex. "Arch Nemesis: Mail Carrier" or "Parcel #5055" + pub description: Option, // free-form description + pub media: Option, // URL to associated media, preferably to decentralized, content-addressed storage + pub media_hash: Option, // Base64-encoded sha256 hash of content referenced by the `media` field. Required if `media` is included. + pub copies: Option, // number of copies of this set of metadata in existence when token was minted. + pub issued_at: Option, // When token was issued or minted, Unix epoch in milliseconds + pub expires_at: Option, // When token expires, Unix epoch in milliseconds + pub starts_at: Option, // When token starts being valid, Unix epoch in milliseconds + pub updated_at: Option, // When token was last updated, Unix epoch in milliseconds + pub extra: Option, // anything extra the NFT wants to store on-chain. Can be stringified JSON. + pub reference: Option, // URL to an off-chain JSON file with more info. + pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. +} + +#[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Token { + //owner of the token + pub owner_id: AccountId, +} + +//The Json token is what will be returned from view calls. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct JsonToken { + //token ID + pub token_id: TokenId, + //owner of the token + pub owner_id: AccountId, + //token metadata + pub metadata: TokenMetadata, +} + +pub trait NonFungibleTokenMetadata { + //view call for returning the contract metadata + fn nft_metadata(&self) -> NFTContractMetadata; +} + +#[near_bindgen] +impl NonFungibleTokenMetadata for Contract { + fn nft_metadata(&self) -> NFTContractMetadata { + self.metadata.get().unwrap() + } +} \ No newline at end of file diff --git a/nft-contract-events/src/mint.rs b/nft-contract-events/src/mint.rs new file mode 100644 index 0000000..51fd42d --- /dev/null +++ b/nft-contract-events/src/mint.rs @@ -0,0 +1,59 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + #[payable] + pub fn nft_mint( + &mut self, + token_id: TokenId, + metadata: TokenMetadata, + receiver_id: AccountId, + ) { + //measure the initial storage being used on the contract + let initial_storage_usage = env::storage_usage(); + + //specify the token struct that contains the owner ID + let token = Token { + //set the owner ID equal to the receiver ID passed into the function + owner_id: receiver_id, + }; + + //insert the token ID and token struct and make sure that the token doesn't exist + assert!( + self.tokens_by_id.insert(&token_id, &token).is_none(), + "Token already exists" + ); + + //insert the token ID and metadata + self.token_metadata_by_id.insert(&token_id, &metadata); + + //call the internal method for adding the token to the owner + self.internal_add_token_to_owner(&token.owner_id, &token_id); + + // Construct the mint log as per the events standard. + let nft_mint_log: EventLog = EventLog { + // Standard name ("nep171"). + standard: NFT_STANDARD_NAME.to_string(), + // Version of the standard ("nft-1.0.0"). + version: NFT_METADATA_SPEC.to_string(), + // The data related with the event stored in a vector. + event: EventLogVariant::NftMint(vec![NftMintLog { + // Owner of the token. + owner_id: token.owner_id.to_string(), + // Vector of token IDs that were minted. + token_ids: vec![token_id.to_string()], + // An optional memo to include. + memo: None, + }]), + }; + + // Log the serialized json. + env::log_str(&nft_mint_log.to_string()); + + //calculate the required storage which was the used - initial + let required_storage_in_bytes = env::storage_usage() - initial_storage_usage; + + //refund any excess storage if the user attached too much. Panic if they didn't attach enough to cover the required. + refund_deposit(required_storage_in_bytes.into()); + } +} \ No newline at end of file diff --git a/nft-contract-events/src/nft_core.rs b/nft-contract-events/src/nft_core.rs new file mode 100644 index 0000000..a74afb1 --- /dev/null +++ b/nft-contract-events/src/nft_core.rs @@ -0,0 +1,242 @@ +use crate::*; +use near_sdk::{ext_contract, Gas, log, PromiseResult}; + +const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(10); +const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas::from_tgas(25); + +pub trait NonFungibleTokenCore { + //transfers an NFT to a receiver ID + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + ); + + //transfers an NFT to a receiver and calls a function on the receiver ID's contract + /// Returns `true` if the token was transferred from the sender's account. + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + msg: String, + ) -> PromiseOrValue; + + //get information about the NFT token passed in + fn nft_token(&self, token_id: TokenId) -> Option; +} + +#[ext_contract(ext_non_fungible_token_receiver)] +trait NonFungibleTokenReceiver { + //Method stored on the receiver contract that is called via cross contract call when nft_transfer_call is called + /// Returns `true` if the token should be returned back to the sender. + fn nft_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_id: AccountId, + token_id: TokenId, + msg: String, + ) -> Promise; +} + +#[ext_contract(ext_self)] +trait NonFungibleTokenResolver { + /* + resolves the promise of the cross contract call to the receiver contract + this is stored on THIS contract and is meant to analyze what happened in the cross contract call when nft_on_transfer was called + as part of the nft_transfer_call method + */ + fn nft_resolve_transfer( + &mut self, + //we introduce an authorized ID for logging the transfer event + authorized_id: Option, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + //we introduce a memo for logging the transfer event + memo: Option, + ) -> bool; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //implementation of the nft_transfer method. This transfers the NFT from the current owner to the receiver. + #[payable] + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + ) { + //assert that the user attached exactly 1 yoctoNEAR. This is for security and so that the user will be redirected to the NEAR wallet. + assert_one_yocto(); + //get the sender to transfer the token from the sender to the receiver + let sender_id = env::predecessor_account_id(); + + //call the internal transfer method + self.internal_transfer( + &sender_id, + &receiver_id, + &token_id, + memo, + ); + } + + //implementation of the transfer call method. This will transfer the NFT and call a method on the receiver_id contract + #[payable] + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + msg: String, + ) -> PromiseOrValue { + //assert that the user attached exactly 1 yocto for security reasons. + assert_one_yocto(); + + //get the sender ID + let sender_id = env::predecessor_account_id(); + + //transfer the token and get the previous token object + let previous_token = self.internal_transfer( + &sender_id, + &receiver_id, + &token_id, + memo.clone(), + ); + + //default the authorized_id to none + let mut authorized_id = None; + + // Initiating receiver's call and the callback + // Defaulting GAS weight to 1, no attached deposit, and static GAS equal to the GAS for nft on transfer. + ext_non_fungible_token_receiver::ext(receiver_id.clone()) + .with_static_gas(GAS_FOR_NFT_ON_TRANSFER) + .nft_on_transfer( + sender_id, + previous_token.owner_id.clone(), + token_id.clone(), + msg + ) + // We then resolve the promise and call nft_resolve_transfer on our own contract + .then( + // Defaulting GAS weight to 1, no attached deposit, and static GAS equal to the GAS for resolve transfer + Self::ext(env::current_account_id()) + .with_static_gas(GAS_FOR_RESOLVE_TRANSFER) + .nft_resolve_transfer( + authorized_id, // we introduce an authorized ID so that we can log the transfer + previous_token.owner_id, + receiver_id, + token_id, + memo, // we introduce a memo for logging in the events standard + ) + ).into() + } + + //get the information for a specific token ID + fn nft_token(&self, token_id: TokenId) -> Option { + //if there is some token ID in the tokens_by_id collection + if let Some(token) = self.tokens_by_id.get(&token_id) { + //we'll get the metadata for that token + let metadata = self.token_metadata_by_id.get(&token_id).unwrap(); + //we return the JsonToken (wrapped by Some since we return an option) + Some(JsonToken { + token_id, + owner_id: token.owner_id, + metadata, + }) + } else { //if there wasn't a token ID in the tokens_by_id collection, we return None + None + } + } +} + +#[near_bindgen] +impl NonFungibleTokenResolver for Contract { + //resolves the cross contract call when calling nft_on_transfer in the nft_transfer_call method + //returns true if the token was successfully transferred to the receiver_id + #[private] + fn nft_resolve_transfer( + &mut self, + //we introduce an authorized ID for logging the transfer event + authorized_id: Option, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + //we introduce a memo for logging the transfer event + memo: Option, + ) -> bool { + // Whether receiver wants to return token back to the sender, based on `nft_on_transfer` + // call result. + if let PromiseResult::Successful(value) = env::promise_result(0) { + //As per the standard, the nft_on_transfer should return whether we should return the token to it's owner or not + if let Ok(return_token) = near_sdk::serde_json::from_slice::(&value) { + //if we need don't need to return the token, we simply return true meaning everything went fine + if !return_token { + /* + since we've already transferred the token and nft_on_transfer returned false, we don't have to + revert the original transfer and thus we can just return true since nothing went wrong. + */ + return true; + } + } + } + + //get the token object if there is some token object + let mut token = if let Some(token) = self.tokens_by_id.get(&token_id) { + if token.owner_id != receiver_id { + // The token is not owner by the receiver anymore. Can't return it. + return true; + } + token + //if there isn't a token object, it was burned and so we return true + } else { + return true; + }; + + //if at the end, we haven't returned true, that means that we should return the token to it's original owner + log!("Return {} from @{} to @{}", token_id, receiver_id, owner_id); + + //we remove the token from the receiver + self.internal_remove_token_from_owner(&receiver_id, &token_id); + //we add the token to the original owner + self.internal_add_token_to_owner(&owner_id, &token_id); + + //we change the token struct's owner to be the original owner + token.owner_id = owner_id.clone(); + //we inset the token back into the tokens_by_id collection + self.tokens_by_id.insert(&token_id, &token); + + /* + We need to log that the NFT was reverted back to the original owner. + The old_owner_id will be the receiver and the new_owner_id will be the + original owner of the token since we're reverting the transfer. + */ + let nft_transfer_log: EventLog = EventLog { + // Standard name ("nep171"). + standard: NFT_STANDARD_NAME.to_string(), + // Version of the standard ("nft-1.0.0"). + version: NFT_METADATA_SPEC.to_string(), + // The data related with the event stored in a vector. + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + // The optional authorized account ID to transfer the token on behalf of the old owner. + authorized_id, + // The old owner's account ID. + old_owner_id: receiver_id.to_string(), + // The account ID of the new owner of the token. + new_owner_id: owner_id.to_string(), + // A vector containing the token IDs as strings. + token_ids: vec![token_id.to_string()], + // An optional memo to include. + memo, + }]), + }; + + //we perform the actual logging + env::log_str(&nft_transfer_log.to_string()); + + //return false + false + } +} diff --git a/nft-contract-events/src/royalty.rs b/nft-contract-events/src/royalty.rs new file mode 100644 index 0000000..7646f9e --- /dev/null +++ b/nft-contract-events/src/royalty.rs @@ -0,0 +1,46 @@ +use crate::*; + +pub trait NonFungibleTokenCore { + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout; + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + #[payable] + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} diff --git a/nft-contract-royalty/Cargo.toml b/nft-contract-royalty/Cargo.toml new file mode 100644 index 0000000..b1b459a --- /dev/null +++ b/nft-contract-royalty/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nft_contract_skeleton" +version = "0.1.0" +authors = ["Near Inc "] +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk = { version = "5.1.0", features = ["legacy"] } +serde_json = "1.0.113" + +[profile.release] +codegen-units = 1 +# Tell `rustc` to optimize for small code size. +opt-level = "z" +lto = true +debug = false +panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 +overflow-checks = true diff --git a/nft-contract-royalty/README.md b/nft-contract-royalty/README.md new file mode 100644 index 0000000..7064491 --- /dev/null +++ b/nft-contract-royalty/README.md @@ -0,0 +1 @@ +# TBD diff --git a/nft-contract-royalty/src/approval.rs b/nft-contract-royalty/src/approval.rs new file mode 100644 index 0000000..3dda71a --- /dev/null +++ b/nft-contract-royalty/src/approval.rs @@ -0,0 +1,174 @@ +use crate::*; +use near_sdk::{ext_contract}; + +pub trait NonFungibleTokenCore { + //approve an account ID to transfer a token on your behalf + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option); + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool; + + //revoke a specific account from transferring the token on your behalf + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); + + //revoke all accounts from transferring the token on your behalf + fn nft_revoke_all(&mut self, token_id: TokenId); +} + +#[ext_contract(ext_non_fungible_approval_receiver)] +trait NonFungibleTokenApprovalsReceiver { + //cross contract call to an external contract that is initiated during nft_approve + fn nft_on_approve( + &mut self, + token_id: TokenId, + owner_id: AccountId, + approval_id: u64, + msg: String, + ); +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //allow a specific account ID to approve a token on your behalf + #[payable] + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option) { + /* + assert at least one yocto for security reasons - this will cause a redirect to the NEAR wallet. + The user needs to attach enough to pay for storage on the contract + */ + assert_at_least_one_yocto(); + + //get the token object from the token ID + let mut token = self.tokens_by_id.get(&token_id).expect("No token"); + + //make sure that the person calling the function is the owner of the token + assert_eq!( + &env::predecessor_account_id(), + &token.owner_id, + "Predecessor must be the token owner." + ); + + //get the next approval ID if we need a new approval + let approval_id: u64 = token.next_approval_id; + + //check if the account has been approved already for this token + let is_new_approval = token + .approved_account_ids + //insert returns none if the key was not present. + .insert(account_id.clone(), approval_id) + //if the key was not present, .is_none() will return true so it is a new approval. + .is_none(); + + //if it was a new approval, we need to calculate how much storage is being used to add the account. + let storage_used = if is_new_approval { + bytes_for_approved_account_id(&account_id) + //if it was not a new approval, we used no storage. + } else { + 0 + }; + + //increment the token's next approval ID by 1 + token.next_approval_id += 1; + //insert the token back into the tokens_by_id collection + self.tokens_by_id.insert(&token_id, &token); + + //refund any excess storage attached by the user. If the user didn't attach enough, panic. + refund_deposit(storage_used); + + //if some message was passed into the function, we initiate a cross contract call on the + //account we're giving access to. + if let Some(msg) = msg { + // Defaulting GAS weight to 1, no attached deposit, and no static GAS to attach. + ext_non_fungible_approval_receiver::ext(account_id) + .nft_on_approve( + token_id, + token.owner_id, + approval_id, + msg + ).as_return(); + } + } + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool { + //get the token object from the token_id + let token = self.tokens_by_id.get(&token_id).expect("No token"); + + //get the approval number for the passed in account ID + let approval = token.approved_account_ids.get(&approved_account_id); + + //if there was some approval ID found for the account ID + if let Some(approval) = approval { + //if a specific approval_id was passed into the function + if let Some(approval_id) = approval_id { + //return if the approval ID passed in matches the actual approval ID for the account + approval_id == *approval + //if there was no approval_id passed into the function, we simply return true + } else { + true + } + //if there was no approval ID found for the account ID, we simply return false + } else { + false + } + } + + //revoke a specific account from transferring the token on your behalf + #[payable] + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId) { + //assert that the user attached exactly 1 yoctoNEAR for security reasons + assert_one_yocto(); + //get the token object using the passed in token_id + let mut token = self.tokens_by_id.get(&token_id).expect("No token"); + + //get the caller of the function and assert that they are the owner of the token + let predecessor_account_id = env::predecessor_account_id(); + assert_eq!(&predecessor_account_id, &token.owner_id); + + //if the account ID was in the token's approval, we remove it and the if statement logic executes + if token + .approved_account_ids + .remove(&account_id) + .is_some() + { + //refund the funds released by removing the approved_account_id to the caller of the function + refund_approved_account_ids_iter(predecessor_account_id, [account_id].iter()); + + //insert the token back into the tokens_by_id collection with the account_id removed from the approval list + self.tokens_by_id.insert(&token_id, &token); + } + } + + //revoke all accounts from transferring the token on your behalf + #[payable] + fn nft_revoke_all(&mut self, token_id: TokenId) { + //assert that the caller attached exactly 1 yoctoNEAR for security + assert_one_yocto(); + + //get the token object from the passed in token ID + let mut token = self.tokens_by_id.get(&token_id).expect("No token"); + //get the caller and make sure they are the owner of the tokens + let predecessor_account_id = env::predecessor_account_id(); + assert_eq!(&predecessor_account_id, &token.owner_id); + + //only revoke if the approved account IDs for the token is not empty + if !token.approved_account_ids.is_empty() { + //refund the approved account IDs to the caller of the function + refund_approved_account_ids(predecessor_account_id, &token.approved_account_ids); + //clear the approved account IDs + token.approved_account_ids.clear(); + //insert the token back into the tokens_by_id collection with the approved account IDs cleared + self.tokens_by_id.insert(&token_id, &token); + } + } +} \ No newline at end of file diff --git a/nft-contract-royalty/src/enumeration.rs b/nft-contract-royalty/src/enumeration.rs new file mode 100644 index 0000000..5926561 --- /dev/null +++ b/nft-contract-royalty/src/enumeration.rs @@ -0,0 +1,76 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + //Query for the total supply of NFTs on the contract + pub fn nft_total_supply(&self) -> U64 { + //return the length of the token metadata by ID + U64(self.token_metadata_by_id.len()) + } + + //Query for nft tokens on the contract regardless of the owner using pagination + pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { + //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index + let start = from_index.unwrap_or(0); + + //iterate through each token using an iterator + self.token_metadata_by_id.keys() + //skip to the index we specified in the start variable + .skip(start as usize) + //take the first "limit" elements in the vector. If we didn't specify a limit, use 50 + .take(limit.unwrap_or(50) as usize) + //we'll map the token IDs which are strings into Json Tokens + .map(|token_id| self.nft_token(token_id.clone()).unwrap()) + //since we turned the keys into an iterator, we need to turn it back into a vector to return + .collect() + } + + //get the total supply of NFTs for a given owner + pub fn nft_supply_for_owner( + &self, + account_id: AccountId, + ) -> U64 { + //get the set of tokens for the passed in owner + let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); + + //if there is some set of tokens, we'll return the length + if let Some(tokens_for_owner_set) = tokens_for_owner_set { + U64(tokens_for_owner_set.len()) + } else { + //if there isn't a set of tokens for the passed in account ID, we'll return 0 + U64(0) + } + } + + //Query for all the tokens for an owner + pub fn nft_tokens_for_owner( + &self, + account_id: AccountId, + from_index: Option, + limit: Option, + ) -> Vec { + //get the set of tokens for the passed in owner + let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); + //if there is some set of tokens, we'll set the tokens variable equal to that set + let tokens = if let Some(tokens_for_owner_set) = tokens_for_owner_set { + tokens_for_owner_set + } else { + //if there is no set of tokens, we'll simply return an empty vector. + return vec![]; + }; + + //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index + let start = from_index.unwrap_or(0); + + //iterate through the keys vector + tokens.iter() + //skip to the index we specified in the start variable + .skip(start as usize) + //take the first "limit" elements in the vector. If we didn't specify a limit, use 50 + .take(limit.unwrap_or(50) as usize) + //we'll map the token IDs which are strings into Json Tokens + .map(|token_id| self.nft_token(token_id.clone()).unwrap()) + //since we turned the keys into an iterator, we need to turn it back into a vector to return + .collect() + } +} \ No newline at end of file diff --git a/nft-contract-royalty/src/events.rs b/nft-contract-royalty/src/events.rs new file mode 100644 index 0000000..f65a3f4 --- /dev/null +++ b/nft-contract-royalty/src/events.rs @@ -0,0 +1,138 @@ +use std::fmt; + +use near_sdk::serde::{Deserialize, Serialize}; + +/// Enum that represents the data type of the EventLog. +/// The enum can either be an NftMint or an NftTransfer. +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "event", content = "data")] +#[serde(rename_all = "snake_case")] +#[serde(crate = "near_sdk::serde")] +#[non_exhaustive] +pub enum EventLogVariant { + NftMint(Vec), + NftTransfer(Vec), +} + +/// Interface to capture data about an event +/// +/// Arguments: +/// * `standard`: name of standard e.g. nep171 +/// * `version`: e.g. 1.0.0 +/// * `event`: associate event data +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct EventLog { + pub standard: String, + pub version: String, + + // `flatten` to not have "event": {} in the JSON, just have the contents of {}. + #[serde(flatten)] + pub event: EventLogVariant, +} + +impl fmt::Display for EventLog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!( + "EVENT_JSON:{}", + &serde_json::to_string(self).map_err(|_| fmt::Error)? + )) + } +} + +/// An event log to capture token minting +/// +/// Arguments +/// * `owner_id`: "account.near" +/// * `token_ids`: ["1", "abc"] +/// * `memo`: optional message +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct NftMintLog { + pub owner_id: String, + pub token_ids: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// An event log to capture token transfer +/// +/// Arguments +/// * `authorized_id`: approved account to transfer +/// * `old_owner_id`: "owner.near" +/// * `new_owner_id`: "receiver.near" +/// * `token_ids`: ["1", "12345abc"] +/// * `memo`: optional message +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct NftTransferLog { + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized_id: Option, + + pub old_owner_id: String, + pub new_owner_id: String, + pub token_ids: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nep_format_vector() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"foundation.near","token_ids":["aurora","proximitylabs"]},{"owner_id":"user1.near","token_ids":["meme"]}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftMint(vec![ + NftMintLog { + owner_id: "foundation.near".to_owned(), + token_ids: vec!["aurora".to_string(), "proximitylabs".to_string()], + memo: None, + }, + NftMintLog { + owner_id: "user1.near".to_owned(), + token_ids: vec!["meme".to_string()], + memo: None, + }, + ]), + }; + assert_eq!(expected, log.to_string()); + } + + #[test] + fn nep_format_mint() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"foundation.near","token_ids":["aurora","proximitylabs"]}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftMint(vec![NftMintLog { + owner_id: "foundation.near".to_owned(), + token_ids: vec!["aurora".to_string(), "proximitylabs".to_string()], + memo: None, + }]), + }; + assert_eq!(expected, log.to_string()); + } + + #[test] + fn nep_format_transfer_all_fields() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_transfer","data":[{"authorized_id":"market.near","old_owner_id":"user1.near","new_owner_id":"user2.near","token_ids":["token"],"memo":"Go Team!"}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + authorized_id: Some("market.near".to_string()), + old_owner_id: "user1.near".to_string(), + new_owner_id: "user2.near".to_string(), + token_ids: vec!["token".to_string()], + memo: Some("Go Team!".to_owned()), + }]), + }; + assert_eq!(expected, log.to_string()); + } +} \ No newline at end of file diff --git a/nft-contract/src/internal.rs b/nft-contract-royalty/src/internal.rs similarity index 90% rename from nft-contract/src/internal.rs rename to nft-contract-royalty/src/internal.rs index 8a02f6b..0f3895b 100644 --- a/nft-contract/src/internal.rs +++ b/nft-contract-royalty/src/internal.rs @@ -2,15 +2,15 @@ use crate::*; use near_sdk::{CryptoHash}; use std::mem::size_of; -//convert the royalty percentage and amount to pay into a payout (U128) -pub(crate) fn royalty_to_payout(royalty_percentage: u32, amount_to_pay: Balance) -> U128 { - U128(royalty_percentage as u128 * amount_to_pay / 10_000u128) +//convert the royalty percentage and amount to pay into a payout +pub(crate) fn royalty_to_payout(royalty_percentage: u128, amount_to_pay: u128) -> U128 { + U128(amount_to_pay.saturating_mul(royalty_percentage).saturating_div(10000)) } //calculate how many bytes the account ID is taking up -pub(crate) fn bytes_for_approved_account_id(account_id: &AccountId) -> u64 { +pub(crate) fn bytes_for_approved_account_id(account_id: &AccountId) -> u128 { // The extra 4 bytes are coming from Borsh serialization to store the length of the string. - account_id.as_str().len() as u64 + 4 + size_of::() as u64 + account_id.as_str().len() as u128 + 4 + size_of::() as u128 } //refund the storage taken up by passed in approved account IDs and send the funds to the passed in account ID. @@ -19,9 +19,9 @@ pub(crate) fn refund_approved_account_ids_iter<'a, I>( approved_account_ids: I, //the approved account IDs must be passed in as an iterator ) -> Promise where I: Iterator { //get the storage total by going through and summing all the bytes for each approved account IDs - let storage_released: u64 = approved_account_ids.map(bytes_for_approved_account_id).sum(); + let storage_released = approved_account_ids.map(bytes_for_approved_account_id).sum(); //transfer the account the storage that is released - Promise::new(account_id).transfer(Balance::from(storage_released) * env::storage_byte_cost()) + Promise::new(account_id).transfer(env::storage_byte_cost().saturating_mul(storage_released)) } //refund a map of approved account IDs and send the funds to the passed in account ID @@ -46,7 +46,7 @@ pub(crate) fn hash_account_id(account_id: &AccountId) -> CryptoHash { pub(crate) fn assert_one_yocto() { assert_eq!( env::attached_deposit(), - 1, + NearToken::from_yoctonear(1), "Requires attached deposit of exactly 1 yoctoNEAR", ) } @@ -54,30 +54,30 @@ pub(crate) fn assert_one_yocto() { //Assert that the user has attached at least 1 yoctoNEAR (for security reasons and to pay for storage) pub(crate) fn assert_at_least_one_yocto() { assert!( - env::attached_deposit() >= 1, + env::attached_deposit() >= NearToken::from_yoctonear(1), "Requires attached deposit of at least 1 yoctoNEAR", ) } //refund the initial deposit based on the amount of storage that was used up -pub(crate) fn refund_deposit(storage_used: u64) { +pub(crate) fn refund_deposit(storage_used: u128) { //get how much it would cost to store the information - let required_cost = env::storage_byte_cost() * Balance::from(storage_used); + let required_cost = env::storage_byte_cost().saturating_mul(storage_used); //get the attached deposit let attached_deposit = env::attached_deposit(); //make sure that the attached deposit is greater than or equal to the required cost assert!( - required_cost <= attached_deposit, - "Must attach {} yoctoNEAR to cover storage", + required_cost.le(&attached_deposit), + "Must attach {} to cover storage", required_cost, ); //get the refund amount from the attached deposit - required cost - let refund = attached_deposit - required_cost; + let refund = attached_deposit.saturating_sub(required_cost); //if the refund is greater than 1 yocto NEAR, we refund the predecessor that amount - if refund > 1 { + if refund.gt(&ONE_YOCTONEAR) { Promise::new(env::predecessor_account_id()).transfer(refund); } } @@ -96,9 +96,7 @@ impl Contract { StorageKey::TokenPerOwnerInner { //we get a new unique prefix for the collection account_id_hash: hash_account_id(&account_id), - } - .try_to_vec() - .unwrap(), + }, ) }); @@ -129,7 +127,7 @@ impl Contract { if tokens_set.is_empty() { self.tokens_per_owner.remove(account_id); } else { - //if the token set is not empty, we simply insert it back for the account ID. + //if the token set is not empty, we simply insert it back for the account ID. self.tokens_per_owner.insert(account_id, &tokens_set); } } diff --git a/nft-contract-royalty/src/lib.rs b/nft-contract-royalty/src/lib.rs new file mode 100644 index 0000000..309e8f1 --- /dev/null +++ b/nft-contract-royalty/src/lib.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; +use near_sdk::borsh::{BorshSerialize, BorshDeserialize}; +use near_sdk::collections::{LazyOption, LookupMap, UnorderedMap, UnorderedSet}; +use near_sdk::json_types::{Base64VecU8, U64, U128}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::{ + near_bindgen, env, AccountId, NearToken, CryptoHash, PanicOnDefault, Promise, PromiseOrValue, BorshStorageKey, NearSchema +}; + +use crate::internal::*; +pub use crate::metadata::*; +pub use crate::mint::*; +pub use crate::nft_core::*; +pub use crate::approval::*; +pub use crate::royalty::*; +pub use crate::events::*; + +mod internal; +mod enumeration; +mod metadata; +mod mint; +mod nft_core; +mod approval; +mod royalty; +mod events; + +/// This spec can be treated like a version of the standard. +pub const NFT_METADATA_SPEC: &str = "1.0.0"; +/// This is the name of the NFT standard we're using +pub const NFT_STANDARD_NAME: &str = "nep171"; + +//Basic NEAR amounts as constants +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); + +#[near_bindgen] +#[derive(BorshSerialize, BorshDeserialize, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Contract { + //contract owner + pub owner_id: AccountId, + + //keeps track of all the token IDs for a given account + pub tokens_per_owner: LookupMap>, + + //keeps track of the token struct for a given token ID + pub tokens_by_id: LookupMap, + + //keeps track of the token metadata for a given token ID + pub token_metadata_by_id: UnorderedMap, + + //keeps track of the metadata for the contract + pub metadata: LazyOption, +} + +/// Helper structure for keys of the persistent collections. +#[derive(BorshSerialize, BorshStorageKey)] +#[borsh(crate = "near_sdk::borsh")] +pub enum StorageKey { + TokensPerOwner, + TokenPerOwnerInner { account_id_hash: CryptoHash }, + TokensById, + TokenMetadataById, + NFTContractMetadata, + TokensPerType, + TokensPerTypeInner { token_type_hash: CryptoHash }, + TokenTypesLocked, +} + +#[near_bindgen] +impl Contract { + /* + initialization function (can only be called once). + this initializes the contract with default metadata so the + user doesn't have to manually type metadata. + */ + #[init] + pub fn new_default_meta(owner_id: AccountId) -> Self { + //calls the other function "new: with some default metadata and the owner_id passed in + Self::new( + owner_id, + NFTContractMetadata { + spec: "nft-1.0.0".to_string(), + name: "NFT Tutorial Contract".to_string(), + symbol: "GOTEAM".to_string(), + icon: None, + base_uri: None, + reference: None, + reference_hash: None, + }, + ) + } + + /* + initialization function (can only be called once). + this initializes the contract with metadata that was passed in and + the owner_id. + */ + #[init] + pub fn new(owner_id: AccountId, metadata: NFTContractMetadata) -> Self { + //create a variable of type Self with all the fields initialized. + let this = Self { + //Storage keys are simply the prefixes used for the collections. This helps avoid data collision + tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner), + tokens_by_id: LookupMap::new(StorageKey::TokensById), + token_metadata_by_id: UnorderedMap::new(StorageKey::TokenMetadataById), + //set the owner_id field equal to the passed in owner_id. + owner_id, + metadata: LazyOption::new( + StorageKey::NFTContractMetadata, + Some(&metadata), + ), + }; + + //return the Contract object + this + } +} \ No newline at end of file diff --git a/nft-contract/src/metadata.rs b/nft-contract-royalty/src/metadata.rs similarity index 93% rename from nft-contract/src/metadata.rs rename to nft-contract-royalty/src/metadata.rs index ec13a70..3153b89 100644 --- a/nft-contract/src/metadata.rs +++ b/nft-contract-royalty/src/metadata.rs @@ -1,13 +1,14 @@ use crate::*; pub type TokenId = String; //defines the payout type we'll be returning as a part of the royalty standards. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, NearSchema)] #[serde(crate = "near_sdk::serde")] pub struct Payout { pub payout: HashMap, } -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone)] +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] #[serde(crate = "near_sdk::serde")] pub struct NFTContractMetadata { pub spec: String, // required, essentially a version like "nft-1.0.0" @@ -19,7 +20,8 @@ pub struct NFTContractMetadata { pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. } -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] #[serde(crate = "near_sdk::serde")] pub struct TokenMetadata { pub title: Option, // ex. "Arch Nemesis: Mail Carrier" or "Parcel #5055" @@ -37,6 +39,7 @@ pub struct TokenMetadata { } #[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] pub struct Token { //owner of the token pub owner_id: AccountId, @@ -49,7 +52,7 @@ pub struct Token { } //The Json token is what will be returned from view calls. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, NearSchema)] #[serde(crate = "near_sdk::serde")] pub struct JsonToken { //token ID diff --git a/nft-contract/src/mint.rs b/nft-contract-royalty/src/mint.rs similarity index 97% rename from nft-contract/src/mint.rs rename to nft-contract-royalty/src/mint.rs index 4fc049a..93f0b98 100644 --- a/nft-contract/src/mint.rs +++ b/nft-contract-royalty/src/mint.rs @@ -68,14 +68,14 @@ impl Contract { memo: None, }]), }; - + // Log the serialized json. env::log_str(&nft_mint_log.to_string()); - + //calculate the required storage which was the used - initial let required_storage_in_bytes = env::storage_usage() - initial_storage_usage; - + //refund any excess storage if the user attached too much. Panic if they didn't attach enough to cover the required. - refund_deposit(required_storage_in_bytes); + refund_deposit(required_storage_in_bytes.into()); } } \ No newline at end of file diff --git a/nft-contract/src/nft_core.rs b/nft-contract-royalty/src/nft_core.rs similarity index 94% rename from nft-contract/src/nft_core.rs rename to nft-contract-royalty/src/nft_core.rs index e628e54..61f4898 100644 --- a/nft-contract/src/nft_core.rs +++ b/nft-contract-royalty/src/nft_core.rs @@ -1,8 +1,8 @@ use crate::*; -use near_sdk::{ext_contract, Gas, PromiseResult}; +use near_sdk::{ext_contract, Gas, log, PromiseResult}; -const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(10_000_000_000_000); -const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas(25_000_000_000_000); +const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(10); +const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas::from_tgas(25); pub trait NonFungibleTokenCore { //transfers an NFT to a receiver ID @@ -45,12 +45,12 @@ trait NonFungibleTokenReceiver { } #[ext_contract(ext_self)] -/* - resolves the promise of the cross contract call to the receiver contract - this is stored on THIS contract and is meant to analyze what happened in the cross contract call when nft_on_transfer was called - as part of the nft_transfer_call method -*/ trait NonFungibleTokenResolver { + /* + resolves the promise of the cross contract call to the receiver contract + this is stored on THIS contract and is meant to analyze what happened in the cross contract call when nft_on_transfer was called + as part of the nft_transfer_call method + */ fn nft_resolve_transfer( &mut self, //we introduce an authorized ID for logging the transfer event @@ -228,6 +228,9 @@ impl NonFungibleTokenResolver for Contract { return true; }; + //if at the end, we haven't returned true, that means that we should return the token to it's original owner + log!("Return {} from @{} to @{}", token_id, receiver_id, owner_id); + //we remove the token from the receiver self.internal_remove_token_from_owner(&receiver_id.clone(), &token_id); //we add the token to the original owner diff --git a/nft-contract/src/royalty.rs b/nft-contract-royalty/src/royalty.rs similarity index 73% rename from nft-contract/src/royalty.rs rename to nft-contract-royalty/src/royalty.rs index bde91f6..6e6cbb8 100644 --- a/nft-contract/src/royalty.rs +++ b/nft-contract-royalty/src/royalty.rs @@ -3,8 +3,8 @@ use crate::*; pub trait NonFungibleTokenCore { //calculates the payout for a token given the passed in balance. This is a view method fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout; - - //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. fn nft_transfer_payout( &mut self, receiver_id: AccountId, @@ -31,34 +31,42 @@ impl NonFungibleTokenCore for Contract { let balance_u128 = u128::from(balance); //keep track of the payout object to send back let mut payout_object = Payout { - payout: HashMap::new() + payout: HashMap::new(), }; //get the royalty object from token let royalty = token.royalty; //make sure we're not paying out to too many people (GAS limits this) - assert!(royalty.len() as u32 <= max_len_payout, "Market cannot payout to that many receivers"); + assert!( + royalty.len() as u32 <= max_len_payout, + "Market cannot payout to that many receivers" + ); - //go through each key and value in the royalty object + //go through each key and value in the royalty object for (k, v) in royalty.iter() { //get the key let key = k.clone(); //only insert into the payout if the key isn't the token owner (we add their payout at the end) if key != owner_id { - payout_object.payout.insert(key, royalty_to_payout(*v, balance_u128)); + payout_object + .payout + .insert(key, royalty_to_payout(*v as u128, balance_u128)); total_perpetual += *v; } } // payout to previous owner who gets 100% - total perpetual royalties - payout_object.payout.insert(owner_id, royalty_to_payout(10000 - total_perpetual, balance_u128)); + payout_object.payout.insert( + owner_id, + royalty_to_payout((10000 - total_perpetual).into(), balance_u128), + ); //return the payout object payout_object } - //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. #[payable] fn nft_transfer_payout( &mut self, @@ -68,19 +76,14 @@ impl NonFungibleTokenCore for Contract { memo: Option, balance: U128, max_len_payout: u32, - ) -> Payout { + ) -> Payout { //assert that the user attached 1 yocto NEAR for security reasons assert_one_yocto(); //get the sender ID let sender_id = env::predecessor_account_id(); //transfer the token to the passed in receiver and get the previous token object back - let previous_token = self.internal_transfer( - &sender_id, - &receiver_id, - &token_id, - Some(approval_id), - memo, - ); + let previous_token = + self.internal_transfer(&sender_id, &receiver_id, &token_id, Some(approval_id), memo); //refund the previous token owner for the storage used up by the previous approved account IDs refund_approved_account_ids( @@ -94,15 +97,18 @@ impl NonFungibleTokenCore for Contract { let mut total_perpetual = 0; //get the u128 version of the passed in balance (which was U128 before) let balance_u128 = u128::from(balance); - //keep track of the payout object to send back + //keep track of the payout object to send back let mut payout_object = Payout { - payout: HashMap::new() + payout: HashMap::new(), }; //get the royalty object from token - let royalty = previous_token.royalty; + let royalty = previous_token.royalty; //make sure we're not paying out to too many people (GAS limits this) - assert!(royalty.len() as u32 <= max_len_payout, "Market cannot payout to that many receivers"); + assert!( + royalty.len() as u32 <= max_len_payout, + "Market cannot payout to that many receivers" + ); //go through each key and value in the royalty object for (k, v) in royalty.iter() { @@ -111,15 +117,20 @@ impl NonFungibleTokenCore for Contract { //only insert into the payout if the key isn't the token owner (we add their payout at the end) if key != owner_id { - payout_object.payout.insert(key, royalty_to_payout(*v, balance_u128)); + payout_object + .payout + .insert(key, royalty_to_payout(*v as u128, balance_u128)); total_perpetual += *v; } } // payout to previous owner who gets 100% - total perpetual royalties - payout_object.payout.insert(owner_id, royalty_to_payout(10000 - total_perpetual, balance_u128)); + payout_object.payout.insert( + owner_id, + royalty_to_payout((10000 - total_perpetual).into(), balance_u128), + ); //return the payout object payout_object } -} \ No newline at end of file +} diff --git a/nft-contract-skeleton/Cargo.toml b/nft-contract-skeleton/Cargo.toml new file mode 100644 index 0000000..6a5b5c8 --- /dev/null +++ b/nft-contract-skeleton/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "nft_contract_skeleton" +version = "0.1.0" +authors = ["Near Inc "] +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +near-sdk = { version = "5.1.0", features = ["legacy"] } + +[profile.release] +codegen-units = 1 +# Tell `rustc` to optimize for small code size. +opt-level = "z" +lto = true +debug = false +panic = "abort" +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 +overflow-checks = true diff --git a/nft-contract-skeleton/README.md b/nft-contract-skeleton/README.md new file mode 100644 index 0000000..7064491 --- /dev/null +++ b/nft-contract-skeleton/README.md @@ -0,0 +1 @@ +# TBD diff --git a/nft-contract-skeleton/src/approval.rs b/nft-contract-skeleton/src/approval.rs new file mode 100644 index 0000000..9d72d6c --- /dev/null +++ b/nft-contract-skeleton/src/approval.rs @@ -0,0 +1,73 @@ +use crate::*; +use near_sdk::{ext_contract}; + +pub trait NonFungibleTokenCore { + //approve an account ID to transfer a token on your behalf + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option); + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool; + + //revoke a specific account from transferring the token on your behalf + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId); + + //revoke all accounts from transferring the token on your behalf + fn nft_revoke_all(&mut self, token_id: TokenId); +} + +#[ext_contract(ext_non_fungible_approval_receiver)] +trait NonFungibleTokenApprovalsReceiver { + //cross contract call to an external contract that is initiated during nft_approve + fn nft_on_approve( + &mut self, + token_id: TokenId, + owner_id: AccountId, + approval_id: u64, + msg: String, + ); +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //allow a specific account ID to approve a token on your behalf + #[payable] + fn nft_approve(&mut self, token_id: TokenId, account_id: AccountId, msg: Option) { + /* + FILL THIS IN + */ + } + + //check if the passed in account has access to approve the token ID + fn nft_is_approved( + &self, + token_id: TokenId, + approved_account_id: AccountId, + approval_id: Option, + ) -> bool { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //revoke a specific account from transferring the token on your behalf + #[payable] + fn nft_revoke(&mut self, token_id: TokenId, account_id: AccountId) { + /* + FILL THIS IN + */ + } + + //revoke all accounts from transferring the token on your behalf + #[payable] + fn nft_revoke_all(&mut self, token_id: TokenId) { + /* + FILL THIS IN + */ + } +} \ No newline at end of file diff --git a/nft-contract-skeleton/src/enumeration.rs b/nft-contract-skeleton/src/enumeration.rs new file mode 100644 index 0000000..d80f47a --- /dev/null +++ b/nft-contract-skeleton/src/enumeration.rs @@ -0,0 +1,44 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + //Query for the total supply of NFTs on the contract + pub fn nft_total_supply(&self) -> U64 { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //Query for nft tokens on the contract regardless of the owner using pagination + pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //get the total supply of NFTs for a given owner + pub fn nft_supply_for_owner( + &self, + account_id: AccountId, + ) -> U64 { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //Query for all the tokens for an owner + pub fn nft_tokens_for_owner( + &self, + account_id: AccountId, + from_index: Option, + limit: Option, + ) -> Vec { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} \ No newline at end of file diff --git a/nft-contract-skeleton/src/events.rs b/nft-contract-skeleton/src/events.rs new file mode 100644 index 0000000..f65a3f4 --- /dev/null +++ b/nft-contract-skeleton/src/events.rs @@ -0,0 +1,138 @@ +use std::fmt; + +use near_sdk::serde::{Deserialize, Serialize}; + +/// Enum that represents the data type of the EventLog. +/// The enum can either be an NftMint or an NftTransfer. +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "event", content = "data")] +#[serde(rename_all = "snake_case")] +#[serde(crate = "near_sdk::serde")] +#[non_exhaustive] +pub enum EventLogVariant { + NftMint(Vec), + NftTransfer(Vec), +} + +/// Interface to capture data about an event +/// +/// Arguments: +/// * `standard`: name of standard e.g. nep171 +/// * `version`: e.g. 1.0.0 +/// * `event`: associate event data +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct EventLog { + pub standard: String, + pub version: String, + + // `flatten` to not have "event": {} in the JSON, just have the contents of {}. + #[serde(flatten)] + pub event: EventLogVariant, +} + +impl fmt::Display for EventLog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!( + "EVENT_JSON:{}", + &serde_json::to_string(self).map_err(|_| fmt::Error)? + )) + } +} + +/// An event log to capture token minting +/// +/// Arguments +/// * `owner_id`: "account.near" +/// * `token_ids`: ["1", "abc"] +/// * `memo`: optional message +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct NftMintLog { + pub owner_id: String, + pub token_ids: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +/// An event log to capture token transfer +/// +/// Arguments +/// * `authorized_id`: approved account to transfer +/// * `old_owner_id`: "owner.near" +/// * `new_owner_id`: "receiver.near" +/// * `token_ids`: ["1", "12345abc"] +/// * `memo`: optional message +#[derive(Serialize, Deserialize, Debug)] +#[serde(crate = "near_sdk::serde")] +pub struct NftTransferLog { + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized_id: Option, + + pub old_owner_id: String, + pub new_owner_id: String, + pub token_ids: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub memo: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nep_format_vector() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"foundation.near","token_ids":["aurora","proximitylabs"]},{"owner_id":"user1.near","token_ids":["meme"]}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftMint(vec![ + NftMintLog { + owner_id: "foundation.near".to_owned(), + token_ids: vec!["aurora".to_string(), "proximitylabs".to_string()], + memo: None, + }, + NftMintLog { + owner_id: "user1.near".to_owned(), + token_ids: vec!["meme".to_string()], + memo: None, + }, + ]), + }; + assert_eq!(expected, log.to_string()); + } + + #[test] + fn nep_format_mint() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"foundation.near","token_ids":["aurora","proximitylabs"]}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftMint(vec![NftMintLog { + owner_id: "foundation.near".to_owned(), + token_ids: vec!["aurora".to_string(), "proximitylabs".to_string()], + memo: None, + }]), + }; + assert_eq!(expected, log.to_string()); + } + + #[test] + fn nep_format_transfer_all_fields() { + let expected = r#"EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_transfer","data":[{"authorized_id":"market.near","old_owner_id":"user1.near","new_owner_id":"user2.near","token_ids":["token"],"memo":"Go Team!"}]}"#; + let log = EventLog { + standard: "nep171".to_string(), + version: "1.0.0".to_string(), + event: EventLogVariant::NftTransfer(vec![NftTransferLog { + authorized_id: Some("market.near".to_string()), + old_owner_id: "user1.near".to_string(), + new_owner_id: "user2.near".to_string(), + token_ids: vec!["token".to_string()], + memo: Some("Go Team!".to_owned()), + }]), + }; + assert_eq!(expected, log.to_string()); + } +} \ No newline at end of file diff --git a/nft-contract-skeleton/src/lib.rs b/nft-contract-skeleton/src/lib.rs new file mode 100644 index 0000000..2c186a9 --- /dev/null +++ b/nft-contract-skeleton/src/lib.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; +use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; +use near_sdk::collections::{LazyOption, LookupMap, UnorderedMap, UnorderedSet}; +use near_sdk::json_types::{Base64VecU8, U64, U128}; +use near_sdk::serde::{Deserialize, Serialize}; +use near_sdk::{ + near_bindgen, env, NearToken, AccountId, CryptoHash, PanicOnDefault, Promise, PromiseOrValue, BorshStorageKey, NearSchema +}; + +pub use crate::metadata::*; +pub use crate::mint::*; +pub use crate::nft_core::*; +pub use crate::approval::*; +pub use crate::royalty::*; + +mod enumeration; +mod metadata; +mod mint; +mod nft_core; +mod approval; +mod royalty; + +#[near_bindgen] +#[derive(BorshSerialize, BorshDeserialize, BorshStorageKey, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Contract { + /* + FILL THIS IN + */ +} + +/// Helper structure for keys of the persistent collections. +#[derive(BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] +pub enum StorageKey { + TokensPerOwner, + TokenPerOwnerInner { account_id_hash: CryptoHash }, + TokensById, + TokenMetadataById, + NFTContractMetadata, + TokensPerType, + TokensPerTypeInner { token_type_hash: CryptoHash }, + TokenTypesLocked, +} + +#[near_bindgen] +impl Contract { + /* + initialization function (can only be called once). + this initializes the contract with default metadata so the + user doesn't have to manually type metadata. + */ + #[init] + pub fn new_default_meta(owner_id: AccountId) -> Self { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + /* + initialization function (can only be called once). + this initializes the contract with metadata that was passed in and + the owner_id. + */ + #[init] + pub fn new(owner_id: AccountId, metadata: NFTContractMetadata) -> Self { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} \ No newline at end of file diff --git a/nft-contract-skeleton/src/metadata.rs b/nft-contract-skeleton/src/metadata.rs new file mode 100644 index 0000000..75c1016 --- /dev/null +++ b/nft-contract-skeleton/src/metadata.rs @@ -0,0 +1,60 @@ +use near_sdk::NearToken; + +use crate::*; +pub type TokenId = String; +//defines the payout type we'll be returning as a part of the royalty standards. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct Payout { + pub payout: HashMap, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct NFTContractMetadata { + /* + FILL THIS IN + */ +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] +#[serde(crate = "near_sdk::serde")] +pub struct TokenMetadata { + /* + FILL THIS IN + */ +} + +#[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] +pub struct Token { + /* + FILL THIS IN + */ +} + +//The Json token is what will be returned from view calls. +#[derive(Serialize, Deserialize, NearSchema)] +#[serde(crate = "near_sdk::serde")] +pub struct JsonToken { + /* + FILL THIS IN + */ +} + +pub trait NonFungibleTokenMetadata { + //view call for returning the contract metadata + fn nft_metadata(&self) -> NFTContractMetadata; +} + +#[near_bindgen] +impl NonFungibleTokenMetadata for Contract { + fn nft_metadata(&self) -> NFTContractMetadata { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} \ No newline at end of file diff --git a/nft-contract-skeleton/src/mint.rs b/nft-contract-skeleton/src/mint.rs new file mode 100644 index 0000000..ece73ad --- /dev/null +++ b/nft-contract-skeleton/src/mint.rs @@ -0,0 +1,16 @@ +use crate::*; + +#[near_bindgen] +impl Contract { + #[payable] + pub fn nft_mint( + &mut self, + token_id: Option, + metadata: TokenMetadata, + receiver_id: Option, + ) { + /* + FILL THIS IN + */ + } +} \ No newline at end of file diff --git a/nft-contract-skeleton/src/nft_core.rs b/nft-contract-skeleton/src/nft_core.rs new file mode 100644 index 0000000..3c35d2c --- /dev/null +++ b/nft-contract-skeleton/src/nft_core.rs @@ -0,0 +1,113 @@ +use crate::*; +use near_sdk::{ext_contract, Gas, log, PromiseResult}; + +const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(10); +const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas::from_tgas(25); + +pub trait NonFungibleTokenCore { + //transfers an NFT to a receiver ID + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + ); + + //transfers an NFT to a receiver and calls a function on the receiver ID's contract + /// Returns `true` if the token was transferred from the sender's account. + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + msg: String, + ) -> PromiseOrValue; + + //get information about the NFT token passed in + fn nft_token(&self, token_id: TokenId) -> Option; +} + +#[ext_contract(ext_non_fungible_token_receiver)] +trait NonFungibleTokenReceiver { + //Method stored on the receiver contract that is called via cross contract call when nft_transfer_call is called + /// Returns `true` if the token should be returned back to the sender. + fn nft_on_transfer( + &mut self, + sender_id: AccountId, + previous_owner_id: AccountId, + token_id: TokenId, + msg: String, + ) -> Promise; +} + +#[ext_contract(ext_self)] +trait NonFungibleTokenResolver { + /* + resolves the promise of the cross contract call to the receiver contract + this is stored on THIS contract and is meant to analyze what happened in the cross contract call when nft_on_transfer was called + as part of the nft_transfer_call method + */ + fn nft_resolve_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + ) -> bool; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //implementation of the nft_transfer method. This transfers the NFT from the current owner to the receiver. + #[payable] + fn nft_transfer( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + ) { + /* + FILL THIS IN + */ + } + + //implementation of the transfer call method. This will transfer the NFT and call a method on the receiver_id contract + #[payable] + fn nft_transfer_call( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + memo: Option, + msg: String, + ) -> PromiseOrValue { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //get the information for a specific token ID + fn nft_token(&self, token_id: TokenId) -> Option { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} + +#[near_bindgen] +impl NonFungibleTokenResolver for Contract { + //resolves the cross contract call when calling nft_on_transfer in the nft_transfer_call method + //returns true if the token was successfully transferred to the receiver_id + #[private] + fn nft_resolve_transfer( + &mut self, + owner_id: AccountId, + receiver_id: AccountId, + token_id: TokenId, + ) -> bool { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} diff --git a/nft-contract-skeleton/src/royalty.rs b/nft-contract-skeleton/src/royalty.rs new file mode 100644 index 0000000..b71f42e --- /dev/null +++ b/nft-contract-skeleton/src/royalty.rs @@ -0,0 +1,45 @@ +use crate::*; + +pub trait NonFungibleTokenCore { + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout; + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout; +} + +#[near_bindgen] +impl NonFungibleTokenCore for Contract { + //calculates the payout for a token given the passed in balance. This is a view method + fn nft_payout(&self, token_id: TokenId, balance: U128, max_len_payout: u32) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } + + //transfers the token to the receiver ID and returns the payout object that should be payed given the passed in balance. + #[payable] + fn nft_transfer_payout( + &mut self, + receiver_id: AccountId, + token_id: TokenId, + approval_id: u64, + memo: Option, + balance: U128, + max_len_payout: u32, + ) -> Payout { + /* + FILL THIS IN + */ + todo!(); //remove once code is filled in. + } +} diff --git a/nft-contract/build.sh b/nft-contract/build.sh deleted file mode 100755 index 98ae080..0000000 --- a/nft-contract/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -set -e && RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release && mkdir -p ../out && cp target/wasm32-unknown-unknown/release/*.wasm ../out/main.wasm \ No newline at end of file diff --git a/nft-contract/res/nft_simple.wasm b/nft-contract/res/nft_simple.wasm deleted file mode 100755 index f68c929..0000000 Binary files a/nft-contract/res/nft_simple.wasm and /dev/null differ diff --git a/nft-contract/src/tests.rs b/nft-contract/src/tests.rs deleted file mode 100644 index bb9b23f..0000000 --- a/nft-contract/src/tests.rs +++ /dev/null @@ -1,314 +0,0 @@ -/* unit tests */ -#[cfg(test)] -use crate::Contract; -use crate::TokenMetadata; -use crate::approval::NonFungibleTokenCore; -use near_sdk::json_types::{U128, U64}; -use near_sdk::test_utils::{accounts, VMContextBuilder}; -use near_sdk::testing_env; -use near_sdk::{env, AccountId}; - -use std::collections::HashMap; - -const MINT_STORAGE_COST: u128 = 100_000_000_000_000_000_000_000; -const MIN_REQUIRED_APPROVAL_YOCTO: u128 = 170000000000000000000; - -fn get_context(predecessor: AccountId) -> VMContextBuilder { - let mut builder = VMContextBuilder::new(); - builder.predecessor_account_id(predecessor); - builder -} - -fn sample_token_metadata() -> TokenMetadata { - TokenMetadata { - title: Some("Olympus Mons".into()), - description: Some("The tallest mountain in the charted solar system".into()), - media: None, - media_hash: None, - copies: Some(1u64), - issued_at: None, - expires_at: None, - starts_at: None, - updated_at: None, - extra: None, - reference: None, - reference_hash: None, - } -} - -#[test] -#[should_panic(expected = "The contract is not initialized")] -fn test_default() { - let context = get_context(accounts(1)); - testing_env!(context.build()); - let _contract = Contract::default(); -} - -#[test] -fn test_new_account_contract() { - let mut context = get_context(accounts(1)); - testing_env!(context.build()); - let contract = Contract::new_default_meta(accounts(1).into()); - testing_env!(context.is_view(true).build()); - let contract_nft_tokens = contract.nft_tokens(Some(U128(0)), None); - assert_eq!(contract_nft_tokens.len(), 0); -} - -#[test] -fn test_mint_nft() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_metadata: TokenMetadata = sample_token_metadata(); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), token_metadata, accounts(0), None); - let contract_nft_tokens = contract.nft_tokens(Some(U128(0)), None); - assert_eq!(contract_nft_tokens.len(), 1); - - assert_eq!(contract_nft_tokens[0].token_id, token_id); - assert_eq!(contract_nft_tokens[0].owner_id, accounts(0)); - assert_eq!( - contract_nft_tokens[0].metadata.title, - sample_token_metadata().title - ); - assert_eq!( - contract_nft_tokens[0].metadata.description, - sample_token_metadata().description - ); - assert_eq!( - contract_nft_tokens[0].metadata.media, - sample_token_metadata().media - ); - assert_eq!(contract_nft_tokens[0].approved_account_ids, HashMap::new()); -} - -#[test] -fn test_internal_transfer() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(1) - .predecessor_account_id(accounts(0)) - .build()); - contract.internal_transfer( - &accounts(0), - &accounts(1), - &token_id.clone(), - Some(U64(1).0), - None, - ); - - testing_env!(context - .storage_usage(env::storage_usage()) - .account_balance(env::account_balance()) - .is_view(true) - .attached_deposit(0) - .build()); - - let tokens = contract.nft_tokens_for_owner(accounts(1), Some(U128(0)), None); - assert_ne!( - tokens.len(), - 0, - "Token not correctly created and/or sent to second account" - ); - let token = &tokens[0]; - assert_eq!(token.token_id, token_id); - assert_eq!(token.owner_id, accounts(1)); - assert_eq!(token.metadata.title, sample_token_metadata().title); - assert_eq!( - token.metadata.description, - sample_token_metadata().description - ); - assert_eq!(token.metadata.media, sample_token_metadata().media); - assert_eq!(token.approved_account_ids, HashMap::new()); -} - -#[test] -fn test_nft_approve() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MIN_REQUIRED_APPROVAL_YOCTO) - .predecessor_account_id(accounts(0)) - .build()); - contract.nft_approve(token_id.clone(), accounts(1), None); - - testing_env!(context - .storage_usage(env::storage_usage()) - .account_balance(env::account_balance()) - .is_view(true) - .attached_deposit(0) - .build()); - assert!(contract.nft_is_approved(token_id.clone(), accounts(1), None)); -} - -#[test] -fn test_nft_revoke() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - // alice approves bob - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MIN_REQUIRED_APPROVAL_YOCTO) - .predecessor_account_id(accounts(0)) - .build()); - contract.nft_approve(token_id.clone(), accounts(1), None); - - // alice revokes bob - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(1) - .predecessor_account_id(accounts(0)) - .build()); - contract.nft_revoke(token_id.clone(), accounts(1)); - testing_env!(context - .storage_usage(env::storage_usage()) - .account_balance(env::account_balance()) - .is_view(true) - .attached_deposit(0) - .build()); - assert!(!contract.nft_is_approved(token_id.clone(), accounts(1), None)); -} - -#[test] -fn test_revoke_all() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - // alice approves bob - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MIN_REQUIRED_APPROVAL_YOCTO) - .predecessor_account_id(accounts(0)) - .build()); - contract.nft_approve(token_id.clone(), accounts(1), None); - - // alice revokes bob - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(1) - .predecessor_account_id(accounts(0)) - .build()); - contract.nft_revoke_all(token_id.clone()); - testing_env!(context - .storage_usage(env::storage_usage()) - .account_balance(env::account_balance()) - .is_view(true) - .attached_deposit(0) - .build()); - assert!(!contract.nft_is_approved(token_id.clone(), accounts(1), Some(1))); -} - -#[test] -fn test_internal_remove_token_from_owner() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - let contract_nft_tokens_before = contract.nft_tokens_for_owner(accounts(0), None, None); - assert_eq!(contract_nft_tokens_before.len(), 1); - - contract.internal_remove_token_from_owner(&accounts(0), &token_id); - let contract_nft_tokens_after = contract.nft_tokens_for_owner(accounts(0), None, None); - assert_eq!(contract_nft_tokens_after.len(), 0); -} - -#[test] -fn test_nft_payout() { - use crate::royalty::NonFungibleTokenCore; - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - // alice approves bob - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MIN_REQUIRED_APPROVAL_YOCTO) - .predecessor_account_id(accounts(0)) - .build()); - contract.nft_approve(token_id.clone(), accounts(1), None); - - let payout = contract.nft_payout(token_id.clone(), U128(10), 1); - let expected = HashMap::from([(accounts(0), U128(10))]); - assert_eq!(payout.payout, expected); -} - -#[test] -fn test_nft_total_supply() { - let mut context = get_context(accounts(0)); - testing_env!(context.build()); - let mut contract = Contract::new_default_meta(accounts(0).into()); - - testing_env!(context - .storage_usage(env::storage_usage()) - .attached_deposit(MINT_STORAGE_COST) - .predecessor_account_id(accounts(0)) - .build()); - let token_id = "0".to_string(); - contract.nft_mint(token_id.clone(), sample_token_metadata(), accounts(0), None); - - let total_supply = contract.nft_total_supply(); - assert_eq!(total_supply, U128(1)); -} \ No newline at end of file diff --git a/nft-series/Cargo.toml b/nft-series/Cargo.toml index 17c1958..97e5048 100644 --- a/nft-series/Cargo.toml +++ b/nft-series/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nft_simple" +name = "nft_series_contract" version = "0.1.0" authors = ["Near Inc "] edition = "2021" @@ -8,16 +8,21 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -near-sdk = "4.1.1" +near-sdk = { version = "5.1.0", features = ["legacy"] } serde_json = "1.0.113" -[patch.crates-io] -parity-secp256k1 = { git = 'https://github.com/paritytech/rust-secp256k1.git' } +[dev-dependencies] +near-sdk = { version = "5.1.0", features = ["unit-testing"] } +near-workspaces = { version = "0.10.0", features = ["unstable"] } +tokio = { version = "1.12.0", features = ["full"] } +serde_json = "1" [profile.release] -codegen-units=1 +codegen-units = 1 +# Tell `rustc` to optimize for small code size. opt-level = "z" lto = true debug = false panic = "abort" -overflow-checks = true +# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801 +overflow-checks = true \ No newline at end of file diff --git a/nft-series/build.sh b/nft-series/build.sh deleted file mode 100755 index eed9fb1..0000000 --- a/nft-series/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -set -e && RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release && mkdir -p ../out && cp target/wasm32-unknown-unknown/release/*.wasm ../out/series.wasm \ No newline at end of file diff --git a/nft-series/res/nft_simple.wasm b/nft-series/res/nft_simple.wasm deleted file mode 100755 index f68c929..0000000 Binary files a/nft-series/res/nft_simple.wasm and /dev/null differ diff --git a/nft-series/rust-toolchain.toml b/nft-series/rust-toolchain.toml new file mode 100644 index 0000000..97c2073 --- /dev/null +++ b/nft-series/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt"] +targets = ["wasm32-unknown-unknown"] \ No newline at end of file diff --git a/nft-series/src/enumeration.rs b/nft-series/src/enumeration.rs index 31e85cb..2dd505b 100644 --- a/nft-series/src/enumeration.rs +++ b/nft-series/src/enumeration.rs @@ -3,7 +3,8 @@ use crate::nft_core::NonFungibleTokenCore; /// Struct to return in views to query for specific data related to a series -#[derive(BorshDeserialize, BorshSerialize, Serialize)] +#[derive(BorshDeserialize, BorshSerialize, Serialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] #[serde(crate = "near_sdk::serde")] pub struct JsonSeries { series_id: u64, @@ -18,15 +19,15 @@ pub struct JsonSeries { #[near_bindgen] impl Contract { //Query for the total supply of NFTs on the contract - pub fn nft_total_supply(&self) -> U128 { + pub fn nft_total_supply(&self) -> U64 { //return the length of the tokens by id - U128(self.tokens_by_id.len() as u128) + U64(self.tokens_by_id.len()) } //Query for nft tokens on the contract regardless of the owner using pagination - pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { + pub fn nft_tokens(&self, from_index: Option, limit: Option) -> Vec { //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through each token using an iterator self.tokens_by_id @@ -42,16 +43,16 @@ impl Contract { } //get the total supply of NFTs for a given owner - pub fn nft_supply_for_owner(&self, account_id: AccountId) -> U128 { + pub fn nft_supply_for_owner(&self, account_id: AccountId) -> U64 { //get the set of tokens for the passed in owner let tokens_for_owner_set = self.tokens_per_owner.get(&account_id); - //if there is some set of tokens, we'll return the length as a U128 + //if there is some set of tokens, we'll return the length if let Some(tokens_for_owner_set) = tokens_for_owner_set { - U128(tokens_for_owner_set.len() as u128) + U64(tokens_for_owner_set.len()) } else { //if there isn't a set of tokens for the passed in account ID, we'll return 0 - U128(0) + U64(0) } } @@ -59,7 +60,7 @@ impl Contract { pub fn nft_tokens_for_owner( &self, account_id: AccountId, - from_index: Option, + from_index: Option, limit: Option, ) -> Vec { //get the set of tokens for the passed in owner @@ -73,7 +74,7 @@ impl Contract { }; //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through the keys vector tokens @@ -89,14 +90,14 @@ impl Contract { } // Get the total supply of series on the contract - pub fn get_series_total_supply(&self) -> u64 { - self.series_by_id.len() + pub fn get_series_total_supply(&self) -> U64 { + U64(self.series_by_id.len()) } // Paginate through all the series on the contract and return the a vector of JsonSeries - pub fn get_series(&self, from_index: Option, limit: Option) -> Vec { + pub fn get_series(&self, from_index: Option, limit: Option) -> Vec { //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through each series using an iterator self.series_by_id @@ -130,15 +131,15 @@ impl Contract { } //get the total supply of NFTs on a current series - pub fn nft_supply_for_series(&self, id: u64) -> U128 { + pub fn nft_supply_for_series(&self, id: u64) -> U64 { //get the series let series = self.series_by_id.get(&id); //if there is some series, get the length of the tokens. Otherwise return - if let Some(series) = series { - U128(series.tokens.len() as u128) + U64(series.tokens.len()) } else { - U128(0) + U64(0) } } @@ -146,7 +147,7 @@ impl Contract { pub fn nft_tokens_for_series( &self, id: u64, - from_index: Option, + from_index: Option, limit: Option, ) -> Vec { // Get the series and its tokens @@ -158,7 +159,7 @@ impl Contract { }; //where to start pagination - if we have a from_index, we'll use that - otherwise start from 0 index - let start = u128::from(from_index.unwrap_or(U128(0))); + let start = from_index.unwrap_or(0); //iterate through the tokens tokens diff --git a/nft-series/src/internal.rs b/nft-series/src/internal.rs index c2b57fe..1e9f959 100644 --- a/nft-series/src/internal.rs +++ b/nft-series/src/internal.rs @@ -2,31 +2,30 @@ use crate::*; use near_sdk::CryptoHash; use std::mem::size_of; -//convert the royalty percentage and amount to pay into a payout (U128) -pub(crate) fn royalty_to_payout(royalty_percentage: u32, amount_to_pay: Balance) -> U128 { - U128(royalty_percentage as u128 * amount_to_pay / 10_000u128) +//Basic NEAR amounts as constants +const ZERO_NEAR: NearToken = NearToken::from_yoctonear(0); +const ONE_YOCTONEAR: NearToken = NearToken::from_yoctonear(1); + +//convert the royalty percentage and amount to pay into a payout +pub(crate) fn royalty_to_payout(royalty_percentage: u128, amount_to_pay: u128) -> U128 { + U128(amount_to_pay.saturating_mul(royalty_percentage)) } //calculate how many bytes the account ID is taking up -pub(crate) fn bytes_for_approved_account_id(account_id: &AccountId) -> u64 { +pub(crate) fn bytes_for_approved_account_id(account_id: &AccountId) -> u128 { // The extra 4 bytes are coming from Borsh serialization to store the length of the string. - account_id.as_str().len() as u64 + 4 + size_of::() as u64 + account_id.as_str().len() as u128 + 4 + size_of::() as u128 } -//refund the storage taken up by passed in approved account IDs and send the funds to the passed in account ID. +//refund the storage taken up by passed in approved account IDs and send the funds to the passed in account ID. pub(crate) fn refund_approved_account_ids_iter<'a, I>( - account_id: AccountId, - approved_account_ids: I, //the approved account IDs must be passed in as an iterator -) -> Promise -where - I: Iterator, -{ - //get the storage total by going through and summing all the bytes for each approved account IDs - let storage_released: u64 = approved_account_ids - .map(bytes_for_approved_account_id) - .sum(); - //transfer the account the storage that is released - Promise::new(account_id).transfer(Balance::from(storage_released) * env::storage_byte_cost()) + account_id: AccountId, + approved_account_ids: I, //the approved account IDs must be passed in as an iterator +) -> Promise where I: Iterator { + //get the storage total by going through and summing all the bytes for each approved account IDs + let storage_released = approved_account_ids.map(bytes_for_approved_account_id).sum(); + //transfer the account the storage that is released + Promise::new(account_id).transfer(env::storage_byte_cost().saturating_mul(storage_released)) } //refund a map of approved account IDs and send the funds to the passed in account ID @@ -49,63 +48,63 @@ pub(crate) fn hash_account_id(account_id: &String) -> CryptoHash { //used to make sure the user attached exactly 1 yoctoNEAR pub(crate) fn assert_one_yocto() { - assert_eq!( - env::attached_deposit(), - 1, - "Requires attached deposit of exactly 1 yoctoNEAR", - ) + assert_eq!( + env::attached_deposit(), + ONE_YOCTONEAR, + "Requires attached deposit of exactly 1 yoctoNEAR", + ) } //Assert that the user has attached at least 1 yoctoNEAR (for security reasons and to pay for storage) pub(crate) fn assert_at_least_one_yocto() { - assert!( - env::attached_deposit() >= 1, - "Requires attached deposit of at least 1 yoctoNEAR", - ) + assert!( + env::attached_deposit() >= ONE_YOCTONEAR, + "Requires attached deposit of at least 1 yoctoNEAR", + ) } // Send all the non storage funds to the series owner -pub(crate) fn payout_series_owner(storage_used: u64, price_per_token: Balance, owner_id: AccountId) { +pub(crate) fn payout_series_owner(storage_used: u128, price_per_token: NearToken, owner_id: AccountId) { //get how much it would cost to store the information - let required_cost = env::storage_byte_cost() * Balance::from(storage_used); + let required_cost = env::storage_byte_cost().saturating_mul(storage_used); //get the attached deposit let attached_deposit = env::attached_deposit(); //make sure that the attached deposit is greater than or equal to the required cost assert!( - attached_deposit >= required_cost + price_per_token, + attached_deposit.ge(&required_cost.saturating_add(price_per_token)), "Must attach {} yoctoNEAR to cover storage and price per token {}", required_cost, price_per_token ); // If there's a price for the token, transfer everything but the storage to the series owner - if price_per_token > 0 { - Promise::new(owner_id).transfer(attached_deposit - required_cost); + if price_per_token.gt(&ZERO_NEAR) { + Promise::new(owner_id).transfer(attached_deposit.saturating_sub(required_cost)); } } //refund the initial deposit based on the amount of storage that was used up -pub(crate) fn refund_deposit(storage_used: u64) { - //get how much it would cost to store the information - let required_cost = env::storage_byte_cost() * Balance::from(storage_used); - //get the attached deposit - let attached_deposit = env::attached_deposit(); - - //make sure that the attached deposit is greater than or equal to the required cost - assert!( - required_cost <= attached_deposit, - "Must attach {} yoctoNEAR to cover storage", - required_cost, - ); - - //get the refund amount from the attached deposit - required cost - let refund = attached_deposit - required_cost; - - //if the refund is greater than 1 yocto NEAR, we refund the predecessor that amount - if refund > 1 { - Promise::new(env::predecessor_account_id()).transfer(refund); - } +pub(crate) fn refund_deposit(storage_used: u128) { + //get how much it would cost to store the information + let required_cost = env::storage_byte_cost().saturating_mul(storage_used); + //get the attached deposit + let attached_deposit = env::attached_deposit(); + + //make sure that the attached deposit is greater than or equal to the required cost + assert!( + required_cost <= attached_deposit, + "Must attach {} yoctoNEAR to cover storage", + required_cost, + ); + + //get the refund amount from the attached deposit - required cost + let refund = attached_deposit.saturating_sub(required_cost); + + //if the refund is greater than 1 yocto NEAR, we refund the predecessor that amount + if refund.gt(&ONE_YOCTONEAR) { + Promise::new(env::predecessor_account_id()).transfer(refund); + } } impl Contract { @@ -131,8 +130,6 @@ impl Contract { //we get a new unique prefix for the collection account_id_hash: hash_account_id(&account_id.to_string()), } - .try_to_vec() - .unwrap(), ) }); diff --git a/nft-series/src/lib.rs b/nft-series/src/lib.rs index b6cd742..e1f1c02 100644 --- a/nft-series/src/lib.rs +++ b/nft-series/src/lib.rs @@ -1,10 +1,10 @@ use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::collections::{LazyOption, LookupMap, LookupSet, UnorderedMap, UnorderedSet}; -use near_sdk::json_types::{Base64VecU8, U128}; +use near_sdk::json_types::{Base64VecU8, U64, U128}; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::{ - env, near_bindgen, require, AccountId, Balance, BorshStorageKey, CryptoHash, PanicOnDefault, - Promise, PromiseOrValue, + env, near_bindgen, require, AccountId, NearToken, BorshStorageKey, CryptoHash, PanicOnDefault, + Promise, PromiseOrValue, NearSchema }; use std::collections::HashMap; @@ -34,6 +34,7 @@ pub const NFT_STANDARD_NAME: &str = "nep171"; // Represents the series type. All tokens will derive this data. #[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] pub struct Series { // Metadata including title, num copies etc.. that all tokens will derive from metadata: TokenMetadata, @@ -43,7 +44,7 @@ pub struct Series { tokens: UnorderedSet, // What is the price of each token in this series? If this is specified, when minting, // Users will need to attach enough $NEAR to cover the price. - price: Option, + price: Option, // Owner of the collection owner_id: AccountId, } @@ -52,6 +53,7 @@ pub type SeriesId = u64; #[near_bindgen] #[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] +#[borsh(crate = "near_sdk::borsh")] pub struct Contract { //contract owner pub owner_id: AccountId, @@ -77,6 +79,7 @@ pub struct Contract { /// Helper structure for keys of the persistent collections. #[derive(BorshSerialize, BorshStorageKey)] +#[borsh(crate = "near_sdk::borsh")] pub enum StorageKey { ApprovedMinters, ApprovedCreators, @@ -121,26 +124,26 @@ impl Contract { pub fn new(owner_id: AccountId, metadata: NFTContractMetadata) -> Self { // Create the approved minters set and insert the owner let mut approved_minters = - LookupSet::new(StorageKey::ApprovedMinters.try_to_vec().unwrap()); + LookupSet::new(StorageKey::ApprovedMinters); approved_minters.insert(&owner_id); // Create the approved creators set and insert the owner let mut approved_creators = - LookupSet::new(StorageKey::ApprovedCreators.try_to_vec().unwrap()); + LookupSet::new(StorageKey::ApprovedCreators); approved_creators.insert(&owner_id); // Create a variable of type Self with all the fields initialized. let this = Self { approved_minters, approved_creators, - series_by_id: UnorderedMap::new(StorageKey::SeriesById.try_to_vec().unwrap()), + series_by_id: UnorderedMap::new(StorageKey::SeriesById), //Storage keys are simply the prefixes used for the collections. This helps avoid data collision - tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner.try_to_vec().unwrap()), - tokens_by_id: UnorderedMap::new(StorageKey::TokensById.try_to_vec().unwrap()), + tokens_per_owner: LookupMap::new(StorageKey::TokensPerOwner), + tokens_by_id: UnorderedMap::new(StorageKey::TokensById), //set the &owner_id field equal to the passed in owner_id. owner_id, metadata: LazyOption::new( - StorageKey::NFTContractMetadata.try_to_vec().unwrap(), + StorageKey::NFTContractMetadata, Some(&metadata), ), }; diff --git a/nft-series/src/metadata.rs b/nft-series/src/metadata.rs index 7ac6730..4f2f025 100644 --- a/nft-series/src/metadata.rs +++ b/nft-series/src/metadata.rs @@ -1,13 +1,14 @@ use crate::*; pub type TokenId = String; //defines the payout type we'll be returning as a part of the royalty standards. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, NearSchema)] #[serde(crate = "near_sdk::serde")] pub struct Payout { pub payout: HashMap, } -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone)] +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Clone, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] #[serde(crate = "near_sdk::serde")] pub struct NFTContractMetadata { pub spec: String, // required, essentially a version like "nft-1.0.0" @@ -19,7 +20,8 @@ pub struct NFTContractMetadata { pub reference_hash: Option, // Base64-encoded sha256 hash of JSON from reference field. Required if `reference` is included. } -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize)] +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, NearSchema)] +#[borsh(crate = "near_sdk::borsh")] #[serde(crate = "near_sdk::serde")] pub struct TokenMetadata { pub title: Option, // ex. "Arch Nemesis: Mail Carrier" or "Parcel #5055" @@ -37,6 +39,7 @@ pub struct TokenMetadata { } #[derive(BorshDeserialize, BorshSerialize)] +#[borsh(crate = "near_sdk::borsh")] pub struct Token { // Series that the token belongs to pub series_id: u64, @@ -49,7 +52,7 @@ pub struct Token { } //The Json token is what will be returned from view calls. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, NearSchema)] #[serde(crate = "near_sdk::serde")] pub struct JsonToken { // Series that the token belongs to diff --git a/nft-series/src/nft_core.rs b/nft-series/src/nft_core.rs index fdcac62..6166edf 100644 --- a/nft-series/src/nft_core.rs +++ b/nft-series/src/nft_core.rs @@ -1,8 +1,8 @@ use crate::*; use near_sdk::{ext_contract, Gas, PromiseResult}; -const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas(10_000_000_000_000); -const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas(25_000_000_000_000); +const GAS_FOR_RESOLVE_TRANSFER: Gas = Gas::from_tgas(10); +const GAS_FOR_NFT_ON_TRANSFER: Gas = Gas::from_tgas(25); pub trait NonFungibleTokenCore { //transfers an NFT to a receiver ID diff --git a/nft-series/src/royalty.rs b/nft-series/src/royalty.rs index 6745f66..7e3ab35 100644 --- a/nft-series/src/royalty.rs +++ b/nft-series/src/royalty.rs @@ -66,7 +66,7 @@ impl NonFungibleTokenCore for Contract { // payout_object .payout - .insert(key, royalty_to_payout(*v, balance_u128)); + .insert(key, royalty_to_payout(*v as u128, balance_u128)); total_perpetual += *v; } } @@ -74,7 +74,7 @@ impl NonFungibleTokenCore for Contract { // payout to previous owner who gets 100% - total perpetual royalties payout_object.payout.insert( owner_id, - royalty_to_payout(10000 - total_perpetual, balance_u128), + royalty_to_payout((10000 - total_perpetual).into(), balance_u128) ); //return the payout object @@ -150,7 +150,7 @@ impl NonFungibleTokenCore for Contract { // payout_object .payout - .insert(key, royalty_to_payout(*v, balance_u128)); + .insert(key, royalty_to_payout(*v as u128, balance_u128)); total_perpetual += *v; } } @@ -158,7 +158,7 @@ impl NonFungibleTokenCore for Contract { // payout to previous owner who gets 100% - total perpetual royalties payout_object.payout.insert( owner_id, - royalty_to_payout(10000 - total_perpetual, balance_u128), + royalty_to_payout((10000 - total_perpetual).into(), balance_u128) ); //return the payout object diff --git a/nft-series/src/series.rs b/nft-series/src/series.rs index c89208f..72b7f9b 100644 --- a/nft-series/src/series.rs +++ b/nft-series/src/series.rs @@ -1,5 +1,3 @@ -use near_sdk::json_types::U64; - use crate::*; #[near_bindgen] @@ -43,7 +41,7 @@ impl Contract { )), }), owner_id: caller, - price: price.map(|p| p.into()), + price: price.map(|p| NearToken::from_yoctonear(p.0)), } ) .is_none(), @@ -54,24 +52,24 @@ impl Contract { let required_storage_in_bytes = env::storage_usage() - initial_storage_usage; //refund any excess storage if the user attached too much. Panic if they didn't attach enough to cover the required. - refund_deposit(required_storage_in_bytes); + refund_deposit(required_storage_in_bytes.into()); } /// Mint a new NFT that is part of a series. The caller must be an approved minter. /// The series ID must exist and if the metadata specifies a copy limit, you cannot exceed it. #[payable] - pub fn nft_mint(&mut self, id: U64, receiver_id: AccountId) { + pub fn nft_mint(&mut self, id: u64, receiver_id: AccountId) { // Measure the initial storage being used on the contract let initial_storage_usage = env::storage_usage(); // Get the series and how many tokens currently exist (edition number = cur_len + 1) - let mut series = self.series_by_id.get(&id.0).expect("Not a series"); + let mut series = self.series_by_id.get(&id).expect("Not a series"); // Check if the series has a price per token. If it does, ensure the caller has attached at least that amount - let mut price_per_token = 0; + let mut price_per_token = NearToken::from_yoctonear(0); if let Some(price) = series.price { price_per_token = price; - require!(env::attached_deposit() > price_per_token, "Need to attach at least enough to cover price"); + require!(env::attached_deposit().ge(&price_per_token), "Need to attach at least enough to cover price"); // If the series doesn't have a price, ensure the caller is an approved minter. } else { // Ensure the caller is an approved minter @@ -92,14 +90,14 @@ impl Contract { } // The token ID is stored internally as `${series_id}:${edition}` - let token_id = format!("{}:{}", id.0, cur_len + 1); + let token_id = format!("{}:{}", id, cur_len + 1); series.tokens.insert(&token_id); - self.series_by_id.insert(&id.0, &series); + self.series_by_id.insert(&id, &series); //specify the token struct that contains the owner ID let token = Token { // Series ID that the token belongs to - series_id: id.0, + series_id: id, //set the owner ID equal to the receiver ID passed into the function owner_id: receiver_id, //we set the approved account IDs to the default value (an empty map) @@ -141,10 +139,10 @@ impl Contract { let required_storage_in_bytes = env::storage_usage() - initial_storage_usage; // If there's some price for the token, we'll payout the series owner. Otherwise, refund the excess deposit for storage to the caller - if price_per_token > 0 { - payout_series_owner(required_storage_in_bytes, price_per_token, series.owner_id); + if price_per_token.gt(&NearToken::from_yoctonear(0)) { + payout_series_owner(required_storage_in_bytes.into(), price_per_token, series.owner_id); } else { - refund_deposit(required_storage_in_bytes); + refund_deposit(required_storage_in_bytes.into()); } } } diff --git a/out/main.wasm b/out/main.wasm deleted file mode 100755 index 459543d..0000000 Binary files a/out/main.wasm and /dev/null differ diff --git a/out/market.wasm b/out/market.wasm deleted file mode 100755 index 4fe8538..0000000 Binary files a/out/market.wasm and /dev/null differ diff --git a/out/series.wasm b/out/series.wasm deleted file mode 100755 index 9b31f36..0000000 Binary files a/out/series.wasm and /dev/null differ diff --git a/package.json b/package.json deleted file mode 100644 index 15c2b35..0000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "nft-tutorial", - "version": "1.0.0", - "description": "Zero to Hero NFT Tutorial", - "author": "Ben Kurrek", - "license": "ISC", - "scripts": { - "build": "cd nft-contract && bash build.sh && cd .. && ls && cd market-contract && bash build.sh && cd .. && cd nft-series && bash build.sh && cd ..", - "test": "npm run test:unit && npm run test:integration", - "test:unit": "npm run test:unit:nft && npm run test:unit:market", - "test:unit:nft": "cd nft-contract && cargo test -- --nocapture --color=always && cd ..", - "test:unit:market": "cd market-contract && cargo test -- --nocapture --color=always && cd ..", - "test:integration": "npm run test:integration:ts && npm run test:integration:rs", - "test:integration:ts": "cd integration-tests/ts && npm i && npm run test", - "test:integration:rs": "cd integration-tests/rs && cargo run --example integration-tests" - } -}