diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..d66854a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,56 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + # Review gh actions docs if you want to further define triggers, paths, etc + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on + +defaults: + run: + working-directory: ./website + +jobs: + build: + name: Build Docusaurus + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 +# cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Build website + run: yarn build + + - name: Upload Build Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: website/build + + deploy: + name: Deploy to GitHub Pages + needs: build + + # Grant GITHUB_TOKEN the permissions required to make a Pages deployment + permissions: + pages: write # to deploy to Pages + id-token: write # to verify the deployment originates from an appropriate source + + # Deploy to the github-pages environment + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + \ No newline at end of file diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml new file mode 100644 index 0000000..7a8fc7f --- /dev/null +++ b/.github/workflows/test-deploy.yml @@ -0,0 +1,31 @@ +name: Test deployment + +on: + pull_request: + branches: + - main + # Review gh actions docs if you want to further define triggers, paths, etc + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on + +defaults: + run: + working-directory: ./website + +jobs: + test-deploy: + name: Test deployment + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 +# cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Test build website + run: yarn build + \ No newline at end of file diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..b2d6de3 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +/node_modules + +# Production +/build + +# Generated files +.docusaurus +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..b28211a --- /dev/null +++ b/website/README.md @@ -0,0 +1,41 @@ +# Website + +This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. + +## Installation + +```bash +yarn +``` + +## Local Development + +```bash +yarn start +``` + +This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. + +## Build + +```bash +yarn build +``` + +This command generates static content into the `build` directory and can be served using any static contents hosting service. + +## Deployment + +Using SSH: + +```bash +USE_SSH=true yarn deploy +``` + +Not using SSH: + +```bash +GIT_USER= yarn deploy +``` + +If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. diff --git a/website/docs/0-intro.md b/website/docs/0-intro.md new file mode 100644 index 0000000..20042fa --- /dev/null +++ b/website/docs/0-intro.md @@ -0,0 +1,52 @@ +--- +id: introduction +slug: / +title: NFT Zero to Hero +sidebar_label: Introduction +description: "Learn how to mint NFTs and build a full NFT contract step by step." +--- + +In this _Zero to Hero_ series, you'll find a set of tutorials that will cover every aspect of a non-fungible token (NFT) smart contract. +You'll start by minting an NFT using a pre-deployed contract and by the end you'll end up building a fully-fledged NFT smart contract that supports every extension. + +--- + +## Prerequisites + +To complete these tutorials successfully, you'll need: + +- [Rust](https://www.rust-lang.org/tools/install) +- [A Testnet wallet](https://testnet.mynearwallet.com/create) +- [NEAR-CLI](https://docs.near.org/tools/near-cli#installation) +- [cargo-near](https://github.com/near/cargo-near) + +:::info New to Rust? +If you are new to Rust and want to dive into smart contract development, our [Quick-start guide](https://docs.near.org/smart-contracts/quickstart) is a great place to start +::: + +--- + +## Overview + +These are the steps that will bring you from **_Zero_** to **_Hero_** in no time! 💪 + +| Step | Name | Description | +|------|---------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| 1 | [Pre-deployed contract](0-predeployed.md) | Mint an NFT without the need to code, create, or deploy a smart contract. | +| 2 | [Contract architecture](1-skeleton.md) | Learn the basic architecture of the NFT smart contract and compile code. | +| 3 | [Minting](2-minting.md) | Flesh out the skeleton so the smart contract can mint a non-fungible token. | +| 4 | [Upgrade a contract](2-upgrade.md) | Discover the process to upgrade an existing smart contract. | +| 5 | [Enumeration](3-enumeration.md) | Explore enumeration methods that can be used to return the smart contract's states. | +| 6 | [Core](4-core.md) | Extend the NFT contract using the core standard which allows token transfer. | +| 7 | [Events](7-events.md) | The events extension, allowing the contract to react on certain events. | +| 8 | [Approvals](5-approvals.md) | Expand the contract allowing other accounts to transfer NFTs on your behalf. | +| 9 | [Royalty](6-royalty.md) | Add NFT royalties allowing for a set percentage to be paid out to the token creator. | +| 10 | [Marketplace](8-marketplace.md) | Learn about how common marketplaces operate on NEAR and dive into some of the code that allows buying and selling NFTs. | + +--- + +## Next steps + +Ready to start? Jump to the [Pre-deployed Contract](0-predeployed.md) tutorial and begin your learning journey! + +If you already know about non-fungible tokens and smart contracts, feel free to skip and jump directly to the tutorial of your interest. The tutorials have been designed so you can start at any given point! diff --git a/website/docs/0-predeployed.md b/website/docs/0-predeployed.md new file mode 100644 index 0000000..a279c84 --- /dev/null +++ b/website/docs/0-predeployed.md @@ -0,0 +1,160 @@ +--- +id: predeployed-contract +title: Pre-deployed Contract +sidebar_label: Pre-deployed Contract +description: "Mint your first NFT using a pre-deployed contract before building your own NFT smart contract." +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Create your first non-fungible token by using a pre-deployed NFT smart contract which works exactly as the one you will build on this tutorial. + +--- + +## Prerequisites + +To complete this tutorial successfully, you'll need [a NEAR Wallet](https://testnet.mynearwallet.com/create) and [NEAR CLI](https://docs.near.org/tools/near-cli#installation) + +--- + +## Using the NFT contract + +Minting an NFT token on NEAR is a simple process that involves calling a smart contract function. + +To interact with the contract you will need to first login to your NEAR account through `near-cli`. + +
+ +### Setup + +Log in to your newly created account with `near-cli` by running the following command in your terminal: + +```bash +near account import-account using-web-wallet network-config testnet +``` + +Set an environment variable for your account ID to make it easy to copy and paste commands from this tutorial: + +```bash +export NEARID=YOUR_ACCOUNT_NAME +``` + +
+ +### Minting your NFTs + +We have already deployed an NFT contract to `nft.examples.testnet` which allows users to freely mint tokens. Let's use it to mint our first token. + +Run this command in your terminal, remember to replace the `token_id` with a string of your choice. This string will uniquely identify the token you mint. + + + + + ```bash + near call nft.examples.testnet nft_mint '{"token_id": "TYPE_A_UNIQUE_VALUE_HERE", "receiver_id": "'$NEARID'", "metadata": { "title": "GO TEAM", "description": "The Team Goes", "media": "https://bafybeidl4hjbpdr6u6xvlrizwxbrfcyqurzvcnn5xoilmcqbxfbdwrmp5m.ipfs.dweb.link/", "copies": 1}}' --gas 100000000000000 --deposit 0.1 --accountId $NEARID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction nft.examples.testnet nft_mint json-args '{"token_id": "TYPE_A_UNIQUE_VALUE_HERE", "receiver_id": "'$NEARID'", "metadata": { "title": "GO TEAM", "description": "The Team Goes", "media": "https://bafybeidl4hjbpdr6u6xvlrizwxbrfcyqurzvcnn5xoilmcqbxfbdwrmp5m.ipfs.dweb.link/", "copies": 1}}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NEARID network-config testnet sign-with-keychain send + ``` + + + +
+Example response: +

+ +```json +Log [nft.examples.testnet]: EVENT_JSON:{"standard":"nep171","version":"nft-1.0.0","event":"nft_mint","data":[{"owner_id":"benjiman.testnet","token_ids":["TYPE_A_UNIQUE_VALUE_HERE"]}]} +Transaction Id 8RFWrQvAsm2grEsd1UTASKpfvHKrjtBdEyXu7WqGBPUr +To see the transaction in the transaction explorer, please open this url in your browser +https://testnet.nearblocks.io/txns/8RFWrQvAsm2grEsd1UTASKpfvHKrjtBdEyXu7WqGBPUr +'' +``` + +

+
+ +:::tip +You can also replace the `media` URL with a link to any image file hosted on your web server. +::: + +
+ +### Querying your NFT + +To view tokens owned by an account you can call the NFT contract with the following `near-cli` command: + + + + + ```bash + near view nft.examples.testnet nft_tokens_for_owner '{"account_id": "'$NEARID'"}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only nft.examples.testnet nft_tokens_for_owner json-args '{"account_id": "'$NEARID'"}' network-config testnet now + ``` + + + +
+Example response: +

+ +```json +[ + { + "token_id": "Goi0CZ", + "owner_id": "bob.testnet", + "metadata": { + "title": "GO TEAM", + "description": "The Team Goes", + "media": "https://bafybeidl4hjbpdr6u6xvlrizwxbrfcyqurzvcnn5xoilmcqbxfbdwrmp5m.ipfs.dweb.link/", + "media_hash": null, + "copies": 1, + "issued_at": null, + "expires_at": null, + "starts_at": null, + "updated_at": null, + "extra": null, + "reference": null, + "reference_hash": null + }, + "approved_account_ids": {} + } +] +``` + +

+
+ +**Congratulations!** You just minted your first NFT token on the NEAR blockchain! 🎉 + +Now try going to your [NEAR Wallet](https://testnet.mynearwallet.com) and view your NFT in the "Collectibles" tab. + +--- + +## Final remarks + +This basic example illustrates all the required steps to call an NFT smart contract on NEAR and start minting your own non-fungible tokens. + +Now that you're familiar with the process, you can jump to [Contract Architecture](1-skeleton.md) and learn more about the smart contract structure and how you can build your own NFT contract from the ground up. + +***Happy minting!*** 🪙 + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- near-cli-rs: `0.17.0` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` + +::: diff --git a/website/docs/1-skeleton.md b/website/docs/1-skeleton.md new file mode 100644 index 0000000..f52b260 --- /dev/null +++ b/website/docs/1-skeleton.md @@ -0,0 +1,270 @@ +--- +id: skeleton +title: Skeleton and Rust Architecture +sidebar_label: Contract Architecture +description: "Learn the basic file structure of the NFT contract and how each Rust file works." +--- +import {Github} from "@site/src/components/UI/Codetabs" + +In this tutorial, you'll explore the architecture of the NFT contract and how Rust files are organized to build a fully-featured smart contract. + +You'll get a walkthrough of each file and its role, giving you the foundation to start building and compiling your own NFT contract. + +:::info Skeleton Contract +You can find the skeleton contract in our [GitHub repository](https://github.com/near-examples/nft-tutorial/tree/main/nft-contract-skeleton) +::: + +:::info New to Rust? +If you are new to Rust and want to dive into smart contract development, our [Quick-start guide](https://docs.near.org/smart-contracts/quickstart) is a great place to start. +::: + +--- + +## Introduction + +This tutorial presents the code skeleton for the NFT smart contract and its file structure. + +Once every file and functions have been covered, we will guide you through the process of building the mock-up contract to confirm that your Rust setup works. + +--- + +## File structure + +Following a regular [Rust](https://www.rust-lang.org/) project, the file structure for this smart contract has: + +``` +nft-contract +├── Cargo.lock +├── Cargo.toml +├── README.md +└── src + ├── approval.rs + ├── enumeration.rs + ├── lib.rs + ├── metadata.rs + ├── mint.rs + ├── nft_core.rs + ├── events.rs + └── royalty.rs +``` + +- The file `Cargo.toml` defines the code dependencies +- The `src` folder contains all the Rust source files + +
+ +### Source files + +Here is a brief description of what each source file is responsible for: + +| File | Description | +|----------------------------------|---------------------------------------------------------------------------------| +| [approval.rs](#approvalrs) | Has the functions that controls the access and transfers of non-fungible tokens | +| [enumeration.rs](#enumerationrs) | Contains the methods to list NFT tokens and their owners | +| [lib.rs](#librs) | Holds the smart contract initialization functions | +| [metadata.rs](#metadatars) | Defines the token and metadata structure | +| [mint.rs](#mintrs) | Contains token minting logic | +| [nft_core.rs](#nft_corers) | Core logic that allows you to transfer NFTs between users. | +| [royalty.rs](#royaltyrs) | Contains payout-related functions | +| [events.rs](#eventsrs) | Contains events related structures | + +:::tip +Explore the code in our [GitHub repository](https://github.com/near-examples/nft-tutorial/). +::: + +--- + +## `approval.rs` + +> This allows people to approve other accounts to transfer NFTs on their behalf. + +This file contains the logic that complies with the standard's [approvals management](https://github.com/near/NEPs/tree/master/neps/nep-0178.md) extension. Here is a breakdown of the methods and their functions: + +| Method | Description | +|---------------------|-----------------------------------------------------------------------------------------------------------| +| **nft_approve** | Approves an account ID to transfer a token on your behalf. | +| **nft_is_approved** | Checks if the input account has access to approve the token ID. | +| **nft_revoke** | Revokes a specific account from transferring the token on your behalf. | +| **nft_revoke_all** | Revokes all accounts from transferring the token on your behalf. | +| **nft_on_approve** | This callback function, initiated during `nft_approve`, is a cross contract call to an external contract. | + + + +You'll learn more about these functions in the [approvals section](5-approvals.md) of the Zero to Hero series. + +--- + +## `enumeration.rs` + +> This file provides the functions needed to view information about NFTs, and follows the standard's [enumeration](https://github.com/near/NEPs/tree/master/neps/nep-0181.md) extension. + +| Method | Description | +|--------------------------|------------------------------------------------------------------------------------| +| **nft_total_supply** | Returns the total amount of NFTs stored on the contract | +| **nft_tokens** | Returns a paginated list of NFTs stored on the contract regardless of their owner. | +| **nft_supply_for_owner** | Allows you view the total number of NFTs owned by any given user | +| **nft_tokens_for_owner** | Returns a paginated list of NFTs owned by any given user | + + + +You'll learn more about these functions in the [enumeration section](3-enumeration.md) of the tutorial series. + +--- + +## `lib.rs` + +> This file outlines what information the contract stores and keeps track of. + +| Method | Description | +|----------------------|-------------------------------------------------------------------------------------------------| +| **new_default_meta** | Initializes the contract with default `metadata` so the user doesn't have to provide any input. | +| **new** | Initializes the contract with the user-provided `metadata`. | + +:::info Keep in mind +The initialization functions (`new`, `new_default_meta`) can only be called once. +::: + + + +You'll learn more about these functions in the [minting section](2-minting.md) of the tutorial series. + +--- + +## `metadata.rs` + +> This file is used to keep track of the information to be stored for tokens, and metadata. +> In addition, you can define a function to view the contract's metadata which is part of the standard's [metadata](https://github.com/near/NEPs/tree/master/neps/nep-0177.md) extension. + +| Name | Description | +|-------------------|---------------------------------------------------------------------------------------------------------------| +| **TokenMetadata** | This structure defines the metadata that can be stored for each token (title, description, media, etc.). | +| **Token** | This structure outlines what information will be stored on the contract for each token. | +| **JsonToken** | When querying information about NFTs through view calls, the return information is stored in this JSON token. | +| **nft_metadata** | This function allows users to query for the contact's internal metadata. | + + + +You'll learn more about these functions in the [minting section](2-minting.md) of the tutorial series. + +--- + +## `mint.rs` + +> Contains the logic to mint the non-fungible tokens + +| Method | Description | +|--------------|-------------------------------------------| +| **nft_mint** | This function mints a non-fungible token. | + + + +--- + +## `nft_core.rs` + +> Core logic that allows to transfer NFTs between users. + +| Method | Description | +|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **nft_transfer** | Transfers an NFT to a receiver ID. | +| **nft_transfer_call** | Transfers an NFT to a receiver and calls a function on the receiver ID's contract. The function returns `true` if the token was transferred from the sender's account. | +| **nft_token** | Allows users to query for the information about a specific NFT. | +| **nft_on_transfer** | Called by other contracts when an NFT is transferred to your contract account via the `nft_transfer_call` method. It returns `true` if the token should be returned back to the sender. | +| **nft_resolve_transfer** | When you start the `nft_transfer_call` and transfer an NFT, the standard also calls a method on the receiver's contract. If the receiver needs you to return the NFT to the sender (as per the return value of the `nft_on_transfer` method), this function allows you to execute that logic. | + + + +You'll learn more about these functions in the [core section](4-core.md) of the tutorial series. + +--- + +## `royalty.rs` + +> Contains payout-related functions. + +| Method | Description | +|-------------------------|---------------------------------------------------------------------------------------------------------------| +| **nft_payout** | This view method calculates the payout for a given token. | +| **nft_transfer_payout** | Transfers the token to the receiver ID and returns the payout object that should be paid for a given balance. | + + + +You'll learn more about these functions in the [royalty section](6-royalty.md) of the tutorial series. + +--- + +## `events.rs` + +> Contains events-related structures. + +| Method | Description | +|---------------------|-----------------------------------------------------| +| **EventLogVariant** | This enum represents the data type of the EventLog. | +| **EventLog** | Interface to capture data about an event. | +| **NftMintLog** | An event log to capture token minting. | +| **NftTransferLog** | An event log to capture token transfer. | + + + +You'll learn more about these functions in the [events section](7-events.md) of the tutorial series. + +--- + +## Building the skeleton + +If you haven't cloned the main repository yet, open a terminal and run: + +```sh +git clone https://github.com/near-examples/nft-tutorial/ +``` + +Next, go to the `nft-contract-skeleton/` folder and build the contract with `cargo-near`: + +```sh +cd nft-tutorial +cd nft-contract-skeleton/ +cargo near build +``` + +Since this source is just a skeleton you'll get many warnings about unused code, such as: + +``` + Compiling nft_contract_skeleton v0.1.0 (/Users/near-examples/Documents/my/projects/near/examples/nft-tutorial/nft-contract-basic) + │ warning: unused imports: `LazyOption`, `LookupMap`, `UnorderedMap`, `UnorderedSet` + │ --> src/lib.rs:3:29 + │ | + │ 3 | use near_sdk::collections::{LazyOption, LookupMap, UnorderedMap, UnorderedSet}; + │ | ^^^^^^^^^^ ^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^ + │ | + │ = note: `#[warn(unused_imports)]` on by default + │ + │ warning: unused import: `Base64VecU8` + │ --> src/lib.rs:4:28 + │ | + │ 4 | use near_sdk::json_types::{Base64VecU8, U128}; + │ | + + │ warning: `nft_contract_skeleton` (lib) generated 48 warnings (run `cargo fix --lib -p nft_contract_skeleton` to apply 45 suggestions) + │ Finished release [optimized] target(s) in 11.01s + ✓ Contract successfully built! +``` + +Don't worry about these warnings, you're not going to deploy this contract yet. +Building the skeleton is useful to validate that your Rust toolchain works properly and that you'll be able to compile improved versions of this NFT contract in the upcoming tutorials. + +--- + +## Conclusion + +You've seen the layout of this NFT smart contract, and how all the functions are laid out across the different source files. +Using `yarn`, you've been able to compile the contract, and you'll start fleshing out this skeleton in the next [Minting tutorial](2-minting.md). + +:::note Versioning for this article +At the time of this writing, this example works with the following versions: + +- rustc: `1.76.0` +- near-sdk-rs: `5.1.0` +- cargo-near: `0.13.2` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` + +::: diff --git a/website/docs/2-minting.md b/website/docs/2-minting.md new file mode 100644 index 0000000..4d2a0ab --- /dev/null +++ b/website/docs/2-minting.md @@ -0,0 +1,442 @@ +--- +id: minting +title: Minting +sidebar_label: Minting +description: "Learn to mint NFTs from scratch with a smart contract that follows all NEAR NFT standards." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +This is the first of many tutorials in a series where you'll be creating a complete NFT smart contract from scratch that conforms with all the NEAR [NFT standards](https://nomicon.io/Standards/NonFungibleToken/). + +Today you'll learn how to create the logic needed to mint NFTs and have them show up in your NEAR wallet. You will be filling a bare-bones [skeleton smart contract](1-skeleton.md) to add minting functionalities. + +:::info Contracts +You can find the skeleton contract in our [Skeleton folder](https://github.com/near-examples/nft-tutorial/tree/main/nft-contract-skeleton) + +A completed version of this tutorial can be found in the [Basic NFT folder](https://github.com/near-examples/nft-tutorial/tree/main/nft-contract-basic) +::: + +--- + +## Introduction + +To get started, go to the `nft-contract-skeleton` folder in our repo. If you haven't cloned the repository, refer to the [Contract Architecture](1-skeleton.md) to get started. + +``` +cd nft-contract-skeleton/ +``` + +If you wish to see the finished code of this step-by-step basic NFT contract tutorial, that can be found on the `nft-contract-basic` folder. + +--- + +## Modifications to the skeleton contract {#what-does-minting-mean} + +In order to implement the logic needed for minting, we should break it up into smaller tasks and handle those one-by-one. Let's step back and think about the best way to do this by asking ourselves a simple question: what does it mean to mint an NFT? + +To mint a non-fungible token, in the most simple way possible, a contract needs to be able to associate a token with an owner on the blockchain. This means you'll need: + +- A way to keep track of tokens and other information on the contract. +- A way to store information for each token such as `metadata` (more on that later). +- A way to link a token with an owner. + +That's it! We've now broken down the larger problem into some smaller, less daunting, subtasks. Let's start by tackling the first and work our way through the rest. + +
+ +### Storing information on the contract {#storing-information} + +Start by navigating to `nft-contract-skeleton/src/lib.rs` and filling in some of the code blocks. +You need to be able to store important information on the contract such as the list of tokens that an account has. + +#### Contract Struct + +The first thing to do is modifying the contract `struct` as follows: + + +This allows you to get the information stored in these data structures from anywhere in the contract. The code above has created 3 token specific storages: + +- **tokens_per_owner**: allows you to keep track of the tokens owned by any account +- **tokens_by_id**: returns all the information about a specific token +- **token_metadata_by_id**: returns just the metadata for a specific token + +In addition, you'll keep track of the owner of the contract as well as the metadata for the contract. + +You might be confused as to some of the types that are being used. In order to make the code more readable, we've introduced custom data types which we'll briefly outline below: + +- **AccountId**: a string that ensures there are no special or unsupported characters. +- **TokenId**: simply a string. + +As for the `Token`, `TokenMetadata`, and `NFTContractMetadata` data types, those are structs that we'll define later in this tutorial. + +#### Initialization Functions + +Next, create what's called an initialization function; we will name it `new`, but you can choose any name you prefer. + +This function needs to be invoked when you first deploy the contract. It will initialize all the contract's fields that you've defined above with default values. +Don't forget to add the `owner_id` and `metadata` fields as parameters to the function, so only those can be customized. + +This function will default all the collections to be empty and set the `owner` and `metadata` equal to what you pass in. + + + +More often than not when doing development, you'll need to deploy contracts several times. You can imagine that it might get tedious to have to pass in metadata every single time you want to initialize the contract. For this reason, let's create a function that can initialize the contract with a set of default `metadata`. You can call it `new_default_meta` and it'll only take the `owner_id` as a parameter. + + + +This function is simply calling the previous `new` function and passing in the owner that you specify and also passes in some default metadata. + +
+ +### Metadata and token information {#metadata-and-token-info} + +Now that you've defined what information to store on the contract itself and you've defined some ways to initialize the contract, you need to define what information should go in the `Token`, `TokenMetadata`, and `NFTContractMetadata` data types. + +Let's switch over to the `nft-contract-skeleton/src/metadata.rs` file as this is where that information will go. + +If you look at the [standards for metadata](https://nomicon.io/Standards/Tokens/NonFungibleToken/Metadata), you'll find all the necessary information that you need to store for both `TokenMetadata` and `NFTContractMetadata`. Simply fill in the following code. + + + +This now leaves you with the `Token` struct and something called a `JsonToken`. The `Token` struct will hold all the information directly related to the token excluding the metadata. The metadata, if you remember, is stored in a map on the contract in a data structure called `token_metadata_by_id`. This allows you to quickly get the metadata for any token by simply passing in the token's ID. + +For the `Token` struct, you'll just keep track of the owner for now. + + + +Since NEAR smart contracts receive and return data in JSON format, the purpose of the `JsonToken` is to act as output when the user asks information for an NFT. This means you'll want to store the owner, token ID, and metadata. + + + +:::tip +Some of you might be thinking _"how come we don't just store all the information in the `Token` struct?"_. +The reason behind this is that it's actually more efficient to construct the JSON token on the fly only when you need it rather than storing all the information in the token struct. +In addition, some operations might only need the metadata for a token and so having the metadata in a separate data structure is more optimal. +::: + +#### Function for querying contract metadata + +Now that you've defined some of the types that were used in the previous section, let's move on and create the first view function `nft_metadata`. This will allow users to query for the contract's metadata as per the [metadata standard](https://nomicon.io/Standards/Tokens/NonFungibleToken/Metadata). + + + +This function will get the `metadata` object from the contract which is of type `NFTContractMetadata` and will return it. + +Just like that, you've completed the first two tasks and are ready to move onto last part of the tutorial. + +
+ +### Minting Logic {#minting-logic} + +Now that all the information and types are defined, let's start brainstorming how the minting logic will play out. In the end, you need to link a `Token` and `TokenId` to a specific owner. Let's look back at the `lib.rs` file to see how you can accomplish this. There are a couple data structures that might be useful: + +```rust +//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, +``` + +Looking at these data structures, you could do the following: + +- Add the token ID into the set of tokens that the receiver owns. This will be done on the `tokens_per_owner` field. +- Create a token object and map the token ID to that token object in the `tokens_by_id` field. +- Map the token ID to it's metadata using the `token_metadata_by_id`. + +#### Storage Implications {#storage-implications} +With those steps outlined, it's important to take into consideration the storage costs of minting NFTs. Since you're adding bytes to the contract by creating entries in the data structures, the contract needs to cover the storage costs. If you just made it so any user could go and mint an NFT for free, that system could easily be abused and users could essentially "drain" the contract of all it's funds by minting thousands of NFTs. For this reason, you'll make it so that users need to attach a deposit to the call to cover the cost of storage. You'll measure the initial storage usage before anything was added and you'll measure the final storage usage after all the logic is finished. Then you'll make sure that the user has attached enough $NEAR to cover that cost and refund them if they've attached too much. + +This is how we do it in code: + + + + +You'll notice that we're using some internal methods such as `refund_deposit` and `internal_add_token_to_owner`. We've described the function of `refund_deposit` and as for `internal_add_token_to_owner`, this will add a token to the set of tokens an account owns for the contract's `tokens_per_owner` data structure. You can create these functions in a file called `internal.rs`. Go ahead and create the file. Your new contract architecture should look as follows: + +``` +nft-contract +├── Cargo.lock +├── Cargo.toml +├── README.md +├── build.sh +└── src + ├── approval.rs + ├── enumeration.rs + ├── internal.rs + ├── lib.rs + ├── metadata.rs + ├── mint.rs + ├── nft_core.rs + ├── events.rs + └── royalty.rs +``` + +Add the following to your newly created `internal.rs` file. + + + +:::note +You may notice more functions in the `internal.rs` file than we need for now. You may ignore them, we'll come back to them later. +::: + +Let's now quickly move to the `lib.rs` file and make the functions we just created invocable in other files. We'll add the internal crates and mod the file as shown below: + + + +At this point, the core logic is all in place so that you can mint NFTs. You can use the function `nft_mint` which takes the following parameters: + +- **token_id**: the ID of the token you're minting (as a string). +- **metadata**: the metadata for the token that you're minting (of type `TokenMetadata` which is found in the `metadata.rs` file). +- **receiver_id**: specifies who the owner of the token will be. + +Behind the scenes, the function will: + +1. Calculate the initial storage before adding anything to the contract +2. Create a `Token` object with the owner ID +3. Link the token ID to the newly created token object by inserting them into the `tokens_by_id` field. +4. Link the token ID to the passed in metadata by inserting them into the `token_metadata_by_id` field. +5. Add the token ID to the list of tokens that the owner owns by calling the `internal_add_token_to_owner` function. +6. Calculate the final and net storage to make sure that the user has attached enough NEAR to the call in order to cover those costs. + +
+ +### Querying for token information + +If you were to go ahead and deploy this contract, initialize it, and mint an NFT, you would have no way of knowing or querying for the information about the token you just minted. Let's quickly add a way to query for the information of a specific NFT. You'll move to the `nft-contract-skeleton/src/nft_core.rs` file and edit the `nft_token` function. + +It will take a token ID as a parameter and return the information for that token. The `JsonToken` contains the token ID, the owner ID, and the token's metadata. + + + +With that finished, it's finally time to build and deploy the contract so you can mint your first NFT. + +--- + +## Interacting with the contract on-chain + +Now that the logic for minting is complete and you've added a way to query for information about specific tokens, it's time to build and deploy your contract to the blockchain. + +### Deploying the contract {#deploy-the-contract} + +For deployment, you will need a NEAR account with the keys stored on your local machine. Navigate to the [NEAR wallet](https://testnet.mynearwallet.com/) site and create an account. + +:::info +Please ensure that you deploy the contract to an account with no pre-existing contracts. It's easiest to simply create a new account or create a sub-account for this tutorial. +::: + +Log in to your newly created account with [`near-cli-rs`](https://docs.near.org/tools/cli) by running the following command in your terminal. + +```bash +near account import-account using-web-wallet network-config testnet +``` + +To make this tutorial easier to copy/paste, we're going to set an environment variable for your account ID. In the command below, replace `YOUR_ACCOUNT_NAME` with the account name you just logged in with including the `.testnet` portion: + +```bash +export NFT_CONTRACT_ID="YOUR_ACCOUNT_NAME" +``` + +Test that the environment variable is set correctly by running: + +```bash +echo $NFT_CONTRACT_ID +``` + +Verify that the correct account ID is printed in the terminal. If everything looks correct you can now deploy your contract. +In the root of your NFT project run the following command to deploy your smart contract and answer questions: + +```bash +cargo near deploy build-non-reproducible-wasm $NFT_CONTRACT_ID + +> Select the need for initialization: with-init-call - Add an initialize +> What is the name of the function? new_default_meta +> How would you like to pass the function arguments? json-args +> Enter the arguments to this function: {"owner_id": ""} +> Enter gas for function call: 100 TeraGas +> Enter deposit for a function call (example: 10NEAR or 0.5near or 10000yoctonear): 0 NEAR +> What is the name of the network? testnet +> Select a tool for signing the transaction: sign-with-keychain +> How would you like to proceed? send +``` + +You don't need to answer these questions every time. If you look at the results you will find the message `Here is the console command if you ever need to re-run it again`. The next line is the command which you may use instead of answering to interactive questions: + +```bash +cargo near deploy build-non-reproducible-wasm $NFT_CONTRACT_ID with-init-call new_default_meta json-args '{"owner_id": "'$NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + +You've just deployed and initialized the contract with some default metadata and set your account ID as the owner. At this point, you're ready to call your first view function. + +
+ +### Viewing the contract's metadata + +Now that the contract has been initialized, you can call some of the functions you wrote earlier. More specifically, let's test out the function that returns the contract's metadata: + + + + + ```bash + near view $NFT_CONTRACT_ID nft_metadata '{}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_metadata json-args {} network-config testnet now + ``` + + + +This should return an output similar to the following: + +```bash +{ + spec: 'nft-1.0.0', + name: 'NFT Tutorial Contract', + symbol: 'GOTEAM', + icon: null, + base_uri: null, + reference: null, + reference_hash: null +} +``` + +At this point, you're ready to move on and mint your first NFT. + +
+ +### Minting our first NFT {#minting-our-first-nft} + +Let's now call the minting function that you've created. This requires a `token_id` and `metadata`. If you look back at the `TokenMetadata` struct you created earlier, there are many fields that could potentially be stored on-chain: + + + +Let's mint an NFT with a title, description, and media to start. The media field can be any URL pointing to a media file. We've got an excellent GIF to mint but if you'd like to mint a custom NFT, simply replace our media link with one of your choosing. If you run the following command, it will mint an NFT with the following parameters: + +- **token_id**: "token-1" +- **metadata**: + - _title_: "My Non Fungible Team Token" + - _description_: "The Team Most Certainly Goes :)" + - _media_: `https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif` + - **receiver_id**: "'$NFT_CONTRACT_ID'" + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"token_id": "token-1", "metadata": {"title": "My Non Fungible Team Token", "description": "The Team Most Certainly Goes :)", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"token_id": "token-1", "metadata": {"title": "My Non Fungible Team Token", "description": "The Team Most Certainly Goes :)", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +:::info +The `amount` flag is specifying how much NEAR to attach to the call. Since you need to pay for storage, 0.1 NEAR is attached and you'll get refunded any excess that is unused at the end. +::: + +
+ +### Viewing information about the NFT + +Now that the NFT has been minted, you can check and see if everything went correctly by calling the `nft_token` function. +This should return a `JsonToken` which should contain the `token_id`, `owner_id`, and `metadata`. + + + + + ```bash + near view $NFT_CONTRACT_ID nft_token '{"token_id": "token-1"}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_token json-args '{"token_id": "token-1"}' network-config testnet now + ``` + + + +
+Example response: +

+ +```bash +{ + token_id: 'token-1', + owner_id: 'goteam.examples.testnet', + metadata: { + title: 'My Non Fungible Team Token', + description: 'The Team Most Certainly Goes :)', + media: 'https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif', + media_hash: null, + copies: null, + issued_at: null, + expires_at: null, + starts_at: null, + updated_at: null, + extra: null, + reference: null, + reference_hash: null + } +} +``` + +

+
+ +**Go team!** You've now verified that everything works correctly and it's time to view your freshly minted NFT in the NEAR wallet's collectibles tab! + +--- + +## Viewing your NFTs in the wallet + +If you navigate to the [collectibles tab](https://testnet.mynearwallet.com/?tab=collectibles) in the NEAR wallet, this should list all the NFTs that you own. It should look something like the what's below. + +![empty-nft-in-wallet](/assets/docs/tutorials/nfts/empty-nft-in-wallet.png) + +We've got a problem. The wallet correctly picked up that you minted an NFT, however, the contract doesn't implement the specific view function that is being called. Behind the scenes, the wallet is trying to call `nft_tokens_for_owner` to get a list of all the NFTs owned by your account on the contract. The only function you've created, however, is the `nft_token` function. It wouldn't be very efficient for the wallet to call `nft_token` for every single NFT that a user has to get information and so they try to call the `nft_tokens_for_owner` function. + +In the next tutorial, you'll learn about how to deploy a patch fix to a pre-existing contract so that you can view the NFT in the wallet. + +--- + +## Conclusion + +In this tutorial, you went through the basics of setting up and understand the logic behind minting NFTs on the blockchain using a skeleton contract. + +You first looked at [what it means](#what-does-minting-mean) to mint NFTs and how to break down the problem into more feasible chunks. You then started modifying the skeleton contract chunk by chunk starting with solving the problem of [storing information / state](#storing-information) on the contract. You then looked at what to put in the [metadata and token information](#metadata-and-token-info). Finally, you looked at the logic necessary for [minting NFTs](#minting-logic). + +After the contract was written, it was time to deploy to the blockchain. You [deployed and initialized the contract](#deploy-the-contract). Finally, you [minted your very first NFT](#minting-our-first-nft) and saw that some changes are needed before you can view it in the wallet. + +--- + +## Next Steps + +In the [next tutorial](2-upgrade.md), you'll find out how to deploy a patch fix and what that means so that you can view your NFTs in the wallet. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://nomicon.io/Standards/Tokens/NonFungibleToken/Core), version `1.0.0` +- Metadata standard: [NEP177](https://nomicon.io/Standards/Tokens/NonFungibleToken/Metadata), version `2.1.0` + +::: diff --git a/website/docs/2-upgrade.md b/website/docs/2-upgrade.md new file mode 100644 index 0000000..e0286b6 --- /dev/null +++ b/website/docs/2-upgrade.md @@ -0,0 +1,160 @@ +--- +id: upgrade-contract +title: Upgrading the Contract +sidebar_label: Upgrade a Contract +description: "Learn how to implement the nft_tokens_for_owner method." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial, you'll build off the work you previously did to implement the [minting functionality](2-minting.md) on a skeleton smart contract. You got to the point where NFTs could be minted and the wallet correctly picked up on the fact that you owned an NFT. However, it had no way of displaying the tokens since your contract didn't implement the method that the wallet was trying to call. + +--- + +## Introduction + +Today you'll learn about deploying patch fixes to smart contracts and you'll use that knowledge to implement the `nft_tokens_for_owner` function on the contract you deployed in the previous tutorial. + +--- + +## Upgrading contracts overview {#upgrading-contracts} + +Upgrading contracts, when done right, can be an immensely powerful tool. If done wrong, it can lead to a lot of headaches. It's important to distinguish between the code and state of a smart contract. When a contract is deployed on top of an existing contract, the only thing that changes is the code. The state will remain the same and that's where a lot of developer's issues come to fruition. + +The NEAR Runtime will read the serialized state from disk and it will attempt to load it using the current contract code. When your code changes, it might not be able to figure out how to do this. + +You need to strategically upgrade your contracts and make sure that the runtime will be able to read your current state with the new contract code. For more information about upgrading contracts and some best practices, see the NEAR SDK's [upgrading contracts](https://docs.near.org/smart-contracts/release/upgrade) write-up. + +--- + +## Modifications to our contract {#modifications-to-contract} + +In order for the wallet to properly display your NFTs, you need to implement the `nft_tokens_for_owner` method. This will allow anyone to query for a paginated list of NFTs owned by a given account ID. + +To accomplish this, let's break it down into some smaller subtasks. First, you need to get access to a list of all token IDs owned by a user. This information can be found in the `tokens_per_owner` data structure. Now that you have a set of token IDs, you need to convert them into `JsonToken` objects as that's what you'll be returning from the function. + +Luckily, you wrote a function `nft_token` which takes a token ID and returns a `JsonToken` in the `nft_core.rs` file. As you can guess, in order to get a list of `JsonToken` objects, you would need to iterate through the token IDs owned by the user and then convert each token ID into a `JsonToken` and store that in a list. + +As for the pagination, Rust has some awesome functions for skipping to a starting index and taking the first `n` elements of an iterator. + +Let's move over to the `enumeration.rs` file and implement that logic: + + + +--- + +## Redeploying the contract {#redeploying-contract} + +Now that you've implemented the necessary logic for `nft_tokens_for_owner`, it's time to build and re-deploy the contract to your account. Using the cargo-near, deploy the contract as you did in the previous tutorial: + +```bash +cargo near deploy build-non-reproducible-wasm $NFT_CONTRACT_ID without-init-call network-config testnet sign-with-keychain send +``` + +Once the contract has been redeployed, let's test and see if the state migrated correctly by running a simple view function: + + + + + ```bash + near view $NFT_CONTRACT_ID nft_metadata '{}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_metadata json-args {} network-config testnet now + ``` + + + +This should return an output similar to the following: + +```bash +{ + spec: 'nft-1.0.0', + name: 'NFT Tutorial Contract', + symbol: 'GOTEAM', + icon: null, + base_uri: null, + reference: null, + reference_hash: null +} +``` + +**Go team!** At this point, you can now test and see if the new function you wrote works correctly. Let's query for the list of tokens that you own: + + + + + ```bash + near view $NFT_CONTRACT_ID nft_tokens_for_owner '{"account_id": "'$NFT_CONTRACT_ID'", "limit": 5}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_tokens_for_owner json-args '{"account_id": "'$NFT_CONTRACT_ID'", "limit": 5}' network-config testnet now + ``` + + + +
+Example response: +

+ +```bash +[ + { + token_id: 'token-1', + owner_id: 'goteam.examples.testnet', + metadata: { + title: 'My Non Fungible Team Token', + description: 'The Team Most Certainly Goes :)', + media: 'https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif', + media_hash: null, + copies: null, + issued_at: null, + expires_at: null, + starts_at: null, + updated_at: null, + extra: null, + reference: null, + reference_hash: null + } + } +] +``` + +

+
+ +--- + +## Viewing NFTs in the wallet {#viewing-nfts-in-wallet} + +Now that your contract implements the necessary functions that the wallet uses to display NFTs, you should be able to see your tokens on display in the [collectibles tab](https://testnet.mynearwallet.com/?tab=collectibles). + +![filled-nft-in-wallet](/assets/docs/tutorials/nfts/filled-nft-in-wallet.png) + +--- + +## Conclusion + +In this tutorial, you learned about the basics of [upgrading contracts](#upgrading-contracts). Then, you implemented the necessary [modifications to your smart contract](#modifications-to-contract) and [redeployed it](#redeploying-contract). Finally you navigated to the wallet collectibles tab and [viewed your NFTs](#viewing-nfts-in-wallet). + +In the [next tutorial](3-enumeration.md), you'll implement the remaining functions needed to complete the [enumeration](https://nomicon.io/Standards/Tokens/NonFungibleToken/Enumeration) standard. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://nomicon.io/Standards/Tokens/NonFungibleToken/Core), version `1.0.0` + +::: diff --git a/website/docs/3-enumeration.md b/website/docs/3-enumeration.md new file mode 100644 index 0000000..1679923 --- /dev/null +++ b/website/docs/3-enumeration.md @@ -0,0 +1,157 @@ +--- +id: enumeration +title: Enumeration +sidebar_label: Enumeration +description: "Extend your NFT smart contract with enumeration methods to track and query tokens." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial, you'll expand on and finish the rest of the enumeration methods as per the [standard](https://github.com/near/NEPs/tree/master/neps/nep-0181.md). + +In the previous tutorials, you looked at ways to integrate the minting functionality into a skeleton smart contract. In order to get your NFTs to show in the wallet, you also had to deploy a patch fix that implemented one of the enumeration methods. + +Now you'll extend the NFT smart contract and add a couple of enumeration methods that can be used to return the contract's state. + +--- + +## Introduction + +As mentioned in the [Upgrade a Contract](2-upgrade.md) tutorial, you can deploy patches and fixes to smart contracts. This time, you'll use that knowledge to implement the `nft_total_supply`, `nft_tokens` and `nft_supply_for_owner` enumeration functions. + +--- + +## Modifications to the contract + +Let's start by opening the `src/enumeration.rs` file and locating the empty `nft_total_supply` function. + +**nft_total_supply** + +This function should return the total number of NFTs stored on the contract. You can easily achieve this functionality by simply returning the length of the `nft_metadata_by_id` data structure. + + + +**nft_token** + +This function should return a paginated list of `JsonTokens` that are stored on the contract regardless of their owners. +If the user provides a `from_index` parameter, you should use that as the starting point for which to start iterating through tokens; otherwise it should start from the beginning. Likewise, if the user provides a `limit` parameter, the function shall stop after reaching either the limit or the end of the list. + +:::tip +Rust has useful methods for pagination, allowing you to skip to a starting index and taking the first `n` elements of an iterator. +::: + + + +**nft_supply_for_owner** + +This function should look for all the non-fungible tokens for a user-defined owner, and return the length of the resulting set. +If there isn't a set of tokens for the provided `AccountID`, then the function shall return `0`. + + + +Next, you can use the CLI to query these new methods and validate that they work correctly. + +--- + +## Redeploying the contract {#redeploying-contract} + +Now that you've implemented the necessary logic for `nft_tokens_for_owner`, it's time to build and re-deploy the contract to your account. Using the cargo-near, deploy the contract as you did in the previous tutorials: + +```bash +cargo near deploy build-non-reproducible-wasm $NFT_CONTRACT_ID without-init-call network-config testnet sign-with-keychain send +``` + +--- + +## Enumerating tokens + +Once the updated contract has been redeployed, you can test and see if these new functions work as expected. + +### NFT tokens + +Let's query for a list of non-fungible tokens on the contract. Use the following command to query for the information of up to 50 NFTs starting from the 10th item: + + + + + ```bash + near view $NFT_CONTRACT_ID nft_tokens '{"from_index": "10", "limit": 50}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_tokens json-args '{"from_index": "10", "limit": 50}' network-config testnet now + ``` + + + +This command should return an output similar to the following: + +
+Example response: +

+ +```json +[] +``` + +

+
+ +
+ +### Tokens by owner + +To get the total supply of NFTs owned by the `goteam.testnet` account, call the `nft_supply_for_owner` function and set the `account_id` parameter: + + + + + ```bash + near view $NFT_CONTRACT_ID nft_supply_for_owner '{"account_id": "goteam.testnet"}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_supply_for_owner json-args '{"account_id": "goteam.testnet"}' network-config testnet now + ``` + + + +This should return an output similar to the following: + +
+Example response: +

+ +```json +0 +``` + +

+
+ +--- + +## Conclusion + +In this tutorial, you have added two [new enumeration functions](#modifications-to-the-contract), and now you have a basic NFT smart contract with minting and enumeration methods in place. After implementing these modifications, you redeployed the smart contract and tested the functions using the CLI. + +In the [next tutorial](4-core.md), you'll implement the core functions needed to allow users to transfer the minted tokens. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` +- Enumeration standard: [NEP181](https://github.com/near/NEPs/tree/master/neps/nep-0181.md), version `1.0.0` + +::: diff --git a/website/docs/4-core.md b/website/docs/4-core.md new file mode 100644 index 0000000..ea912da --- /dev/null +++ b/website/docs/4-core.md @@ -0,0 +1,258 @@ +--- +id: core +title: Transfers +description: "Learn how to implement NFT transfers, including simple and cross-contract transfer calls, in your smart contract." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial you'll learn how to implement NFT transfers as defined in the [core standards](https://github.com/near/NEPs/tree/master/neps/nep-0171.md) into your smart contract. + +We will define two methods for transferring NFTs: +- `nft_transfer`: that transfers ownership of an NFT from one account to another +- `nft_transfer_call`: that transfers an NFT to a "receiver" and calls a method on the receiver's account + +:::tip Why two transfer methods? + +`nft_transfer` is a simple transfer between two user, while `nft_transfer_call` allows you to **attach an NFT to a function call** + +::: + +--- + +## Introduction {#introduction} + +Up until this point, you've created a simple NFT smart contract that allows users to mint tokens and view information using the [enumeration standards](https://github.com/near/NEPs/tree/master/neps/nep-0181.md). Today, you'll expand your smart contract to allow for users to not only mint tokens, but transfer them as well. + +As we did in the [minting tutorial](2-minting.md), let's break down the problem into multiple subtasks to make our lives easier. When a token is minted, information is stored in 3 places: + +- **tokens_per_owner**: set of tokens for each account. +- **tokens_by_id**: maps a token ID to a `Token` object. +- **token_metadata_by_id**: maps a token ID to its metadata. + +Let's now consider the following scenario. If Benji owns token A and wants to transfer it to Mike as a birthday gift, what should happen? First of all, token A should be removed from Benji's set of tokens and added to Mike's set of tokens. + +If that's the only logic you implement, you'll run into some problems. If you were to do a `view` call to query for information about that token after it's been transferred to Mike, it would still say that Benji is the owner. + +This is because the contract is still mapping the token ID to the old `Token` object that contains the `owner_id` field set to Benji's account ID. You still have to change the `tokens_by_id` data structure so that the token ID maps to a new `Token` object which has Mike as the owner. + +With that being said, the final process for when an owner transfers a token to a receiver should be the following: + +- Remove the token from the owner's set. +- Add the token to the receiver's set. +- Map a token ID to a new `Token` object containing the correct owner. + +:::note +You might be curious as to why we don't edit the `token_metadata_by_id` field. This is because no matter who owns the token, the token ID will always map to the same metadata. The metadata should never change and so we can just leave it alone. +::: + +At this point, you're ready to move on and make the necessary modifications to your smart contract. + +--- + +## Modifications to the contract + +Let's start our journey in the `nft-contract-skeleton/src/nft_core.rs` file. + +### Transfer function {#transfer-function} + +You'll start by implementing the `nft_transfer` logic. This function will transfer the specified `token_id` to the `receiver_id` with an optional `memo` such as `"Happy Birthday Mike!"`. + + + +There are a couple things to notice here. Firstly, we've introduced a new function called `assert_one_yocto()`, which ensures the user has attached exactly one yoctoNEAR to the call. This is a [security measure](https://docs.near.org/smart-contracts/security/one_yocto) to ensure that the user is signing the transaction with a [full access key](https://docs.near.org/protocol/access-keys). + +Since the transfer function is potentially transferring very valuable assets, you'll want to make sure that whoever is calling the function has a full access key. + +Secondly, we've introduced an `internal_transfer` method. This will perform all the logic necessary to transfer an NFT. + +
+ +### Internal helper functions + +Let's quickly move over to the `nft-contract/src/internal.rs` file so that you can implement the `assert_one_yocto()` and `internal_transfer` methods. + +Let's start with the easier one, `assert_one_yocto()`. + +#### assert_one_yocto + + + +#### internal_transfer + +It's now time to explore the `internal_transfer` function which is the core of this tutorial. This function takes the following parameters: + +- **sender_id**: the account that's attempting to transfer the token. +- **receiver_id**: the account that's receiving the token. +- **token_id**: the token ID being transferred. +- **memo**: an optional memo to include. + +The first thing we have to do is to make sure that the sender is authorized to transfer the token. In this case, we just make sure that the sender is the owner of the token. We do that by getting the `Token` object using the `token_id` and making sure that the sender is equal to the token's `owner_id`. + +Second, we remove the token ID from the sender's list and add the token ID to the receiver's list of tokens. Finally, we create a new `Token` object with the receiver as the owner and remap the token ID to that newly created object. + +We want to create this function within the contract implementation (below the `internal_add_token_to_owner` you created in the minting tutorial). + + + +Now let's look at the function called `internal_remove_token_from_owner`. That function implements the functionality for removing a token ID from an owner's set. + +In the remove function, we get the set of tokens for a given account ID and then remove the passed in token ID. If the account's set is empty after the removal, we simply remove the account from the `tokens_per_owner` data structure. + + + +Your `internal.rs` file should now have the following outline: + +``` +internal.rs +├── hash_account_id +├── assert_one_yocto +├── refund_deposit +└── impl Contract + ├── internal_add_token_to_owner + ├── internal_remove_token_from_owner + └── internal_transfer +``` + +
+ +### Transfer call function {#transfer-call-function} + +The idea behind the `nft_transfer_call` function is to transfer an NFT to a receiver while calling a method on the receiver's contract all in the same transaction. + +This way, we can effectively **attach an NFT to a function call**. + + + +The function will first assert that the caller attached exactly 1 yocto for security purposes. It will then transfer the NFT using `internal_transfer` and start the cross contract call. It will call the method `nft_on_transfer` on the `receiver_id`'s contract, and create a promise to call back `nft_resolve_transfer` with the result. This is a very common workflow when dealing with [cross contract calls](https://docs.near.org/smart-contracts/anatomy/crosscontract). + +As dictated by the core standard, the function we are calling (`nft_on_transfer`) needs to return a boolean stating whether or not you should return the NFT to its original owner. + + + +If `nft_on_transfer` returned true or the called failed, you should send the token back to its original owner. On the contrary, if false was returned, no extra logic is needed. + +As for the return value of our function `nft_resolve_transfer`, the standard dictates that the function should return a boolean indicating whether or not the receiver successfully received the token or not. + +This means that if `nft_on_transfer` returned true, you should return false. This is because if the token is being returned its original owner, the `receiver_id` didn't successfully receive the token in the end. On the contrary, if `nft_on_transfer` returned false, you should return true since we don't need to return the token and thus the `receiver_id` successfully owns the token. + +With that finished, you've now successfully added the necessary logic to allow users to transfer NFTs. It's now time to deploy and do some testing. + +--- + +## Redeploying the contract {#redeploying-contract} + +Using cargo-near, deploy the contract as you did in the previous tutorials: + +```bash +cargo near deploy build-non-reproducible-wasm $NFT_CONTRACT_ID without-init-call network-config testnet sign-with-keychain send +``` + +:::tip +If you haven't completed the previous tutorials and are just following along with this one, simply create an account and login with your CLI using `near login`. You can then export an environment variable `export NFT_CONTRACT_ID=YOUR_ACCOUNT_ID_HERE`. +::: + +--- + +## Testing the new changes {#testing-changes} + +Now that you've deployed a patch fix to the contract, it's time to move onto testing. Using the previous NFT contract where you had minted a token to yourself, you can test the `nft_transfer` method. If you transfer the NFT, it should be removed from your account's collectibles displayed in the wallet. In addition, if you query any of the enumeration functions, it should show that you are no longer the owner. + +Let's test this out by transferring an NFT to the account `benjiman.testnet` and seeing if the NFT is no longer owned by you. + +
+ +### Testing the transfer function + +:::note +This means that the NFT won't be recoverable unless the account `benjiman.testnet` transfers it back to you. If you don't want your NFT lost, make a new account and transfer the token to that account instead. +::: + +If you run the following command, it will transfer the token `"token-1"` to the account `benjiman.testnet` with the memo `"Go Team :)"`. Take note that you're also attaching exactly 1 yoctoNEAR by using the `--depositYocto` flag. + +:::tip +If you used a different token ID in the previous tutorials, replace `token-1` with your token ID. +::: + + + + + ```bash + near call $NFT_CONTRACT_ID nft_transfer '{"receiver_id": "benjiman.testnet", "token_id": "token-1", "memo": "Go Team :)"}' --gas 100000000000000 --depositYocto 1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_transfer json-args '{"receiver_id": "benjiman.testnet", "token_id": "token-1", "memo": "Go Team :)"}' prepaid-gas '100.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +If you now query for all the tokens owned by your account, that token should be missing. Similarly, if you query for the list of tokens owned by `benjiman.testnet`, that account should now own your NFT. + +
+ +### Testing the transfer call function + +Now that you've tested the `nft_transfer` function, it's time to test the `nft_transfer_call` function. If you try to transfer an NFT to a receiver that does **not** implement the `nft_on_transfer` function, the contract will panic and the NFT will **not** be transferred. Let's test this functionality below. + +First mint a new NFT that will be used to test the transfer call functionality. + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"token_id": "token-2", "metadata": {"title": "NFT Tutorial Token", "description": "Testing the transfer call function", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"token_id": "token-2", "metadata": {"title": "NFT Tutorial Token", "description": "Testing the transfer call function", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +Now that you've minted the token, you can try to transfer the NFT to the account `no-contract.testnet` which as the name suggests, doesn't have a contract. This means that the receiver doesn't implement the `nft_on_transfer` function and the NFT should remain yours after the transaction is complete. + + + + + ```bash + near call $NFT_CONTRACT_ID nft_transfer_call '{"receiver_id": "no-contract.testnet", "token_id": "token-2", "msg": "foo"}' --gas 100000000000000 --depositYocto 1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_transfer_call json-args '{"receiver_id": "no-contract.testnet", "token_id": "token-2", "msg": "foo"}' prepaid-gas '100.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +If you query for your tokens, you should still have `token-2` and at this point, you're finished! + +--- + +## Conclusion + +In this tutorial, you learned how to expand an NFT contract past the minting functionality and you added ways for users to transfer NFTs. You [broke down](#introduction) the problem into smaller, more digestible subtasks and took that information and implemented both the [NFT transfer](#transfer-function) and [NFT transfer call](#transfer-call-function) functions. In addition, you deployed another [patch fix](#redeploying-contract) to your smart contract and [tested](#testing-changes) the transfer functionality. + +In the [next tutorial](5-approvals.md), you'll learn about the approval management system and how you can approve others to transfer tokens on your behalf. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` +- Enumeration standard: [NEP181](https://github.com/near/NEPs/tree/master/neps/nep-0181.md), version `1.0.0` + +::: diff --git a/website/docs/5-approvals.md b/website/docs/5-approvals.md new file mode 100644 index 0000000..9a29b97 --- /dev/null +++ b/website/docs/5-approvals.md @@ -0,0 +1,612 @@ +--- +id: approvals +title: Approvals +sidebar_label: Approvals +description: "Learn how to manage NFT approvals so that others can transfer NFTs on your behalf." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial you'll learn the basics of an approval management system which will allow you to grant others access to transfer NFTs on your behalf. + +This is the backbone of all NFT marketplaces and allows for some complex yet beautiful scenarios to happen. If you're joining us for the first time, feel free to clone [this repository](https://github.com/near-examples/nft-tutorial) and go to the `nft-contract-basic/` folder to follow along. + +```bash +cd nft-contract-basic/ +``` + +:::tip +If you wish to see the finished code for this _Approval_ tutorial, you can find it in the `nft-contract-approval/` folder. +::: + +--- + +## Introduction + +Up until this point you've created a smart contract that allows users to mint and transfer NFTs as well as query for information using the [enumeration standard](https://github.com/near/NEPs/tree/master/neps/nep-0181.md). As we've been doing in the previous tutorials, let's break down the problem into smaller, more digestible, tasks. + +Let's first define some of the end goals that we want to accomplish as per the [approval management](https://github.com/near/NEPs/tree/master/neps/nep-0178.md) extension of the standard. We want a user to have the ability to: + +- Grant other accounts access to transfer their NFTs on a per token basis. +- Check if an account has access to a specific token. +- Revoke a specific account the ability to transfer an NFT. +- Revoke **all** other accounts the ability to transfer an NFT. + +If you look at all these goals, they are all on a per token basis. This is a strong indication that you should change the `Token` struct which keeps track of information for each token. + +--- + +## Allow an account to transfer your NFT + +Let's start by trying to accomplish the first goal. How can you grant another account access to transfer an NFT on your behalf? + +The simplest way that you can achieve this is to add a list of approved accounts to the `Token` struct. When transferring the NFT, if the caller is not the owner, you could check if they're in the list. + +Before transferring, you would need to clear the list of approved accounts since the new owner wouldn't expect the accounts approved by the original owner to still have access to transfer their new NFT. + +
+ +### The problem {#the-problem} + +On the surface, this would work, but if you start thinking about the edge cases, some problems arise. Often times when doing development, a common approach is to think about the easiest and most straightforward solution. Once you've figured it out, you can start to branch off and think about optimizations and edge cases. + +Let's consider the following scenario. Benji has an NFT and gives two separate marketplaces access to transfer his token. By doing so, he's putting the NFT for sale. Let's say he put the NFT for sale for 1 NEAR on both markets. The tokens list of approved account IDs would look like the following: + +``` +Token: { + owner_id: Benji + approved_accounts_ids: [marketplace A, marketplace B] +} +``` + +Josh then comes along and purchases the NFT on marketplace A for 1 NEAR. This would take the sale down from the marketplace A and clear the list of approved accounts. Marketplace B, however, still has the token listed for sale for 1 NEAR and has no way of knowing that the token was purchased on marketplace A by Josh. The new token struct would look as follows: + +``` +Token: { + owner_id: Josh + approved_accounts_ids: [] +} +``` + +Let's say Josh is low on cash and wants to flip this NFT and put it for sale for 10 times the price on marketplace B. He goes to put it for sale and for whatever reason, the marketplace is built in a way that if you try to put a token up for sale twice, it keeps the old sale data. This would mean that from marketplace B's perspective, the token is still for sale for 1 NEAR (which was the price that Benji had originally listed it for). + +Since Josh approved the marketplace to try and put it for sale, the token struct would look as follows: + +``` +Token: { + owner_id: Josh + approved_accounts_ids: [marketplace A, marketplace B] +} +``` + +If Mike then comes along and purchases the NFT for only 1 NEAR on marketplace B, the marketplace would go to try and transfer the NFT and since technically, Josh approved the marketplace and it's in the list of approved accounts, the transaction would go through properly. + +
+ +### The solution {#the-solution} + +Now that we've identified a problem with the original solution, let's think about ways that we can fix it. What would happen now if, instead of just keeping track of a list of approved accounts, you introduced a specific ID that went along with each approved account. The new approved accounts would now be a map instead of a list. It would map an account to its `approval id`. + +For this to work, you need to make sure that the approval ID is **always** a unique, new ID. If you set it as an integer that always increases by 1 whenever u approve an account, this should work. Let's consider the same scenario with the new solution. + +Benji puts his NFT for sale for 1 NEAR on marketplace A and marketplace B by approving both marketplaces. The "next approval ID" would start off at 0 when the NFT was first minted and will increase from there. This would result in the following token struct: + +``` +Token: { + owner_id: Benji + approved_accounts_ids: { + marketplace A: 0 + marketplace B: 1 + } + next_approval_id: 2 +} +``` + +When Benji approved marketplace A, it took the original value of `next_approval_id` which started off at 0. The marketplace was then inserted into the map and the next approval ID was incremented. This process happened again for marketplace B and the next approval ID was again incremented where it's now 2. + +Josh comes along and purchases the NFT on marketplace A for 1 NEAR. Notice how the next approval ID stayed at 2: + +``` +Token: { + owner_id: Josh + approved_accounts_ids: {} + next_approval_id: 2 +} +``` + +Josh then flips the NFT because he's once again low on cash and approves marketplace B: + +``` +Token: { + owner_id: Josh + approved_accounts_ids: { + marketplace B: 2 + } + next_approval_id: 3 +} +``` + +The marketplace is inserted into the map and the next approval ID is incremented. From marketplace B's perspective it stores its original approval ID from when Benji put the NFT up for sale which has a value of 1. If Mike were to go and purchase the NFT on marketplace B for the original 1 NEAR sale price, the NFT contract should panic. This is because the marketplace is trying to transfer the NFT with an approval ID 1 but the token struct shows that it **should** have an approval ID of 2. + +
+ +### Expanding the `Token` and `JsonToken` structs + +Now that you understand the proposed solution to the original problem of allowing an account to transfer your NFT, it's time to implement some of the logic. The first thing you should do is modify the `Token` and `JsonToken` structs to reflect the new changes. Let's switch over to the `nft-contract-basic/src/metadata.rs` file: + + + +You'll then need to initialize both the `approved_account_ids` and `next_approval_id` to their default values when a token is minted. Switch to the `nft-contract-basic/src/mint.rs` file and when creating the `Token` struct to store in the contract, let's set the next approval ID to be 0 and the approved account IDs to be an empty map: + + + +
+ +### Approving accounts + +Now that you've added the support for approved account IDs and the next approval ID on the token level, it's time to add the logic for populating and changing those fields through a function called `nft_approve`. This function should approve an account to have access to a specific token ID. Let's move to the `nft-contract-basic/src/approval.rs` file and edit the `nft_approve` function: + + + +The function will first assert that the user has attached **at least** one yoctoNEAR (which we'll implement soon). This is both for security and to cover storage. When someone approves an account ID, they're storing that information on the contract. As you saw in the [minting tutorial](2-minting.md), you can either have the smart contract account cover the storage, or you can have the users cover that cost. The latter is more scalable and it's the approach you'll be working with throughout this tutorial. + +After the assertion comes back with no problems, you get the token object and make sure that only the owner is calling this method. Only the owner should be able to allow other accounts to transfer their NFTs. You then get the next approval ID and insert the passed in account into the map with the next approval ID. If it's a new approval ID, storage must be paid. If it's not a new approval ID, no storage needs to be paid and only attaching 1 yoctoNEAR would be enough. + +You then calculate how much storage is being used by adding that new account to the map and increment the tokens `next_approval_id` by 1. After inserting the token object back into the `tokens_by_id` map, you refund any excess storage. + +You'll notice that the function contains an optional `msg` parameter. This message can be used by NFT marketplaces. If a message was provided into the function, you're going to perform a cross contract call to the account being given access. This cross contract call will invoke the `nft_on_approve` function which will parse the message and act accordingly. + +It is up to the approving person to provide a properly encoded message that the marketplace can decode and use. This is usually done through the marketplace's frontend app which would know how to construct the `msg` in a useful way. + +
+ +### Internal functions + +Now that the core logic for approving an account is finished, you need to implement the `assert_at_least_one_yocto` and `bytes_for_approved_account` functions. Move to the `nft-contract/src/internal.rs` file and copy the following function right below the `assert_one_yocto` function. + + + +Next, you'll need to copy the logic for calculating how many bytes it costs to store an account ID. Place this function at the very top of the page: + + + +Now that the logic for approving accounts is finished, you need to change the restrictions for transferring. + + +#### Changing the restrictions for transferring NFTs + +Currently, an NFT can **only** be transferred by its owner. You need to change that restriction so that people that have been approved can also transfer NFTs. In addition, you'll make it so that if an approval ID is passed, you can increase the security and check if both the account trying to transfer is in the approved list **and** they correspond to the correct approval ID. This is to address the problem we ran into earlier. + +In the `internal.rs` file, you need to change the logic of the `internal_transfer` method as that's where the restrictions are being made. Change the internal transfer function to be the following: + + + +This will check if the sender isn't the owner and then if they're not, it will check if the sender is in the approval list. If an approval ID was passed into the function, it will check if the sender's actual approval ID stored on the contract matches the one passed in. + +
+ +#### Refunding storage on transfer + +While you're in the internal file, you're going to need to add methods for refunding users who have paid for storing approved accounts on the contract when an NFT is transferred. This is because you'll be clearing the `approved_account_ids` map whenever NFTs are transferred and so the storage is no longer being used. + +Right below the `bytes_for_approved_account_id` function, copy the following two functions: + + + +These will be useful in the next section where you'll be changing the `nft_core` functions to include the new approval logic. + +
+ +### Changes to `nft_core.rs` + +Head over to the `nft-contract-basic/src/nft_core.rs` file and the first change that you'll want to make is to add an `approval_id` to both the `nft_transfer` and `nft_transfer_call` functions. This is so that anyone trying to transfer the token that isn't the owner must pass in an approval ID to address the problem seen earlier. If they are the owner, the approval ID won't be used as we saw in the `internal_transfer` function. + + + +You'll then need to add an `approved_account_ids` map to the parameters of `nft_resolve_transfer`. This is so that you can refund the list if the transfer went through properly. + + + +Moving over to `nft_transfer`, the only change that you'll need to make is to pass in the approval ID into the `internal_transfer` function and then refund the previous tokens approved account IDs after the transfer is finished + + + +Next, you need to do the same to `nft_transfer_call` but instead of refunding immediately, you need to attach the previous token's approved account IDs to `nft_resolve_transfer` instead as there's still the possibility that the transfer gets reverted. + + + +You'll also need to add the tokens approved account IDs to the `JsonToken` being returned by `nft_token`. + + + +Finally, you need to add the logic for refunding the approved account IDs in `nft_resolve_transfer`. If the transfer went through, you should refund the owner for the storage being released by resetting the tokens `approved_account_ids` field. If, however, you should revert the transfer, it wouldn't be enough to just not refund anybody. Since the receiver briefly owned the token, they could have added their own approved account IDs and so you should refund them if they did so. + + + +With that finished, it's time to move on and complete the next task. + +--- + +## Check if an account is approved + +Now that the core logic is in place for approving and refunding accounts, it should be smooth sailing from this point on. You now need to implement the logic for checking if an account has been approved. This should take an account and token ID as well as an optional approval ID. If no approval ID was provided, it should simply return whether or not the account is approved. + +If an approval ID was provided, it should return whether or not the account is approved and has the same approval ID as the one provided. Let's move to the `nft-contract-basic/src/approval.rs` file and add the necessary logic to the `nft_is_approved` function. + + + +Let's now move on and add the logic for revoking an account + +--- + +## Revoke an account + +The next step in the tutorial is to allow a user to revoke a specific account from having access to their NFT. The first thing you'll want to do is assert one yocto for security purposes. You'll then need to make sure that the caller is the owner of the token. If those checks pass, you'll need to remove the passed in account from the tokens approved account IDs and refund the owner for the storage being released. + + + +--- + +## Revoke all accounts + +The final step in the tutorial is to allow a user to revoke all accounts from having access to their NFT. This should also assert one yocto for security purposes and make sure that the caller is the owner of the token. You then refund the owner for releasing all the accounts in the map and then clear the `approved_account_ids`. + + + +With that finished, it's time to deploy and start testing the contract. + +--- + +## Testing the new changes {#testing-changes} + +Since these changes affect all the other tokens and the state won't be able to automatically be inherited by the new code, simply redeploying the contract will lead to errors. For this reason, it's best practice to create a new account and deploy the contract there. + +
+ +### Deployment and initialization + +Next, you'll deploy this contract to the network. + + + + + ```bash + export APPROVAL_NFT_CONTRACT_ID= + near create-account $APPROVAL_NFT_CONTRACT_ID --useFaucet + ``` + + + + + ```bash + export APPROVAL_NFT_CONTRACT_ID= + near account create-account sponsor-by-faucet-service $APPROVAL_NFT_CONTRACT_ID autogenerate-new-keypair save-to-keychain network-config testnet create + ``` + + + +Using the cargo-near, deploy and initialize the contract as you did in the previous tutorials: + +```bash +cargo near deploy build-non-reproducible-wasm $APPROVAL_NFT_CONTRACT_ID with-init-call new_default_meta json-args '{"owner_id": "'$APPROVAL_NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + +
+ +### Minting {#minting} + +Next, you'll need to mint a token. By running this command, you'll mint a token with a token ID `"approval-token"` and the receiver will be your new account. + + + + + ```bash + near call $APPROVAL_NFT_CONTRACT_ID nft_mint '{"token_id": "approval-token", "metadata": {"title": "Approval Token", "description": "testing out the new approval extension of the standard", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$APPROVAL_NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $APPROVAL_NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $APPROVAL_NFT_CONTRACT_ID nft_mint json-args '{"token_id": "approval-token", "metadata": {"title": "Approval Token", "description": "testing out the new approval extension of the standard", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$APPROVAL_NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $APPROVAL_NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +You can check to see if everything went through properly by calling one of the enumeration functions: + + + + + ```bash + near view $APPROVAL_NFT_CONTRACT_ID nft_tokens_for_owner '{"account_id": "'$APPROVAL_NFT_CONTRACT_ID'", "limit": 10}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $APPROVAL_NFT_CONTRACT_ID nft_tokens_for_owner json-args '{"account_id": "'$APPROVAL_NFT_CONTRACT_ID'", "limit": 10}' network-config testnet now + ``` + + + +This should return an output similar to the following: + +```json +[ + { + "token_id": "approval-token", + "owner_id": "approval.goteam.examples.testnet", + "metadata": { + "title": "Approval Token", + "description": "testing out the new approval extension of the standard", + "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif", + "media_hash": null, + "copies": null, + "issued_at": null, + "expires_at": null, + "starts_at": null, + "updated_at": null, + "extra": null, + "reference": null, + "reference_hash": null + }, + "approved_account_ids": {} + } +] +``` + +Notice how the approved account IDs are now being returned from the function? This is a great sign! You're now ready to move on and approve an account to have access to your token. + +
+ +### Approving an account {#approving-an-account} + +At this point, you should have two accounts. One stored under `$NFT_CONTRACT_ID` and the other under the `$APPROVAL_NFT_CONTRACT_ID` environment variable. You can use both of these accounts to test things out. If you approve your old account, it should have the ability to transfer the NFT to itself. + +Execute the following command to approve the account stored under `$NFT_CONTRACT_ID` to have access to transfer your NFT with an ID `"approval-token"`. You don't need to pass a message since the old account didn't implement the `nft_on_approve` function. In addition, you'll need to attach enough NEAR to cover the cost of storing the account on the contract. 0.1 NEAR should be more than enough and you'll be refunded any excess that is unused. + + + + + ```bash + near call $APPROVAL_NFT_CONTRACT_ID nft_approve '{"token_id": "approval-token", "account_id": "'$NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $APPROVAL_NFT_CONTRACT_ID nft_approve json-args '{"token_id": "approval-token", "account_id": "'$NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +If you call the same enumeration method as before, you should see the new approved account ID being returned. + + + + + ```bash + near view $APPROVAL_NFT_CONTRACT_ID nft_tokens_for_owner '{"account_id": "'$APPROVAL_NFT_CONTRACT_ID'", "limit": 10}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $APPROVAL_NFT_CONTRACT_ID nft_tokens_for_owner json-args '{"account_id": "'$APPROVAL_NFT_CONTRACT_ID'", "limit": 10}' network-config testnet now + ``` + + + +This should return an output similar to the following: + +```json +[ + { + "token_id": "approval-token", + "owner_id": "approval.goteam.examples.testnet", + "metadata": { + "title": "Approval Token", + "description": "testing out the new approval extension of the standard", + "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif", + "media_hash": null, + "copies": null, + "issued_at": null, + "expires_at": null, + "starts_at": null, + "updated_at": null, + "extra": null, + "reference": null, + "reference_hash": null + }, + "approved_account_ids": { "goteam.examples.testnet": 0 } + } +] +``` + +
+ +### Transferring an NFT as an approved account {#transferring-the-nft} + +Now that you've approved another account to transfer the token, you can test that behavior. You should be able to use the other account to transfer the NFT to itself by which the approved account IDs should be reset. Let's test transferring the NFT with the wrong approval ID: + + + + + ```bash + near call $APPROVAL_NFT_CONTRACT_ID nft_transfer '{"receiver_id": "'$NFT_CONTRACT_ID'", "token_id": "approval-token", "approval_id": 1}' --gas 100000000000000 --depositYocto 1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $APPROVAL_NFT_CONTRACT_ID nft_transfer json-args '{"receiver_id": "'$NFT_CONTRACT_ID'", "token_id": "approval-token", "approval_id": 1}' prepaid-gas '100.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +
+Example response: +

+ +```bash +kind: { + ExecutionError: "Smart contract panicked: panicked at 'assertion failed: `(left == right)`\n" + + ' left: `0`,\n' + + " right: `1`: The actual approval_id 0 is different from the given approval_id 1', src/internal.rs:165:17" + }, +``` + +

+
+ +If you pass the correct approval ID which is `0`, everything should work fine. + + + + + ```bash + near call $APPROVAL_NFT_CONTRACT_ID nft_transfer '{"receiver_id": "'$NFT_CONTRACT_ID'", "token_id": "approval-token", "approval_id": 0}' --gas 100000000000000 --depositYocto 1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $APPROVAL_NFT_CONTRACT_ID nft_transfer json-args '{"receiver_id": "'$NFT_CONTRACT_ID'", "token_id": "approval-token", "approval_id": 0}' prepaid-gas '100.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +If you again call the enumeration method, you should see the owner updated and the approved account IDs reset. + +```json +[ + { + "token_id": "approval-token", + "owner_id": "goteam.examples.testnet", + "metadata": { + "title": "Approval Token", + "description": "testing out the new approval extension of the standard", + "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif", + "media_hash": null, + "copies": null, + "issued_at": null, + "expires_at": null, + "starts_at": null, + "updated_at": null, + "extra": null, + "reference": null, + "reference_hash": null + }, + "approved_account_ids": {} + } +] +``` + +Let's now test the approval ID incrementing across different owners. If you approve the account that originally minted the token, the approval ID should be 1 now. + + + + + ```bash + near call $APPROVAL_NFT_CONTRACT_ID nft_approve '{"token_id": "approval-token", "account_id": "'$APPROVAL_NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $APPROVAL_NFT_CONTRACT_ID nft_approve json-args '{"token_id": "approval-token", "account_id": "'$APPROVAL_NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +Calling the view function again show now return an approval ID of 1 for the account that was approved. + + + + + ```bash + near view $APPROVAL_NFT_CONTRACT_ID nft_tokens_for_owner '{"account_id": "'$NFT_CONTRACT_ID'", "limit": 10}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $APPROVAL_NFT_CONTRACT_ID nft_tokens_for_owner json-args '{"account_id": "'$NFT_CONTRACT_ID'", "limit": 10}' network-config testnet now + ``` + + + +
+Example response: +

+ +```json +[ + { + "token_id": "approval-token", + "owner_id": "goteam.examples.testnet", + "metadata": { + "title": "Approval Token", + "description": "testing out the new approval extension of the standard", + "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif", + "media_hash": null, + "copies": null, + "issued_at": null, + "expires_at": null, + "starts_at": null, + "updated_at": null, + "extra": null, + "reference": null, + "reference_hash": null + }, + "approved_account_ids": { "approval.goteam.examples.testnet": 1 } + } +] +``` + +

+
+ +With the testing finished, you've successfully implemented the approvals extension to the standard! + +--- + +## Conclusion + +Today you went through a lot of logic to implement the [approvals extension](https://github.com/near/NEPs/tree/master/neps/nep-0178.md) so let's break down exactly what you did. + +First, you explored the [basic approach](#allow-an-account-to-transfer-your-nft) of how to solve the problem. You then went through and discovered some of the [problems](#the-problem) with that solution and learned how to [fix it](#the-solution). + +After understanding what you should do to implement the approvals extension, you started to [modify](#expanding-the-token-and-jsontoken-structs) the JsonToken and Token structs in the contract. You then implemented the logic for [approving accounts](#approving-accounts). + +After implementing the logic behind approving accounts, you went and [changed the restrictions](#changing-the-restrictions-for-transferring-nfts) needed to transfer NFTs. The last step you did to finalize the approving logic was to go back and edit the [nft_core](#changes-to-nft_corers) files to be compatible with the new changes. + +At this point, everything was implemented in order to allow accounts to be approved and you extended the functionality of the [core standard](https://github.com/near/NEPs/tree/master/neps/nep-0171.md) to allow for approved accounts to transfer tokens. + +You implemented a view method to [check](#check-if-an-account-is-approved) if an account is approved and to finish the coding portion of the tutorial, you implemented the logic necessary to [revoke an account](#revoke-an-account) as well as [revoke all accounts](#revoke-all-accounts). + +After this, the contract code was finished and it was time to move onto testing where you created an [account](#deployment-and-initialization) and tested the [approving](#approving-an-account) and [transferring](#transferring-the-nft) for your NFTs. + +In the [next tutorial](6-royalty.md), you'll learn about the royalty standards and how you can interact with NFT marketplaces. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` +- Enumeration standard: [NEP181](https://github.com/near/NEPs/tree/master/neps/nep-0181.md), version `1.0.0` +- Approval standard: [NEP178](https://github.com/near/NEPs/tree/master/neps/nep-0178.md), version `1.1.0` + +::: diff --git a/website/docs/6-royalty.md b/website/docs/6-royalty.md new file mode 100644 index 0000000..e4c9b3d --- /dev/null +++ b/website/docs/6-royalty.md @@ -0,0 +1,299 @@ +--- +id: royalty +title: Royalty +sidebar_label: Royalty +description: "Learn how to add perpetual royalties to NFT so creators earn a percentage on every sale." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial you'll continue building your non-fungible token (NFT) smart contract, and learn how to implement perpetual royalties into your NFTs. This will allow people to get a percentage of the purchase price when an NFT is sold. + +## Introduction + +By now, you should have a fully fledged NFT contract, except for the royalties support. +To get started, go to the `nft-contract-approval/` folder from our [GitHub repository](https://github.com/near-examples/nft-tutorial/), or continue your work from the previous tutorials. + +```bash +cd nft-contract-approval/ +``` + +:::tip +If you wish to see the finished code for this _Royalty_ tutorial, you can find it in the `nft-contract-royalty` folder. +::: + +--- + +## Thinking about the problem + +In order to implement the functionality, you first need to understand how NFTs are sold. In the previous tutorial, you saw how someone with an NFT could list it on a marketplace using the `nft_approve` function by passing in a message that could be properly decoded. When a user purchases your NFT on the marketplace, what happens? + +Using the knowledge you have now, a reasonable conclusion would be to say that the marketplace transfers the NFT to the buyer by performing a cross-contract call and invokes the NFT contract's `nft_transfer` method. Once that function finishes, the marketplace would pay the seller for the correct amount that the buyer paid. + +Let's now think about how this can be expanded to allow for a cut of the pay going to other accounts that aren't just the seller. + +
+ +### Expanding the current solution + +Since perpetual royalties will be on a per-token basis, it's safe to assume that you should be changing the `Token` and `JsonToken` structs. You need some way of keeping track of what percentage each account with a royalty should have. If you introduce a map of an account to an integer, that should do the trick. + +Now, you need some way to relay that information to the marketplace. This method should be able to transfer the NFT exactly like the old solution but with the added benefit of telling the marketplace exactly what accounts should be paid what amounts. If you implement a method that transfers the NFT and then calculates exactly what accounts get paid and to what amount based on a passed-in balance, that should work nicely. + +This is what the [royalty standards](https://github.com/near/NEPs/blob/master/neps/nep-0199.md) outlined. Let's now move on and modify our smart contract to introduce this behavior. + +--- + +## Modifications to the contract + +The first thing you'll want to do is add the royalty information to the structs. Open the `nft-contract-approval/src/metadata.rs` file and add `royalty` to the `Token` struct: + +```rust +pub royalty: HashMap, +``` + +Second, you'll want to add `royalty` to the `JsonToken` struct as well: + +```rust +pub royalty: HashMap, +``` + +
+ +### Internal helper function + +**royalty_to_payout** + +To simplify the payout calculation, let's add a helper `royalty_to_payout` function to `src/internal.rs`. This will convert a percentage to the actual amount that should be paid. In order to allow for percentages less than 1%, you can give 100% a value of `10,000`. This means that the minimum percentage you can give out is 0.01%, or `1`. For example, if you wanted the account `benji.testnet` to have a perpetual royalty of 20%, you would insert the pair `"benji.testnet": 2000` into the payout map. + + + +If you were to use the `royalty_to_payout` function and pass in `2000` as the `royalty_percentage` and an `amount_to_pay` of 1 NEAR, it would return a value of 0.2 NEAR. + +
+ +### Royalties + +**nft_payout** + +Let's now implement a method to check what accounts will be paid out for an NFT given an amount, or balance. Open the `nft-contract/src/royalty.rs` file, and modify the `nft_payout` function as shown. + + + +This function will loop through the token's royalty map and take the balance and convert that to a payout using the `royalty_to_payout` function you created earlier. It will give the owner of the token whatever is left from the total royalties. As an example: + +You have a token with the following royalty field: + +```rust +Token { + owner_id: "damian", + royalty: { + "benji": 1000, + "josh": 500, + "mike": 2000 + } +} +``` + +If a user were to call `nft_payout` on the token and pass in a balance of 1 NEAR, it would loop through the token's royalty field and insert the following into the payout object: + +```rust +Payout { + payout: { + "benji": 0.1 NEAR, + "josh": 0.05 NEAR, + "mike": 0.2 NEAR + } +} +``` + +At the very end, it will insert `damian` into the payout object and give him `1 NEAR - 0.1 - 0.05 - 0.2 = 0.65 NEAR`. + +**nft_transfer_payout** + +Now that you know how payouts are calculated, it's time to create the function that will transfer the NFT and return the payout to the marketplace. + + + +
+ +### Perpetual royalties + +To add support for perpetual royalties, let's edit the `src/mint.rs` file. First, add an optional parameter for perpetual royalties. This is what will determine what percentage goes to which accounts when the NFT is purchased. You will also need to create and insert the royalty to be put in the `Token` object: + + + +Next, you can use the CLI to query the new `nft_payout` function and validate that it works correctly. + +### Adding royalty object to struct implementations + +Since you've added a new field to your `Token` and `JsonToken` structs, you need to edit your implementations accordingly. Move to the `nft-contract/src/internal.rs` file and edit the part of your `internal_transfer` function that creates the new `Token` object: + + + +Once that's finished, move to the `nft-contract-approval/src/nft_core.rs` file. You need to edit your implementation of `nft_token` so that the `JsonToken` sends back the new royalty information. + + + +--- + +## Deploying the contract {#redeploying-contract} + +As you saw in the previous tutorial, adding changes like these will cause problems when redeploying. Since these changes affect all the other tokens and the state won't be able to automatically be inherited by the new code, simply redeploying the contract will lead to errors. For this reason, you'll create a new account again. + +### Deployment and initialization + +Next, you'll deploy this contract to the network. + + + + + ```bash + export ROYALTY_NFT_CONTRACT_ID= + near create-account $ROYALTY_NFT_CONTRACT_ID --useFaucet + ``` + + + + + ```bash + export ROYALTY_NFT_CONTRACT_ID= + near account create-account sponsor-by-faucet-service $ROYALTY_NFT_CONTRACT_ID autogenerate-new-keypair save-to-keychain network-config testnet create + ``` + + + +Using the cargo-near, deploy and initialize the contract as you did in the previous tutorials: + +```bash +cargo near deploy build-non-reproducible-wasm $ROYALTY_NFT_CONTRACT_ID with-init-call new_default_meta json-args '{"owner_id": "'$ROYALTY_NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + +### Minting {#minting} + +Next, you'll need to mint a token. By running this command, you'll mint a token with a token ID `"royalty-token"` and the receiver will be your new account. In addition, you're passing in a map with two accounts that will get perpetual royalties whenever your token is sold. + + + + + ```bash + near call $ROYALTY_NFT_CONTRACT_ID nft_mint '{"token_id": "royalty-token", "metadata": {"title": "Royalty Token", "description": "testing out the new royalty extension of the standard", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$ROYALTY_NFT_CONTRACT_ID'", "perpetual_royalties": {"benjiman.testnet": 2000, "mike.testnet": 1000, "josh.testnet": 500}}' --gas 100000000000000 --deposit 0.1 --accountId $ROYALTY_NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $ROYALTY_NFT_CONTRACT_ID nft_mint json-args '{"token_id": "royalty-token", "metadata": {"title": "Royalty Token", "description": "testing out the new royalty extension of the standard", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$ROYALTY_NFT_CONTRACT_ID'", "perpetual_royalties": {"benjiman.testnet": 2000, "mike.testnet": 1000, "josh.testnet": 500}}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $ROYALTY_NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +You can check to see if everything went through properly by calling one of the enumeration functions: + + + + + ```bash + near view $ROYALTY_NFT_CONTRACT_ID nft_tokens_for_owner '{"account_id": "'$ROYALTY_NFT_CONTRACT_ID'", "limit": 10}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $ROYALTY_NFT_CONTRACT_ID nft_tokens_for_owner json-args '{"account_id": "'$ROYALTY_NFT_CONTRACT_ID'", "limit": 10}' network-config testnet now + ``` + + + +This should return an output similar to the following: + +```json +[ + { + "token_id": "royalty-token", + "owner_id": "royalty.goteam.examples.testnet", + "metadata": { + "title": "Royalty Token", + "description": "testing out the new royalty extension of the standard", + "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif", + "media_hash": null, + "copies": null, + "issued_at": null, + "expires_at": null, + "starts_at": null, + "updated_at": null, + "extra": null, + "reference": null, + "reference_hash": null + }, + "approved_account_ids": {}, + "royalty": { + "josh.testnet": 500, + "benjiman.testnet": 2000, + "mike.testnet": 1000 + } + } +] +``` + +Notice how there's now a royalty field that contains the 3 accounts that will get a combined 35% of all sales of this NFT? Looks like it works! Go team :) + +### NFT payout + +Let's calculate the payout for the `"royalty-token"` NFT, given a balance of 100 yoctoNEAR. It's important to note that the balance being passed into the `nft_payout` function is expected to be in yoctoNEAR. + + + + + ```bash + near view $ROYALTY_NFT_CONTRACT_ID nft_payout '{"token_id": "royalty-token", "balance": "100", "max_len_payout": 100}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $ROYALTY_NFT_CONTRACT_ID nft_payout json-args '{"token_id": "royalty-token", "balance": "100", "max_len_payout": 100}' network-config testnet now + ``` + + + +This command should return an output similar to the following: + +```js +{ + payout: { + 'josh.testnet': '5', + 'royalty.goteam.examples.testnet': '65', + 'mike.testnet': '10', + 'benjiman.testnet': '20' + } +} +``` + +If the NFT was sold for 100 yoctoNEAR, josh would get 5, Benji would get 20, mike would get 10, and the owner, in this case `royalty.goteam.examples.testnet` would get the rest: 65. + +## Conclusion + +At this point you have everything you need for a fully functioning NFT contract to interact with marketplaces. +The last remaining standard that you could implement is the events standard. This allows indexers to know what functions are being called and makes it easier and more reliable to keep track of information that can be used to populate the collectibles tab in the wallet for example. + +:::info remember +If you want to see the finished code from this tutorial, you can go to the `nft-contract-royalty` folder. +::: + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` +- Enumeration standard: [NEP181](https://github.com/near/NEPs/tree/master/neps/nep-0181.md), version `1.0.0` +- Royalties standard: [NEP199](https://github.com/near/NEPs/tree/master/neps/nep-0171.md/Payout), version `2.0.0` + +::: diff --git a/website/docs/7-events.md b/website/docs/7-events.md new file mode 100644 index 0000000..3737def --- /dev/null +++ b/website/docs/7-events.md @@ -0,0 +1,305 @@ +--- +id: events +title: Events +description: "Learn about the events standard and how to implement it in your smart contract." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial, you'll learn about the [events standard](https://github.com/near/NEPs/blob/master/neps/nep-0256.md) and how to implement it in your smart contract. + +--- + +## Understanding the use case {#understanding-the-use-case} + +Have you ever wondered how the wallet knows which NFTs you own and how it can display them in the [collectibles tab](https://testnet.mynearwallet.com/?tab=collectibles)? Originally, an indexer used to listen for any functions calls starting with `nft_` on your account. These contracts were then flagged on your account as likely NFT contracts. + +When you navigated to your collectibles tab, the wallet would then query all those contracts for the list of NFTs you owned using the `nft_tokens_for_owner` function you saw in the [enumeration tutorial](3-enumeration.md). + +
+ +### The problem {#the-problem} + +This method of flagging contracts was not reliable as each NFT-driven application might have its own way of minting or transferring NFTs. In addition, it's common for apps to transfer or mint many tokens at a time using batch functions. + +
+ +### The solution {#the-solution} + +A standard was introduced so that smart contracts could emit an event anytime NFTs were transferred, minted, or burnt. This event was in the form of a log. No matter how a contract implemented the functionality, an indexer could now listen for those standardized logs. + +As per the standard, you need to implement a logging functionality that gets fired when NFTs are transferred or minted. In this case, the contract doesn't support burning so you don't need to worry about that for now. + +It's important to note the standard dictates that the log should begin with `"EVENT_JSON:"`. The structure of your log should, however, always contain the 3 following things: + +- **standard**: the current name of the standard (e.g. nep171) +- **version**: the version of the standard you're using (e.g. 1.0.0) +- **event**: a list of events you're emitting. + +The event interface differs based on whether you're recording transfers or mints. The interface for both events is outlined below. + +**Transfer events**: +- *Optional* - **authorized_id**: the account approved to transfer on behalf of the owner. +- **old_owner_id**: the old owner of the NFT. +- **new_owner_id**: the new owner that the NFT is being transferred to. +- **token_ids**: a list of NFTs being transferred. +- *Optional* - **memo**: an optional message to include with the event. + +**Minting events**: +- **owner_id**: the owner that the NFT is being minted to. +- **token_ids**: a list of NFTs being transferred. +- *Optional* - **memo**: an optional message to include with the event. + +
+ +### Examples {#examples} + +In order to solidify your understanding of the standard, let's walk through three scenarios and see what the logs should look like. + +#### Scenario A - simple mint + +In this scenario, Benji wants to mint an NFT to Mike with a token ID `"team-token"` and he doesn't include a message. The log should look as follows. + +```rust +EVENT_JSON:{ + "standard": "nep171", + "version": "1.0.0", + "event": "nft_mint", + "data": [ + {"owner_id": "mike.testnet", "token_ids": ["team-token"]} + ] +} +``` + +#### Scenario B - batch mint + +In this scenario, Benji wants to perform a batch mint. He will mint an NFT to Mike, Damian, Josh, and Dorian. Dorian, however, will get two NFTs. Each token ID will be `"team-token"` followed by an incrementing number. The log is as follows. + + +```rust +EVENT_JSON:{ + "standard": "nep171", + "version": "1.0.0", + "event": "nft_mint", + "data": [ + {"owner_id": "mike.testnet", "token_ids": ["team-token0"]}, + {"owner_id": "damian.testnet", "token_ids": ["team-token1"]}, + {"owner_id": "josh.testnet", "token_ids": ["team-token2"]} + {"owner_id": "dorian.testnet", "token_ids": ["team-token3", "team-token4"]}, + ] +} +``` + +#### Scenario C - transfer NFTs + +In this scenario, Mike is transferring both his team tokens to Josh. The log should look as follows. + +```rust +EVENT_JSON:{ + "standard": "nep171", + "version": "1.0.0", + "event": "nft_transfer", + "data": [ + {"old_owner_id": "mike.testnet", "new_owner_id": "josh.testnet", "token_ids": ["team-token", "team-token0"], "memo": "Go Team!"} + ] +} +``` + +--- + +## Modifications to the contract {#modifications-to-the-contract} + +At this point, you should have a good understanding of what the end goal should be so let's get to work! Open the repository and create a new file in the `nft-contract-basic/src` directory called `events.rs`. This is where your log structs will live. + +If you wish to see the finished code of the events implementation, that can be found on the `nft-contract-events` folder. + +### Creating the events file {#events-rs} + +Copy the following into your file. This will outline the structs for your `EventLog`, `NftMintLog`, and `NftTransferLog`. In addition, we've added a way for `EVENT_JSON:` to be prefixed whenever you log the `EventLog`. + + + +This requires the `serde_json` package which you can easily add to your `nft-contract-skeleton/Cargo.toml` file: + + + +
+ +### Adding modules and constants {#lib-rs} + +Now that you've created a new file, you need to add the module to the `lib.rs` file. In addition, you can define two constants for the standard and version that will be used across our contract. + + + +
+ +### Logging minted tokens {#logging-minted-tokens} + +Now that all the tools are set in place, you can now implement the actual logging functionality. Since the contract will only be minting tokens in one place, open the `nft-contract-basic/src/mint.rs` file and navigate to the bottom of the file. This is where you'll construct the log for minting. Anytime someone successfully mints an NFT, it will now correctly emit a log. + + + +
+ +### Logging transfers {#logging-transfers} + +Let's open the `nft-contract-basic/src/internal.rs` file and navigate to the `internal_transfer` function. This is the location where you'll build your transfer logs. Whenever an NFT is transferred, this function is called and so you'll correctly be logging the transfers. + + + +This solution, unfortunately, has an edge case which will break things. If an NFT is transferred via the `nft_transfer_call` function, there's a chance that the transfer will be reverted if the `nft_on_transfer` function returns `true`. Taking a look at the logic for `nft_transfer_call`, you can see why this is a problem. + +When `nft_transfer_call` is invoked, it will: +- Call `internal_transfer` to perform the actual transfer logic. +- Initiate a cross-contract call and invoke the `nft_on_transfer` function. +- Resolve the promise and perform logic in `nft_resolve_transfer`. + - This will either return true meaning the transfer went fine or it will revert the transfer and return false. + +If you only place the log in the `internal_transfer` function, the log will be emitted and the indexer will think that the NFT was transferred. If the transfer is reverted during `nft_resolve_transfer`, however, that event should **also** be emitted. Anywhere that an NFT **could** be transferred, we should add logs. Replace the `nft_resolve_transfer` with the following code. + + + +In addition, you need to add an `authorized_id` and `memo` to the parameters for `nft_resolve_transfer` as shown below. + +:::tip + +We will talk more about this [`authorized_id`](./5-approvals.md) in the following chapter. + +::: + + + + +The last step is to modify the `nft_transfer_call` logic to include these new parameters: + + + +With that finished, you've successfully implemented the events standard and it's time to start testing. + +--- + +## Deploying the contract {#redeploying-contract} + +For the purpose of readability and ease of development, instead of redeploying the contract to the same account, let's create an account and deploy to that instead. You could have deployed to the same account as none of the changes you implemented in this tutorial would have caused errors. + +### Deployment + +Next, you'll deploy this contract to the network. + + + + + ```bash + export EVENTS_NFT_CONTRACT_ID= + near create-account $EVENTS_NFT_CONTRACT_ID --useFaucet + ``` + + + + + ```bash + export EVENTS_NFT_CONTRACT_ID= + near account create-account sponsor-by-faucet-service $EVENTS_NFT_CONTRACT_ID autogenerate-new-keypair save-to-keychain network-config testnet create + ``` + + + +Using the cargo-near, deploy and initialize the contract as you did in the previous tutorials: + +```bash +cargo near deploy build-non-reproducible-wasm $EVENTS_NFT_CONTRACT_ID with-init-call new_default_meta json-args '{"owner_id": "'$EVENTS_NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + +
+ +### Minting {#minting} + +Next, you'll need to mint a token. By running this command, you'll mint a token with a token ID `"events-token"` and the receiver will be your new account. In addition, you're passing in a map with two accounts that will get perpetual royalties whenever your token is sold. + + + + + ```bash + near call $EVENTS_NFT_CONTRACT_ID nft_mint '{"token_id": "events-token", "metadata": {"title": "Events Token", "description": "testing out the new events extension of the standard", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$EVENTS_NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $EVENTS_NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $EVENTS_NFT_CONTRACT_ID nft_mint json-args '{"token_id": "events-token", "metadata": {"title": "Events Token", "description": "testing out the new events extension of the standard", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$EVENTS_NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $EVENTS_NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +You can check to see if everything went through properly by looking at the output in your CLI: + +```bash +Doing account.functionCall() +Receipts: F4oxNfv54cqwUwLUJ7h74H1iE66Y3H7QDfZMmGENwSxd, BJxKNFRuLDdbhbGeLA3UBSbL8UicU7oqHsWGink5WX7S + Log [events.goteam.examples.testnet]: EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_mint","data":[{"owner_id":"events.goteam.examples.testnet","token_ids":["events-token"]}]} +Transaction Id 4Wy2KQVTuAWQHw5jXcRAbrz7bNyZBoiPEvLcGougciyk +To see the transaction in the transaction explorer, please open this url in your browser +https://testnet.nearblocks.io/txns/4Wy2KQVTuAWQHw5jXcRAbrz7bNyZBoiPEvLcGougciyk +'' +``` + +You can see that the event was properly logged! + +
+ +### Transferring {#transferring} + +You can now test if your transfer log works as expected by sending `benjiman.testnet` your NFT. + + + + + ```bash + near call $EVENTS_NFT_CONTRACT_ID nft_transfer '{"receiver_id": "benjiman.testnet", "token_id": "events-token", "memo": "Go Team :)", "approval_id": 0}' --gas 100000000000000 --depositYocto 1 --accountId $EVENTS_NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $EVENTS_NFT_CONTRACT_ID nft_transfer json-args '{"receiver_id": "benjiman.testnet", "token_id": "events-token", "memo": "Go Team :)", "approval_id": 0}' prepaid-gas '100.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as $EVENTS_NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +This should return an output similar to the following: + +```bash +Doing account.functionCall() +Receipts: EoqBxrpv9Dgb8KqK4FdeREawVVLWepEUR15KPNuZ4fGD, HZ4xQpbgc8EfU3PiV72LvfXb2f3dVC1n9aVTbQds9zfR + Log [events.goteam.examples.testnet]: Memo: Go Team :) + Log [events.goteam.examples.testnet]: EVENT_JSON:{"standard":"nep171","version":"1.0.0","event":"nft_transfer","data":[{"authorized_id":"events.goteam.examples.testnet","old_owner_id":"events.goteam.examples.testnet","new_owner_id":"benjiman.testnet","token_ids":["events-token"],"memo":"Go Team :)"}]} +Transaction Id 4S1VrepKzA6HxvPj3cK12vaT7Dt4vxJRWESA1ym1xdvH +To see the transaction in the transaction explorer, please open this url in your browser +https://testnet.nearblocks.io/txns/4S1VrepKzA6HxvPj3cK12vaT7Dt4vxJRWESA1ym1xdvH +'' +``` + +Hurray! At this point, your NFT contract is fully complete and the events standard has been implemented. + +--- + +## Conclusion + +Today you went through the [events standard](https://github.com/near/NEPs/blob/master/neps/nep-0256.md) and implemented the necessary logic in your smart contract. You created events for [minting](#logging-minted-tokens) and [transferring](#logging-transfers) NFTs. You then deployed and tested your changes by [minting](#minting) and [transferring](#transferring) NFTs. + +In the [next tutorial](8-marketplace.md), you'll look at the basics of a marketplace contract and how it was built. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` +- Events standard: [NEP297 extension](https://github.com/near/NEPs/blob/master/neps/nep-0256.md), version `1.1.0` + +::: diff --git a/website/docs/8-marketplace.md b/website/docs/8-marketplace.md new file mode 100644 index 0000000..5c74375 --- /dev/null +++ b/website/docs/8-marketplace.md @@ -0,0 +1,406 @@ +--- +id: marketplace +title: Marketplace +sidebar_label: Marketplace +description: "Learn how to build an NFT marketplace on NEAR to list, buy, and sell tokens." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial, you'll learn the basics of an NFT marketplace contract where you can buy and sell non-fungible tokens for $NEAR. In the previous tutorials, you went through and created a fully fledged NFT contract that incorporates all the standards found in the [NFT standard](https://github.com/near/NEPs/tree/master/neps/nep-0171.md). + +--- + +## Introduction + +Throughout this tutorial, you'll learn how a marketplace contract **could** work on NEAR. This is meant to be **an example** as there is no **canonical implementation**. Feel free to branch off and modify this contract to meet your specific needs. + +```bash +cd market-contract/ +``` + +This folder contains both the actual contract code and dependencies as outlined below. + +``` +market-contract +├── Cargo.lock +├── Cargo.toml +├── README.md +└── src + ├── external.rs + ├── internal.rs + ├── lib.rs + ├── nft_callbacks.rs + ├── sale.rs + └── sale_views.rs +``` + +--- + +## Understanding the contract + +At first, the contract can be quite overwhelming but if you strip away all the fluff and dig into the core functionalities, it's actually quite simple. This contract was designed for only one thing - to allow people to buy and sell NFTs for NEAR. This includes the support for paying royalties, updating the price of your sales, removing sales and paying for storage. + +Let's go through the files and take note of some of the important functions and what they do. + +--- + +## lib.rs {#lib-rs} + +This file outlines what information is stored on the contract as well as some other crucial functions that you'll learn about below. + +### Initialization function {#initialization-function} + +The first function you'll look at is the initialization function. This takes an `owner_id` as the only parameter and will default all the storage collections to their default values. + + + +
+ +### Storage management model {#storage-management-model} + +Next, let's talk about the storage management model chosen for this contract. On the NFT contract, users attached $NEAR to the calls that needed storage paid for. For example, if someone was minting an NFT, they would need to attach `x` amount of NEAR to cover the cost of storing the data on the contract. + +On this marketplace contract, however, the storage model is a bit different. Users will need to deposit $NEAR onto the marketplace to cover the storage costs. Whenever someone puts an NFT for sale, the marketplace needs to store that information which costs $NEAR. Users can either deposit as much NEAR as they want so that they never have to worry about storage again or they can deposit the minimum amount to cover 1 sale on an as-needed basis. + +You might be thinking about the scenario when a sale is purchased. What happens to the storage that is now being released on the contract? This is why we've introduced a storage withdrawal function. This allows users to withdraw any excess storage that is not being used. Let's go through some scenarios to understand the logic. The required storage for 1 sale is 0.01 NEAR on the marketplace contract. + +**Scenario A** + +- Benji wants to list his NFT on the marketplace but has never paid for storage. +- He deposits exactly 0.01 NEAR using the `storage_deposit` method. This will cover 1 sale. +- He lists his NFT on the marketplace and is now using up 1 out of his prepaid 1 sales and has no more storage left. If he were to call `storage_withdraw`, nothing would happen. +- Dorian loves his NFT and quickly purchases it before anybody else can. This means that Benji's sale has now been taken down (since it was purchased) and Benji is using up 0 out of his prepaid 1 sales. In other words, he has an excess of 1 sale or 0.01 NEAR. +- Benji can now call `storage_withdraw` and will be transferred his 0.01 NEAR back. On the contract's side, after withdrawing, he will have 0 sales paid for and will need to deposit storage before trying to list anymore NFTs. + +**Scenario B** + +- Dorian owns one hundred beautiful NFTs and knows that he wants to list all of them. +- To avoid having to call `storage_deposit` everytime he wants to list an NFT, he calls it once. Since Dorian is a baller, he attaches 10 NEAR which is enough to cover 1000 sales. Then he lists his 100 NFTs and now he has an excess of 9 NEAR or 900 sales. +- Dorian needs the 9 NEAR for something else but doesn't want to take down his 100 listings. Since he has an excess of 9 NEAR, he can easily withdraw and still have his 100 listings. After calling `storage_withdraw` and being transferred 9 NEAR, he will have an excess of 0 sales. + +With this behavior in mind, the following two functions outline the logic. + + + + +In this contract, the storage required for each sale is 0.01 NEAR but you can query that information using the `storage_minimum_balance` function. In addition, if you wanted to check how much storage a given account has paid, you can query the `storage_balance_of` function. + +With that out of the way, it's time to move onto the `sale.rs` file where you'll look at how NFTs are put for sale. + +--- + +## sale.rs {#sale} + +This file is responsible for the internal marketplace logic. + +### Listing logic {#listing-logic} + +In order to put an NFT on sale, a user should: + +1. Approve the marketplace contract on an NFT token (by calling `nft_approve` method on the NFT contract) +2. Call the `list_nft_for_sale` method on the marketplace contract. + +#### nft_approve +This method has to be called by the user to [approve our marketplace](5-approvals.md), so it can transfer the NFT on behalf of the user. In our contract, we only need to implement the `nft_on_approve` method, which is called by the NFT contract when the user approves our contract. + +In our case, we left it blank, but you could implement it to do some additional logic when the user approves your contract. + + + + +#### list_nft_for_sale +The `list_nft_for_sale` method lists an nft for sale, for this, it takes the id of the NFT contract (`nft_contract_id`), the `token_id` to know which token is listed, the [`approval_id`](5-approvals.md), and the price in yoctoNEAR at which we want to sell the NFT. + + + +The function first checks if the user has [enough storage available](#storage-management-model), and makes two calls in parallel to the NFT contract. The first is to check if this marketplace contract is authorized to transfer the NFT. The second is to make sure that the caller (`predecessor`) is actually the owner of the NFT, otherwise, anyone could call this method to create fake listings. This second call is mostly a measure to avoid spam, since anyways, only the owner could approve the marketplace contract to transfer the NFT. + +Both calls return their results to the `process_listing` function, which executes the logic to store the sale object on the contract. + +#### process_listing + +The `process_listing` function will receive if our marketplace is authorized to list the NFT on sale, and if this was requested by the NFTs owner. If both conditions are met, it will proceed to check if the user has enough storage, and store the sale object on the contract. + + + +
+ +### Sale object {#sale-object} + +It's important to understand what information the contract is storing for each sale object. Since the marketplace has many NFTs listed that come from different NFT contracts, simply storing the token ID would not be enough to distinguish between different NFTs. This is why you need to keep track of both the token ID and the contract by which the NFT came from. In addition, for each listing, the contract must keep track of the approval ID it was given to transfer the NFT. Finally, the owner and sale conditions are needed. + + + +
+ +### Removing sales {#removing-sales} + +In order to remove a listing, the owner must call the `remove_sale` function and pass the NFT contract and token ID. Behind the scenes, this calls the `internal_remove_sale` function which you can find in the `internal.rs` file. This will assert one yoctoNEAR for security reasons. + + + +
+ +### Updating price {#updating-price} + +In order to update the list price of a token, the owner must call the `update_price` function and pass in the contract, token ID, and desired price. This will get the sale object, change the sale conditions, and insert it back. For security reasons, this function will assert one yoctoNEAR. + + + +
+ +### Purchasing NFTs {#purchasing-nfts} + +For purchasing NFTs, you must call the `offer` function. It takes an `nft_contract_id` and `token_id` as parameters. You must attach the correct amount of NEAR to the call in order to purchase. Behind the scenes, this will make sure your deposit is greater than the list price and call a private method `process_purchase` which will perform a cross-contract call to the NFT contract to invoke the `nft_transfer_payout` function. This will transfer the NFT using the [approval management](https://github.com/near/NEPs/tree/master/neps/nep-0178.md) standard that you learned about and it will return the `Payout` object which includes royalties. + +The marketplace will then call `resolve_purchase` where it will check for malicious payout objects and then if everything went well, it will pay the correct accounts. + + + +--- + +## sale_view.rs {#sale_view-rs} + +The final file is [`sale_view.rs`](https://github.com/near-examples/nft-tutorial/blob/main/market-contract/src/sale_view.rs) file. This is where some of the enumeration methods are outlined. It allows users to query for important information regarding sales. + +--- + +## Deployment and Initialization + +Next, you'll deploy this contract to the network. + + + + + ```bash + export MARKETPLACE_CONTRACT_ID= + near create-account $MARKETPLACE_CONTRACT_ID --useFaucet + ``` + + + + + ```bash + export MARKETPLACE_CONTRACT_ID= + near account create-account sponsor-by-faucet-service $MARKETPLACE_CONTRACT_ID autogenerate-new-keypair save-to-keychain network-config testnet create + ``` + + + +Using the build script, deploy the contract as you did in the previous tutorials: + +```bash +cargo near deploy build-non-reproducible-wasm $MARKETPLACE_CONTRACT_ID with-init-call new json-args '{"owner_id": "'$MARKETPLACE_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + +
+ +### Minting and approving + +Let's mint a new NFT token and approve a marketplace contract: + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"token_id": "token-1", "metadata": {"title": "My Non Fungible Team Token", "description": "The Team Most Certainly Goes :)", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$NFT_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"token_id": "token-1", "metadata": {"title": "My Non Fungible Team Token", "description": "The Team Most Certainly Goes :)", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "receiver_id": "'$NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + + + + + ```bash + near call $NFT_CONTRACT_ID nft_approve '{"token_id": "token-1", "account_id": "'$MARKETPLACE_CONTRACT_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_approve json-args '{"token_id": "token-1", "account_id": "'$MARKETPLACE_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +
+ +### Listing NFT on sale + + + + + ```bash + near call $MARKETPLACE_CONTRACT_ID list_nft_for_sale '{"nft_contract_id": "'$NFT_CONTRACT_ID'", "token_id": "token-1", "approval_id": 0, "msg": "{\"sale_conditions\": \"1\"}"}' --gas 300000000000000 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $MARKETPLACE_CONTRACT_ID list_nft_for_sale json-args '{"nft_contract_id": "'$NFT_CONTRACT_ID'", "token_id": "token-1", "approval_id": 0, "msg": "{\"sale_conditions\": \"1\"}"}' prepaid-gas '300.0 Tgas' attached-deposit '0 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +
+ +### Total supply {#total-supply} + +To query for the total supply of NFTs listed on the marketplace, you can call the `get_supply_sales` function. An example can be seen below. + + + + + ```bash + near view $MARKETPLACE_CONTRACT_ID get_supply_sales '{}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $MARKETPLACE_CONTRACT_ID get_supply_sales json-args {} network-config testnet now + ``` + + + + +
+ +### Total supply by owner {#total-supply-by-owner} + +To query for the total supply of NFTs listed by a specific owner on the marketplace, you can call the `get_supply_by_owner_id` function. An example can be seen below. + + + + + ```bash + near view $MARKETPLACE_CONTRACT_ID get_supply_by_owner_id '{"account_id": "'$NFT_CONTRACT_ID'"}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $MARKETPLACE_CONTRACT_ID get_supply_by_owner_id json-args '{"account_id": "'$NFT_CONTRACT_ID'"}' network-config testnet now + ``` + + + +
+ +### Total supply by contract {#total-supply-by-contract} + +To query for the total supply of NFTs that belong to a specific contract, you can call the `get_supply_by_nft_contract_id` function. An example can be seen below. + + + + + ```bash + near view $MARKETPLACE_CONTRACT_ID get_supply_by_nft_contract_id '{"nft_contract_id": "'$NFT_CONTRACT_ID'"}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $MARKETPLACE_CONTRACT_ID get_supply_by_nft_contract_id json-args '{"nft_contract_id": "'$NFT_CONTRACT_ID'"}' network-config testnet now + ``` + + + +
+ +### Query for listing information {#query-listing-information} + +To query for important information for a specific listing, you can call the `get_sale` function. This requires that you pass in the `nft_contract_token`. This is essentially the unique identifier for sales on the market contract as explained earlier. It consists of the NFT contract followed by a `DELIMITER` followed by the token ID. In this contract, the `DELIMITER` is simply a period: `.`. An example of this query can be seen below. + + + + + ```bash + near view $MARKETPLACE_CONTRACT_ID get_sale '{"nft_contract_token": "'$NFT_CONTRACT_ID'.token-1"}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $MARKETPLACE_CONTRACT_ID get_sale json-args '{"nft_contract_token": "'$NFT_CONTRACT_ID'.token-1"}' network-config testnet now + ``` + + + +In addition, you can query for paginated information about the listings for a given owner by calling the `get_sales_by_owner_id` function. + + + + + ```bash + near view $MARKETPLACE_CONTRACT_ID get_sales_by_owner_id '{"account_id": "'$NFT_CONTRACT_ID'", "from_index": "0", "limit": 5}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $MARKETPLACE_CONTRACT_ID get_sales_by_owner_id json-args '{"account_id": "'$NFT_CONTRACT_ID'", "from_index": "0", "limit": 5}' network-config testnet now + ``` + + + +Finally, you can query for paginated information about the listings that originate from a given NFT contract by calling the `get_sales_by_nft_contract_id` function. + + + + + ```bash + near view $MARKETPLACE_CONTRACT_ID get_sales_by_nft_contract_id '{"nft_contract_id": "'$NFT_CONTRACT_ID'", "from_index": "0", "limit": 5}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $MARKETPLACE_CONTRACT_ID get_sales_by_nft_contract_id json-args '{"nft_contract_id": "'$NFT_CONTRACT_ID'", "from_index": "0", "limit": 5}' network-config testnet now + ``` + + + +--- + +## Conclusion + +In this tutorial, you learned about the basics of a marketplace contract and how it works. You went through the [lib.rs](#lib-rs) file and learned about the [initialization function](#initialization-function) in addition to the [storage management](#storage-management-model) model. + +You went through the [NFTs listing process](#listing-logic). In addition, you went through some important functions needed after you've listed an NFT. This includes [removing sales](#removing-sales), [updating the price](#updating-price), and [purchasing NFTs](#purchasing-nfts). + +Finally, you went through the enumeration methods found in the [`sale_view`](#sale_view-rs) file. These allow you to query for important information found on the marketplace contract. + +You should now have a solid understanding of NFTs and marketplaces on NEAR. Feel free to branch off and expand on these contracts to create whatever cool applications you'd like. In the [next tutorial](9-series.md), you'll learn how to take the existing NFT contract and optimize it to allow for: +- Lazy Minting +- Creating Collections +- Allowlisting functionalities +- Optimized Storage Models + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://github.com/near/NEPs/tree/master/neps/nep-0171.md), version `1.0.0` + +::: diff --git a/website/docs/9-series.md b/website/docs/9-series.md new file mode 100644 index 0000000..e519bff --- /dev/null +++ b/website/docs/9-series.md @@ -0,0 +1,719 @@ +--- +id: series +title: Customizing the NFT Contract +sidebar_label: Lazy Minting, Collections, and More! +description: "Learn how to create NFT series." +--- +import {Github} from "@site/src/components/UI/Codetabs"; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In this tutorial, you'll learn how to take the [existing NFT contract](https://github.com/near-examples/nft-tutorial) you've been working with and modify it to meet some of the most common needs in the ecosystem.This includes [Lazy Minting NFTs](#lazy-minting), [Creating Collections](#nft-collections-and-series), [Restricting Minting Access](#restricted-access), and [Highly Optimizing Storage](#modifying-view-calls-for-optimizations), and hacking enumeration methods. + +--- + +## Introduction + +Now that you have a deeper understanding of basic NFT smart contracts, we can start to get creative and implement more unique features. The basic contract works really well for simple use-cases but as you begin to explore the potential of NFTs, you can use it as a foundation to build upon. + +A fun analogy would be that you now have a standard muffin recipe and it's now up to you to decide how to alter it to create your own delicious varieties, may I suggest blueberry perhaps. + +Below we've created a few of these new varieties by showing potential solutions to the problems outlined above. As we demonstrate how to customize the basic NFT contract, we hope it activates your ingenuity thus introducing you to what's possible and helping you discover the true potential of NFTs. 💪 + +
+ +### NFT Collections and Series + +NFT Collections help solve two common problems when dealing with the basic NFT contract: +- Storing repeated data. +- Organizing data and code. + +The concept of a collection in the NFT space has a very loose meaning and can be interpreted in many different ways. In our case, we'll define a collection as a set of tokens that share **similar metadata**. For example, you could create a painting and want 100 identical copies to be put for sale. In this case, all one hundred pieces would be part of the same *collection*. Each piece would have the same artist, title, description, media etc. + +One of the biggest problems with the basic NFT contract is that you store similar data many times. If you mint NFTs, the contract will store the metadata individually for **every single token ID**. We can fix this by introducing the idea of NFT series, or NFT collection. + +A series can be thought of as a bucket of token IDs that *all* share similar information. This information is specified when the series is **created** and can be the metadata, royalties, price etc. Rather than storing this information for **every token ID**, you can simply store it once in the series and then associate token IDs with their respective buckets. + +
+ +### Restricted Access + +Currently, the NFT contract allows anyone to mint NFTs. While this works well for some projects, the vast majority of dApps and creators want to restrict who can create NFTs on the contract. This is why you'll introduce an allowlist functionality for both series and for NFTs. You'll have two data structures customizable by the contract owner: +- Approved Minters +- Approved Creators + +If you're an approved minter, you can freely mint NFTs for any given series. You cannot, however, create new series. + +On the other hand, you can also be an approved creator. This allows you to define new series that NFTs can be minted from. It's important to note that if you're an approved creator, you're not automatically an approved minter as well. Each of these permissions need to be given by the owner of the contract and they can be revoked at any time. + +
+ +### Lazy Minting + +Lazy minting allows users to mint *on demand*. Rather than minting all the NFTs and spending $NEAR on storage, you can instead mint the tokens **when they are purchased**. This helps to avoid burning unnecessary Gas and saves on storage for when not all the NFTs are purchased. Let's look at a common scenario to help solidify your understanding: + +Benji has created an amazing digital painting of the famous Go Team gif. He wants to sell 1000 copies of it for 1 $NEAR each. Using the traditional approach, he would have to mint each copy individually and pay for the storage himself. He would then need to either find or deploy a marketplace contract and pay for the storage to put 1000 copies up for sale. He would need to burn Gas putting each token ID up for sale 1 by 1. + +After that, people would purchase the NFTs, and there would be no guarantee that all or even any would be sold. There's a real possibility that nobody buys a single piece of his artwork, and Benji spent all that time, effort and money on nothing. + +Lazy minting would allow the NFTs to be *automatically minted on-demand*. Rather than having to purchase NFTs from a marketplace, Benji could specify a price on the NFT contract and a user could directly call the `nft_mint` function whereby the funds would be distributed to Benji's account directly. + +Using this model, NFTs would **only** be minted when they're actually purchased and there wouldn't be any upfront fee that Benji would need to pay in order to mint all 1000 NFTs. In addition, it removes the need to have a separate marketplace contract. + +With this example laid out, a high level overview of lazy minting is that it gives the ability for someone to mint "on-demand" - they're lazily minting the NFTs instead of having to mint everything up-front even if they're unsure if there's any demand for the NFTs. With this model, you don't have to waste Gas or storage fees because you're only ever minting when someone actually purchases the artwork. + +--- + +## New Contract File Structure + +Let's now take a look at how we've implemented solutions to the issues we've discussed so far. + +In your locally cloned example of the [`nft-tutorial`](https://github.com/near-examples/nft-tutorial) check out the `main` branch and be sure to pull the most recent version. + +```bash +git checkout main && git pull +``` + +You'll notice that there's a folder at the root of the project called `nft-series`. This is where the smart contract code lives. If you open the `src` folder, it should look similar to the following: + +``` +src +├── approval.rs +├── enumeration.rs +├── events.rs +├── internal.rs +├── lib.rs +├── metadata.rs +├── nft_core.rs +├── owner.rs +├── royalty.rs +├── series.rs +``` + +--- + +## Differences + +You'll notice that most of this code is the same, however, there are a few differences between this contract and the basic NFT contract. + +### Main Library File + +Starting with `lib.rs`, you'll notice that the contract struct has been modified to now store the following information. + +```diff +pub owner_id: AccountId, ++ pub approved_minters: LookupSet, ++ pub approved_creators: LookupSet, +pub tokens_per_owner: LookupMap>, +pub tokens_by_id: UnorderedMap, +- pub token_metadata_by_id: UnorderedMap, ++ pub series_by_id: UnorderedMap, +pub metadata: LazyOption, +``` + +As you can see, we've replaced `token_metadata_by_id` with `series_by_id` and added two lookup sets: + +- **series_by_id**: Map a series ID (u64) to its Series object. +- **approved_minters**: Keeps track of accounts that can call the `nft_mint` function. +- **approved_creators**: Keeps track of accounts that can create new series. + +
+ +### Series Object {#series-object} +In addition, we're now keeping track of a new object called a `Series`. + +```rust +pub struct Series { + // Metadata including title, num copies etc.. that all tokens will derive from + metadata: TokenMetadata, + // Royalty used for all tokens in the collection + royalty: Option>, + // Set of tokens in the collection + 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, + // Owner of the collection + owner_id: AccountId, +} +``` + +This object stores information that each token will inherit from. This includes: +- The [metadata](2-minting.md#metadata-and-token-info). +- The [royalties](6-royalty.md). +- The price. + +:::caution +If a price is specified, there will be no restriction on who can mint tokens in the series. In addition, if the `copies` field is specified in the metadata, **only** that number of NFTs can be minted. If the field is omitted, an unlimited amount of tokens can be minted. +::: + +We've also added a field `tokens` which keeps track of all the token IDs that have been minted for this series. This allows us to deal with the potential `copies` cap by checking the length of the set. It also allows us to paginate through all the tokens in the series. + +
+ +### Creating Series + +`series.rs` is a new file that replaces the old [minting](2-minting.md) logic. This file has been created to combine both the series creation and minting logic into one. + + + +The function takes in a series ID in the form of a [u64](https://doc.rust-lang.org/std/primitive.u64.html), the metadata, royalties, and the price for tokens in the series. It will then create the [Series object](#series-object) and insert it into the contract's series_by_id data structure. It's important to note that the caller must be an approved creator and they must attach enough $NEAR to cover storage costs. + +
+ +### Minting NFTs + +Next, we'll look at the minting function. If you remember from before, this used to take the following parameters: +- Token ID +- Metadata +- Receiver ID +- Perpetual Royalties + +With the new and improved minting function, these parameters have been changed to just two: +- The series ID +- The receiver ID. + +The mint function might look complicated at first but let's break it down to understand what's happening. The first thing it does is get the [series object](#series-object) from the specified series ID. From there, it will check that the number of copies won't be exceeded if one is specified in the metadata. + +It will then store the token information on the contract as explained in the [minting section](2-minting.md#storage-implications) of the tutorial and map the token ID to the series. Once this is finished, a mint log will be emitted and it will ensure that enough deposit has been attached to the call. This amount differs based on whether or not the series has a price. + +#### Required Deposit + +As we went over in the [minting section](2-minting.md#storage-implications) of this tutorial, all information stored on the contract costs $NEAR. When minting, there is a required deposit to pay for this storage. For *this contract*, a series price can also be specified by the owner when the series is created. This price will be used for **all** NFTs in the series when they are minted. If the price is specified, the deposit must cover both the storage as well as the price. + +If a price **is specified** and the user attaches more deposit than what is necessary, the excess is sent to the **series owner**. There is also *no restriction* on who can mint tokens for series that have a price. The caller does **not** need to be an approved minter. + +If **no price** was specified in the series and the user attaches more deposit than what is necessary, the excess is *refunded to them*. In addition, the contract makes sure that the caller is an approved minter in this case. + +:::info +Notice how the token ID isn't required? This is because the token ID is automatically generated when minting. The ID stored on the contract is `${series_id}:${token_id}` where the token ID is a nonce that increases each time a new token is minted in a series. This not only reduces the amount of information stored on the contract but it also acts as a way to check the specific edition number. +::: + + + +
+ +### View Functions + +Now that we've introduced the idea of series, more view functions have also been added. + +:::info +Notice how we've also created a new struct `JsonSeries` instead of returning the regular `Series` struct. This is because the `Series` struct contains an `UnorderedSet` which cannot be serialized. + +The common practice is to return everything **except** the `UnorderedSet` in a separate struct and then have entirely different methods for accessing the data from the `UnorderedSet` itself. + +::: + + + +The view functions are listed below. +- **[get_series_total_supply](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/enumeration.rs#L92)**: Get the total number of series currently on the contract. + - Arguments: None. + + + +- **[get_series](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/enumeration.rs#L97)**: Paginate through all the series in the contract and return a vector of `JsonSeries` objects. + - Arguments: `from_index: String | null`, `limit: number | null`. + + + +- **[get_series_details](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/enumeration.rs#L115)**: Get the `JsonSeries` details for a specific series. + - Arguments: `id: number`. + + + +- **[nft_supply_for_series](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/enumeration.rs#L133)**: View the total number of NFTs minted for a specific series. + - Arguments: `id: number`. + + + +- **[nft_tokens_for_series](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/enumeration.rs#L146)**: Paginate through all NFTs for a specific series and return a vector of `JsonToken` objects. + - Arguments: `id: number`, `from_index: String | null`, `limit: number | null`. + + + +:::info +Notice how with every pagination function, we've also included a getter to view the total supply. This is so that you can use the `from_index` and `limit` parameters of the pagination functions in conjunction with the total supply so you know where to end your pagination. +::: + +
+ +### Modifying View Calls for Optimizations + +Storing information on-chain can be very expensive. As you level up in your smart contract development skills, one area to look into is reducing the amount of information stored. View calls are a perfect example of this optimization. + +For example, if you wanted to relay the edition number for a given NFT in its title, you don't necessarily need to store this on-chain for every token. Instead, you could modify the view functions to manually append this information to the title before returning it. + +To do this, here's a way of modifying the `nft_token` function as it's central to all enumeration methods. + + + +For example if a token had a title `"My Amazing Go Team Gif"` and the NFT was edition 42, the new title returned would be `"My Amazing Go Team Gif - 42"`. If the NFT didn't have a title in the metadata, the series and edition number would be returned in the form of `Series {} : Edition {}`. + +While this is a small optimization, this idea is extremely powerful as you can potentially save on a ton of storage. As an example: most of the time NFTs don't utilize the following fields in their metadata. +- `issued_at` +- `expires_at` +- `starts_at` +- `updated_at` + +As an optimization, you could change the token metadata that's **stored** on the contract to not include these fields but then when returning the information in `nft_token`, you could simply add them in as `null` values. + +
+ +### Owner File + +The last file we'll look at is the owner file found at `owner.rs`. This file simply contains all the functions for getting and setting approved creators and approved minters which can only be called by the contract owner. + +:::info +There are some other smaller changes made to the contract that you can check out if you'd like. The most notable are: +- The `Token` and `JsonToken` objects have been [changed](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/metadata.rs#L40) to reflect the new series IDs. +- All references to `token_metadata_by_id` have been [changed](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/enumeration.rs#L23) to `tokens_by_id` +- Royalty functions [now](https://github.com/near-examples/nft-tutorial/blob/main/nft-series/src/royalty.rs#L43) calculate the payout objects by using the series' royalties rather than the token's royalties. +::: + +--- + +## Building the Contract + +Now that you hopefully have a good understanding of the contract, let's get started building it. Run the following build command to compile the contract to wasm. + +```bash +cargo near build +``` + +--- + +## Deployment and Initialization + +Next, you'll deploy this contract to the network. + + + + + ```bash + export NFT_CONTRACT_ID= + near create-account $NFT_CONTRACT_ID --useFaucet + ``` + + + + + ```bash + export NFT_CONTRACT_ID= + near account create-account sponsor-by-faucet-service $NFT_CONTRACT_ID autogenerate-new-keypair save-to-keychain network-config testnet create + ``` + + + +Check if this worked correctly by echoing the environment variable. +```bash +echo $NFT_CONTRACT_ID +``` +This should return your ``. The next step is to initialize the contract with some default metadata. + +```bash +cargo near deploy build-non-reproducible-wasm $NFT_CONTRACT_ID with-init-call new_default_meta json-args '{"owner_id": "'$NFT_CONTRACT_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' network-config testnet sign-with-keychain send +``` + +If you now query for the metadata of the contract, it should return our default metadata. + + + + + ```bash + near view $NFT_CONTRACT_ID nft_metadata '{}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID nft_metadata json-args {} network-config testnet now + ``` + + + +--- + +## Creating The Series + +The next step is to create two different series. One will have a price for lazy minting and the other will simply be a basic series with no price. The first step is to create an owner [sub-account](https://docs.near.org/tools/near-cli#create) that you can use to create both series + + + + + ```bash + export SERIES_OWNER=owner.$NFT_CONTRACT_ID + + near create-account $SERIES_OWNER --use-account $NFT_CONTRACT_ID --initial-balance 3 --network-id testnet + ``` + + + + + ```bash + export SERIES_OWNER=owner.$NFT_CONTRACT_ID + + near account create-account fund-myself $SERIES_OWNER '3 NEAR' autogenerate-new-keypair save-to-keychain sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +### Basic Series + +You'll now need to create the simple series with no price and no royalties. If you try to run the following command before adding the owner account as an approved creator, the contract should throw an error. + + + + + ```bash + near call $NFT_CONTRACT_ID create_series '{"id": 1, "metadata": {"title": "SERIES!", "description": "testing out the new series contract", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}}' --gas 100000000000000 --deposit 1 --accountId $SERIES_OWNER --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID create_series json-args '{"id": 1, "metadata": {"title": "SERIES!", "description": "testing out the new series contract", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}}' prepaid-gas '100.0 Tgas' attached-deposit '1 NEAR' sign-as $SERIES_OWNER network-config testnet sign-with-keychain send + ``` + + + +The expected output is an error thrown: `ExecutionError: 'Smart contract panicked: only approved creators can add a type`. If you now add the series owner as a creator, it should work. + + + + + ```bash + near call $NFT_CONTRACT_ID add_approved_creator '{"account_id": "'$SERIES_OWNER'"}' --gas 100000000000000 --accountId $SERIES_OWNER --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID add_approved_creator json-args '{"account_id": "'$SERIES_OWNER'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + + + + + ```bash + near call $NFT_CONTRACT_ID create_series '{"id": 1, "metadata": {"title": "SERIES!", "description": "testing out the new series contract", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}}' --gas 100000000000000 --deposit 1 --accountId $SERIES_OWNER --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID create_series json-args '{"id": 1, "metadata": {"title": "SERIES!", "description": "testing out the new series contract", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}}' prepaid-gas '100.0 Tgas' attached-deposit '1 NEAR' sign-as $SERIES_OWNER network-config testnet sign-with-keychain send + ``` + + + +If you now query for the series information, it should work! + + + + + ```bash + near view $NFT_CONTRACT_ID get_series '{}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID get_series json-args {} network-config testnet now + ``` + + + +Which should return something similar to: + +```js +[ + { + series_id: 1, + metadata: { + title: 'SERIES!', + description: 'testing out the new series contract', + media: 'https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif', + media_hash: null, + copies: null, + issued_at: null, + expires_at: null, + starts_at: null, + updated_at: null, + extra: null, + reference: null, + reference_hash: null + }, + royalty: null, + owner_id: 'owner.nft_contract.testnet' + } +] +``` + +
+ +### Series With a Price + +Now that you've created the first, simple series, let's create the second one that has a price of 1 $NEAR associated with it. + + + + + ```bash + near call $NFT_CONTRACT_ID create_series '{"id": 2, "metadata": {"title": "COMPLEX SERIES!", "description": "testing out the new contract with a complex series", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "price": "500000000000000000000000"}' --gas 100000000000000 --deposit 1 --accountId $SERIES_OWNER --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID create_series json-args '{"id": 2, "metadata": {"title": "COMPLEX SERIES!", "description": "testing out the new contract with a complex series", "media": "https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif"}, "price": "500000000000000000000000"}' prepaid-gas '100.0 Tgas' attached-deposit '1 NEAR' sign-as $SERIES_OWNER network-config testnet sign-with-keychain send + ``` + + + +If you now paginate through the series again, you should see both appear. + + + + + ```bash + near view $NFT_CONTRACT_ID get_series '{}' --networkId testnet + ``` + + + + + ```bash + near contract call-function as-read-only $NFT_CONTRACT_ID get_series json-args {} network-config testnet now + ``` + + + + +Which has + +```js +[ + { + series_id: 1, + metadata: { + title: 'SERIES!', + description: 'testing out the new series contract', + media: 'https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif', + media_hash: null, + copies: null, + issued_at: null, + expires_at: null, + starts_at: null, + updated_at: null, + extra: null, + reference: null, + reference_hash: null + }, + royalty: null, + owner_id: 'owner.nft_contract.testnet' + }, + { + series_id: 2, + metadata: { + title: 'COMPLEX SERIES!', + description: 'testing out the new contract with a complex series', + media: 'https://bafybeiftczwrtyr3k7a2k4vutd3amkwsmaqyhrdzlhvpt33dyjivufqusq.ipfs.dweb.link/goteam-gif.gif', + media_hash: null, + copies: null, + issued_at: null, + expires_at: null, + starts_at: null, + updated_at: null, + extra: null, + reference: null, + reference_hash: null + }, + royalty: null, + owner_id: 'owner.nft_contract.testnet' + } +] +``` + +--- + +## Minting NFTs + +Now that you have both series created, it's time to now mint some NFTs. You can either login with an existing NEAR wallet using [`near login`](https://docs.near.org/tools/near-cli#import) or you can create a sub-account of the NFT contract. In our case, we'll use a sub-account. + + + + + ```bash + export BUYER_ID=buyer.$NFT_CONTRACT_ID + + near create-account $BUYER_ID --use-account $NFT_CONTRACT_ID --initial-balance 1 --network-id testnet + ``` + + + + + ```bash + export BUYER_ID=buyer.$NFT_CONTRACT_ID + + near account create-account fund-myself $BUYER_ID '1 NEAR' autogenerate-new-keypair save-to-keychain sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +### Lazy Minting + +The first workflow you'll test out is [lazy minting](#lazy-minting) NFTs. If you remember, the second series has a price associated with it of 1 $NEAR. This means that there are no minting restrictions and anyone can try and purchase the NFT. Let's try it out. + +In order to view the NFT in the NEAR wallet, you'll want the `receiver_id` to be an account you have currently available in the wallet site. Let's export it to an environment variable. Run the following command but replace `YOUR_ACCOUNT_ID_HERE` with your actual NEAR account ID. + +```bash +export NFT_RECEIVER_ID=YOUR_ACCOUNT_ID_HERE +``` +Now if you try and run the mint command but don't attach enough $NEAR, it should throw an error. + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"id": "2", "receiver_id": "'$NFT_RECEIVER_ID'"}' --gas 100000000000000 --accountId $BUYER_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"id": "2", "receiver_id": "'$NFT_RECEIVER_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as $BUYER_ID network-config testnet sign-with-keychain send + ``` + + + +Run the command again but this time, attach 1.5 $NEAR. + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"id": "2", "receiver_id": "'$NFT_RECEIVER_ID'"}' --gas 100000000000000 --deposit 1.5 --accountId $BUYER_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"id": "2", "receiver_id": "'$NFT_RECEIVER_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '1.5 NEAR' sign-as $BUYER_ID network-config testnet sign-with-keychain send + ``` + + + +This should output the following logs. + +```bash +Receipts: BrJLxCVmxLk3yNFVnwzpjZPDRhiCinNinLQwj9A7184P, 3UwUgdq7i1VpKyw3L5bmJvbUiqvFRvpi2w7TfqmnPGH6 + Log [nft_contract.testnet]: EVENT_JSON:{"standard":"nep171","version":"nft-1.0.0","event":"nft_mint","data":[{"owner_id":"benjiman.testnet","token_ids":["2:1"]}]} +Transaction Id FxWLFGuap7SFrUPLskVr7Uxxq8hpDtAG76AvshWppBVC +To see the transaction in the transaction explorer, please open this url in your browser +https://testnet.nearblocks.io/txns/FxWLFGuap7SFrUPLskVr7Uxxq8hpDtAG76AvshWppBVC +'' +``` + +If you check the explorer link, it should show that the owner received on the order of `0.59305 $NEAR`. + + + +
+ +### Becoming an Approved Minter + +If you try to mint the NFT for the simple series with no price, it should throw an error saying you're not an approved minter. + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"id": "1", "receiver_id": "'$NFT_RECEIVER_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $BUYER_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"id": "1", "receiver_id": "'$NFT_RECEIVER_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $BUYER_ID network-config testnet sign-with-keychain send + ``` + + + +Go ahead and run the following command to add the buyer account as an approved minter. + + + + + ```bash + near call $NFT_CONTRACT_ID add_approved_minter '{"account_id": "'$BUYER_ID'"}' --gas 100000000000000 --accountId $NFT_CONTRACT_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID add_approved_minter json-args '{"account_id": "'$BUYER_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0 NEAR' sign-as $NFT_CONTRACT_ID network-config testnet sign-with-keychain send + ``` + + + +If you now run the mint command again, it should work. + + + + + ```bash + near call $NFT_CONTRACT_ID nft_mint '{"id": "1", "receiver_id": "'$NFT_RECEIVER_ID'"}' --gas 100000000000000 --deposit 0.1 --accountId $BUYER_ID --networkId testnet + ``` + + + + + ```bash + near contract call-function as-transaction $NFT_CONTRACT_ID nft_mint json-args '{"id": "1", "receiver_id": "'$NFT_RECEIVER_ID'"}' prepaid-gas '100.0 Tgas' attached-deposit '0.1 NEAR' sign-as $BUYER_ID network-config testnet sign-with-keychain send + ``` + + + +
+ +### Viewing the NFTs in the Wallet + +Now that you've received both NFTs, they should show up in the NEAR wallet. Open the collectibles tab and search for the contract with the title `NFT Series Contract` and you should own two NFTs. One should be the complex series and the other should just be the simple version. Both should have ` - 1` appended to the end of the title because the NFTs are the first editions for each series. + + + +Hurray! You've successfully deployed and tested the series contract! **GO TEAM!**. + +--- + +## Conclusion + +In this tutorial, you learned how to take the basic NFT contract and iterate on it to create a complex and custom version to meet the needs of the community. You optimized the storage, introduced the idea of collections, created a lazy minting functionality, hacked the enumeration functions to save on storage, and created an allowlist functionality. + +You then built the contract and deployed it on chain. Once it was on-chain, you initialized it and created two sets of series. One was complex with a price and the other was a regular series. You lazy minted an NFT and purchased it for `1.5 $NEAR` and then added yourself as an approved minter. You then minted an NFT from the regular series and viewed them both in the NEAR wallet. + +Thank you so much for going through this journey with us! I wish you all the best and am eager to see what sorts of neat and unique use-cases you can come up with. If you have any questions, feel free to ask on our [Discord](https://near.chat) or any other social media channels we have. If you run into any issues or have feedback, feel free to use the `Feedback` button on the right. + +:::note Versioning for this article + +At the time of this writing, this example works with the following versions: + +- rustc: `1.77.1` +- near-cli-rs: `0.17.0` +- cargo-near `0.6.1` +- NFT standard: [NEP171](https://nomicon.io/Standards/Tokens/NonFungibleToken/Core), version `1.0.0` + +::: diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js new file mode 100644 index 0000000..a7d60b8 --- /dev/null +++ b/website/docusaurus.config.js @@ -0,0 +1,144 @@ +// @ts-check +// `@type` JSDoc annotations allow editor autocompletion and type checking +// (when paired with `@ts-check`). +// There are various equivalent ways to declare your Docusaurus config. +// See: https://docusaurus.io/docs/api/docusaurus-config + +import {themes as prismThemes} from 'prism-react-renderer'; + +// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) + +/** @type {import('@docusaurus/types').Config} */ +const config = { + title: 'NFT Tutorial', + tagline: 'NEAR Protocol', + favicon: 'img/favicon.ico', + + // Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future + future: { + v4: true, // Improve compatibility with the upcoming Docusaurus v4 + }, + + // Set the production url of your site here + url: 'https://near-examples.github.io', + // Set the // pathname under which your site is served + // For GitHub pages deployment, it is often '//' + baseUrl: '/nft-tutorial/', + + // GitHub pages deployment config. + // If you aren't using GitHub pages, you don't need these. + organizationName: 'near-examples', // Usually your GitHub org/user name. + projectName: 'nft-tutorial', // Usually your repo name. + + onBrokenLinks: 'throw', + + // Even if you don't use internationalization, you can use this field to set + // useful metadata like html lang. For example, if your site is Chinese, you + // may want to replace "en" with "zh-Hans". + i18n: { + defaultLocale: 'en', + locales: ['en'], + }, + + presets: [ + [ + 'classic', + /** @type {import('@docusaurus/preset-classic').Options} */ + ({ + docs: { + sidebarPath: './sidebars.js', + routeBasePath: "/", + // Please change this to your repo. + // Remove this to remove the "edit this page" links. + editUrl: + 'https://github.com/near-examples/nft-tutorial/tree/main/website/docs/', + }, + theme: { + customCss: './src/css/custom.css', + }, + }), + ], + ], + + themeConfig: + /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ + ({ + // Replace with your project's social card + colorMode: { + respectPrefersColorScheme: true, + }, + navbar: { + title: 'FT Tutorial', + logo: { + alt: 'NEAR Protocol', + src: "img/near_logo.svg", + srcDark: "img/near_logo_white.svg", + }, + items: [ + { + type: 'docSidebar', + sidebarId: 'tutorialSidebar', + position: 'left', + label: 'Tutorial', + }, + {href: 'https://docs.near.org', label: 'NEAR Docs', position: 'left'}, + { + href: 'https://github.com/near/docs', + label: 'GitHub', + position: 'right', + }, + ], + }, + footer: { + style: 'dark', + links: [ + { + title: 'Docs', + items: [ + { + label: 'Tutorial', + to: '/', + }, + ], + }, + { + title: 'Community', + items: [ + { + label: 'Telegram', + href: 'https://t.me/neardev', + }, + { + label: 'Discord', + href: 'http://near.chat/', + }, + { + label: 'X', + href: 'https://x.com/nearprotocol', + }, + ], + }, + { + title: 'More', + items: [ + { + label: 'NEAR Docs', + href: 'https://docs.near.org', + }, + { + label: 'GitHub', + href: 'https://github.com/near/docs', + }, + ], + }, + ], + copyright: `Copyright © ${new Date().getFullYear()} NEAR Protocol. Built with Docusaurus.`, + }, + prism: { + theme: prismThemes.github, + darkTheme: prismThemes.dracula, + }, + }), +}; + +export default config; diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..ee7bd25 --- /dev/null +++ b/website/package.json @@ -0,0 +1,44 @@ +{ + "name": "my-website", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "start": "docusaurus start", + "build": "docusaurus build", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids" + }, + "dependencies": { + "@docusaurus/core": "3.9.2", + "@docusaurus/preset-classic": "3.9.2", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "prism-react-renderer": "^2.3.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "3.9.2", + "@docusaurus/types": "3.9.2" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + }, + "engines": { + "node": ">=20.0" + } +} diff --git a/website/sidebars.js b/website/sidebars.js new file mode 100644 index 0000000..f77355c --- /dev/null +++ b/website/sidebars.js @@ -0,0 +1,35 @@ +// @ts-check + +// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) + +/** + * Creating a sidebar enables you to: + - create an ordered group of docs + - render a sidebar for each doc of that group + - provide next/previous navigation + + The sidebars can be generated from the filesystem, or explicitly defined here. + + Create as many sidebars as you want. + + @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} + */ +const sidebars = { + // By default, Docusaurus generates a sidebar from the docs folder structure + tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], + + // But you can create a sidebar manually + /* + tutorialSidebar: [ + 'intro', + 'hello', + { + type: 'category', + label: 'Tutorial', + items: ['tutorial-basics/create-a-document'], + }, + ], + */ +}; + +export default sidebars; diff --git a/website/src/components/HomepageFeatures/index.js b/website/src/components/HomepageFeatures/index.js new file mode 100644 index 0000000..acc7621 --- /dev/null +++ b/website/src/components/HomepageFeatures/index.js @@ -0,0 +1,64 @@ +import clsx from 'clsx'; +import Heading from '@theme/Heading'; +import styles from './styles.module.css'; + +const FeatureList = [ + { + title: 'Easy to Use', + Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, + description: ( + <> + Docusaurus was designed from the ground up to be easily installed and + used to get your website up and running quickly. + + ), + }, + { + title: 'Focus on What Matters', + Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, + description: ( + <> + Docusaurus lets you focus on your docs, and we'll do the chores. Go + ahead and move your docs into the docs directory. + + ), + }, + { + title: 'Powered by React', + Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, + description: ( + <> + Extend or customize your website layout by reusing React. Docusaurus can + be extended while reusing the same header and footer. + + ), + }, +]; + +function Feature({Svg, title, description}) { + return ( +
+
+ +
+
+ {title} +

{description}

+
+
+ ); +} + +export default function HomepageFeatures() { + return ( +
+
+
+ {FeatureList.map((props, idx) => ( + + ))} +
+
+
+ ); +} diff --git a/website/src/components/HomepageFeatures/styles.module.css b/website/src/components/HomepageFeatures/styles.module.css new file mode 100644 index 0000000..b248eb2 --- /dev/null +++ b/website/src/components/HomepageFeatures/styles.module.css @@ -0,0 +1,11 @@ +.features { + display: flex; + align-items: center; + padding: 2rem 0; + width: 100%; +} + +.featureSvg { + height: 200px; + width: 200px; +} diff --git a/website/src/components/UI/Accordion/Accordion.module.scss b/website/src/components/UI/Accordion/Accordion.module.scss new file mode 100644 index 0000000..0621e8e --- /dev/null +++ b/website/src/components/UI/Accordion/Accordion.module.scss @@ -0,0 +1,110 @@ +.accordion { + border-radius: var(--radius-card); + margin-bottom: var(--space-component-lg); + box-shadow: var(--shadow-card); + transition: var(--transition-all); + background-color: var(--color-bg-surface); + border: var(--border-card); + + &:hover { + border-color: var(--color-border-secondary); + box-shadow: var(--shadow-card-hover); + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-component-md) var(--space-component-lg); + cursor: pointer; + border-radius: var(--radius-card); + background-color: var(--color-bg-subtle); + transition: var(--transition-colors); + } + + &__title { + margin: 0 !important; + font-size: var(--text-base); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + flex: 1; + padding-right: var(--space-component-lg); + line-height: var(--leading-snug); + + &:hover { + color: var(--near-brand-primary); + } + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-transform); + color: var(--color-text-tertiary); + width: var(--size-icon-md); + height: var(--size-icon-md); + + &--open { + transform: rotate(180deg); + } + + svg { + width: 100%; + height: 100%; + } + } + + &__content { + overflow: hidden; + transition: max-height var(--transition-slow) ease-out; + } + + &__detail { + padding: var(--space-component-lg); + color: var(--color-text-secondary); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); + background-color: var(--color-bg-surface); + border-radius: 0 0 var(--radius-card) var(--radius-card); + + p { + margin-bottom: var(--space-component-sm); + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: var(--near-brand-primary); + text-decoration: none; + transition: var(--transition-colors); + + &:hover { + color: var(--near-brand-primary-600); + text-decoration: underline; + } + } + } +} + +@media (max-width: 768px) { + .accordion { + margin-bottom: var(--space-component-md); + + &__header { + padding: var(--space-component-sm) var(--space-component-md); + } + + &__title { + font-size: var(--text-sm); + padding-right: var(--space-component-md); + } + + &__detail { + padding: var(--space-component-md); + font-size: var(--text-xs); + } + } +} diff --git a/website/src/components/UI/Accordion/index.jsx b/website/src/components/UI/Accordion/index.jsx new file mode 100644 index 0000000..0be3806 --- /dev/null +++ b/website/src/components/UI/Accordion/index.jsx @@ -0,0 +1,57 @@ +import { useState, useRef, useEffect } from 'react'; +import styles from './Accordion.module.scss'; + +const Accordion = ({ title, detail }) => { + const [isOpen, setIsOpen] = useState(false); + const [maxHeight, setMaxHeight] = useState(0); + const contentRef = useRef(null); + + const toggleAccordion = () => { + setIsOpen(!isOpen); + }; + + useEffect(() => { + if (contentRef.current) { + // Use a small delay to ensure content is fully rendered + const updateHeight = () => { + if (contentRef.current) { + setMaxHeight(isOpen ? contentRef.current.scrollHeight : 0); + } + }; + + if (isOpen) { + // Initial update + updateHeight(); + // Update again after a short delay to catch any dynamic content + const timer = setTimeout(updateHeight, 100); + return () => clearTimeout(timer); + } else { + setMaxHeight(0); + } + } + }, [isOpen, detail]); + + return ( +
+
+

{title}

+
+ + + +
+
+
+
+ {detail} +
+
+
+ ); +}; + +export default Accordion; \ No newline at end of file diff --git a/website/src/components/UI/Button/Button.module.scss b/website/src/components/UI/Button/Button.module.scss new file mode 100644 index 0000000..a39617c --- /dev/null +++ b/website/src/components/UI/Button/Button.module.scss @@ -0,0 +1,260 @@ +.button { + // Base styles + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + + // Typography + font-family: var(--bs-body-font-family); + font-weight: var(--font-weight-medium); + line-height: var(--leading-tight); + text-decoration: none; + white-space: nowrap; + + // Layout + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + + // Transitions + transition: var(--transition-all); + + // Remove default button styles + appearance: none; + user-select: none; + + &:focus-visible { + outline: 2px solid var(--color-border-focus); + outline-offset: 2px; + } + + &:active:not(:disabled) { + transform: scale(0.98); + } + + &[target="_blank"]::after { + content: none; + } +} + +// ============================================================================ +// SIZE VARIANTS +// ============================================================================ + +.button--small { + padding: calc(var(--space-1) + 2px) var(--space-4); + font-size: var(--text-sm); + min-height: 32px; +} + +.button--medium { + padding: var(--space-2) var(--space-6); + font-size: var(--text-base); + min-height: 40px; +} + +.button--large { + padding: var(--space-component-md) var(--space-8); + font-size: var(--text-lg); + min-height: 48px; +} + +// ============================================================================ +// COLOR VARIANTS +// ============================================================================ + +// Primary Button +.button--primary { + background: var(--near-brand-primary); + color: var(--color-text-inverse)!important; + border-color: var(--near-brand-primary); + + &:hover:not(:disabled) { + background: var(--near-brand-primary-600); + border-color: var(--near-brand-primary-600); + box-shadow: var(--shadow-card); + } + + &:active:not(:disabled) { + background: var(--near-brand-primary-600); + } +} + +// Secondary Button +.button--secondary { + background: var(--color-bg-surface); + color: var(--color-text-primary); + border-color: var(--color-border-primary); + + &:hover:not(:disabled) { + background: var(--color-bg-subtle); + border-color: var(--color-border-secondary); + box-shadow: var(--shadow-card); + } + + &:active:not(:disabled) { + background: var(--color-bg-subtle); + } +} + +// Outline Button +.button--outline { + background: transparent; + color: var(--near-brand-primary); + border-color: var(--near-brand-primary); + + &:hover:not(:disabled) { + background: var(--color-bg-subtle); + border-color: var(--near-brand-primary-600); + } + + &:active:not(:disabled) { + background: var(--near-brand-primary-100); + } +} + +// Ghost Button +.button--ghost { + background: transparent; + color: var(--color-text-primary); + border-color: transparent; + + &:hover:not(:disabled) { + background: var(--color-bg-subtle); + color: var(--near-brand-primary); + } + + &:active:not(:disabled) { + background: var(--color-bg-subtle); + } +} + +// Danger Button +.button--danger { + background: var(--color-danger); + color: var(--color-text-inverse); + border-color: var(--color-danger); + + &:hover:not(:disabled) { + background: var(--color-danger-hover); + border-color: var(--color-danger-hover); + box-shadow: var(--shadow-card); + } + + &:active:not(:disabled) { + background: var(--color-danger-active); + } +} + +// ============================================================================ +// STATE VARIANTS +// ============================================================================ + +// Full Width +.button--fullWidth { + width: 100%; +} + +// Disabled State +.button--disabled, +.button:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +// Loading State +.button--loading { + position: relative; + pointer-events: none; + + .button__text, + .button__icon { + opacity: 0; + } +} + +// ============================================================================ +// BUTTON ELEMENTS +// ============================================================================ + +.button__text { + display: inline-block; +} + +.button__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg { + width: 1.25em; + height: 1.25em; + } +} + +.button__spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; +} + +// ============================================================================ +// LOADING SPINNER +// ============================================================================ + +.spinner { + width: 1.25em; + height: 1.25em; + animation: spin 1s linear infinite; +} + +.spinnerCircle { + stroke-dasharray: 60; + stroke-dashoffset: 20; + stroke-linecap: round; + animation: spinnerDash 1.5s ease-in-out infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes spinnerDash { + 0% { + stroke-dasharray: 1, 150; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -35; + } + 100% { + stroke-dasharray: 90, 150; + stroke-dashoffset: -124; + } +} + +// ============================================================================ +// RESPONSIVE ADJUSTMENTS +// ============================================================================ + +@media (max-width: 768px) { + .button { + gap: var(--space-1); + } + + .button--large { + padding: var(--space-2) var(--space-6); + font-size: var(--text-base); + min-height: 44px; + } +} diff --git a/website/src/components/UI/Button/index.jsx b/website/src/components/UI/Button/index.jsx new file mode 100644 index 0000000..5e5bd09 --- /dev/null +++ b/website/src/components/UI/Button/index.jsx @@ -0,0 +1,102 @@ +import styles from './Button.module.scss'; + +const Button = ({ + children, + variant = 'primary', // 'primary', 'secondary', 'outline', 'ghost', 'danger' + size = 'medium', // 'small', 'medium', 'large' + href, + target, + onClick, + disabled = false, + fullWidth = false, + loading = false, + leftIcon, + rightIcon, + className = '', + type = 'button', + ...props +}) => { + // Determine if button should be a link + const Component = href && !disabled ? 'a' : 'button'; + + // Build CSS classes + const buttonClasses = [ + styles.button, + styles[`button--${variant}`], + styles[`button--${size}`], + fullWidth ? styles['button--fullWidth'] : '', + loading ? styles['button--loading'] : '', + disabled ? styles['button--disabled'] : '', + className + ].filter(Boolean).join(' '); + + // Button content + const buttonContent = ( + <> + {loading && ( + + + + + + )} + + {!loading && leftIcon && ( + + {leftIcon} + + )} + + + {children} + + + {!loading && rightIcon && ( + + {rightIcon} + + )} + + ); + + // Render link + if (href && !disabled) { + return ( + + {buttonContent} + + ); + } + + // Render button + return ( + + {buttonContent} + + ); +}; + +export default Button; diff --git a/website/src/components/UI/Card/Card.module.scss b/website/src/components/UI/Card/Card.module.scss new file mode 100644 index 0000000..5bfd934 --- /dev/null +++ b/website/src/components/UI/Card/Card.module.scss @@ -0,0 +1,382 @@ +.card { + padding: var(--space-1); + border-radius: var(--radius-card); + border: var(--border-card); + background: var(--color-bg-surface); + box-shadow: var(--shadow-card); + transition: var(--transition-all); + overflow: visible; + height: 100%; + display: flex; + flex-direction: column; + text-decoration: none; + color: inherit; + + // Clickable state + &--clickable { + cursor: pointer; + + &:focus { + outline: 2px solid var(--color-border-focus); + outline-offset: 2px; + } + + &:active { + box-shadow: var(--shadow-card); + } + + &:hover { + box-shadow: var(--shadow-card-hover); + transform: translateY(-2px); + } + } + + // Icon variant + &--icon { + position: relative; + padding-top: var(--space-10); + margin-top: var(--space-8); + + &:hover { + transform: translateY(calc(var(--space-2) * -1)); + box-shadow: var(--shadow-card-hover); + border-color: var(--color-border-secondary); + } + + &:hover .card__icon { + transform: scale(1.02); + border-color: var(--color-border-focus); + } + } + + ul a, + ol a { + display: inline-block; + border-radius: var(--radius-sm); + text-decoration: none; + color: var(--near-brand-primary); + font-weight: var(--font-weight-medium); + transition: var(--transition-all); + + &:hover { + transform: translateX(var(--space-1)); + text-decoration: none; + color: var(--near-brand-primary-600); + } + } + + // Image variant + &--image { + .card__body { + padding-top: var(--space-4); + } + } + + // Color variants + &--mint { + background: linear-gradient(135deg, #a7f3d0 0%, #6ee7b7 100%); + border: 1px solid rgba(16, 185, 129, 0.2); + color: #065f46; + + .card__title { + color: #065f46; + font-weight: var(--font-weight-bold); + } + + .card__description, + .card__content { + color: #047857; + } + + // &:hover { + // background: linear-gradient(135deg, #6ee7b7 0%, #34d399 100%); + // border-color: rgba(16, 185, 129, 0.3); + // } + + } + + // based on #DBEAFE + &--blue { + background: linear-gradient(135deg, #bfdbfe 0%, #93c5fd 100%); + border: 1px solid rgba(59, 130, 246, 0.2); + + .card__title { + font-weight: var(--font-weight-bold); + } + } + + &--purple { + background: linear-gradient(135deg, #c4b5fd 0%, #a78bfa 100%); + border: 1px solid rgba(139, 92, 246, 0.2); + color: #4c1d95; + + .card__title { + color: #4c1d95; + font-weight: var(--font-weight-bold); + } + + .card__description, + .card__content { + color: #5b21b6; + } + + // &:hover { + // background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%); + // border-color: rgba(139, 92, 246, 0.3); + // } + + } + + &--orange { + background: linear-gradient(135deg, #fed7aa 0%, #fdba74 100%); + border: 1px solid rgba(249, 115, 22, 0.2); + color: #9a3412; + + .card__title { + color: #9a3412; + font-weight: var(--font-weight-bold); + } + + .card__description, + .card__content { + color: #c2410c; + } + + // &:hover { + // background: linear-gradient(135deg, #fdba74 0%, #fb923c 100%); + // border-color: rgba(249, 115, 22, 0.3); + // } + + // Buttons and interactive elements inside orange cards + button { + background-color: rgba(255, 255, 255, 0.15); + color: inherit; + border: none; + transition: var(--transition-all); + + &:hover { + background-color: rgba(255, 255, 255, 0.25); + } + } + } + + // Common styles for buttons in colored cards + &--mint, + &--purple, + &--orange { + button { + background-color: rgba(255, 255, 255, 0.15); + color: inherit; + border: none; + transition: var(--transition-all); + cursor: pointer; + + &:hover { + background-color: rgba(255, 255, 255, 0.25); + } + } + + h4 { + margin: 0 0 var(--space-2) 0; + font-size: var(--text-sm); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.8; + } + } + + // Icon element + &__icon { + position: absolute; + top: calc(-1 * var(--space-8)); + left: var(--space-6); + width: var(--space-16); + height: var(--space-16); + background: var(--color-bg-primary); + border: var(--border-2) var(--border-solid) var(--color-border-primary); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-all); + z-index: 10; + overflow: visible; + } + + &__iconSvg { + width: var(--space-10); + height: var(--space-10); + transition: var(--transition-all); + color: var(--near-brand-primary); + + svg { + width: 100%; + height: 100%; + } + } + + // Image element + &__image { + position: relative; + overflow: hidden; + border: 0; + height: var(--space-50); + background: linear-gradient(135deg, var(--near-brand-primary-100) 0%, var(--near-brand-primary-200) 100%); + border-radius: var(--radius-card) var(--radius-card) 0 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: var(--transition-transform); + } + + &:hover img { + transform: scale(1.05); + } + } + + // Body element + &__body { + padding: var(--space-2) var(--space-6); + display: flex; + flex-direction: column; + color: var(--color-text-primary); + flex-grow: 1; + } + + // Title element + &__title { + margin: 0 0 var(--space-2) 0; + color: var(--color-text-primary); + font-size: var(--text-xl); + font-weight: var(--font-weight-semibold); + line-height: var(--leading-tight); + } + + // Description element + &__description { + flex-grow: 1; + margin: 0 0 var(--space-4) 0; + color: var(--color-text-secondary); + line-height: var(--leading-relaxed); + font-size: var(--text-base); + } + + // Content element + &__content { + color: var(--color-text-secondary); + line-height: var(--leading-relaxed); + flex-grow: 1; + + p { + margin-bottom: var(--space-component-sm); + + &:last-child { + margin-bottom: 0; + } + } + + // Links element + ul { + margin-top: auto; + padding: var(--space-2) 0; + list-style: none; + } + + li { + border-bottom: 1px solid rgba(0, 0, 0, 0.175); + + &:last-child { + border-bottom: none; + } + + a { + display: block; + padding: var(--space-2) 0; + color: var(--color-text-primary); + text-decoration: none; + font-size: 0.87rem; + transition: var(--transition-all); + + &:hover { + color: var(--near-brand-primary); + transform: translateX(var(--space-2)); + } + } + } + } +} + +// Dark theme variations +[data-theme='dark'] .card { + box-shadow: var(--shadow-card); + + &--clickable:hover { + box-shadow: var(--shadow-card-hover); + } + + &__icon { + background: var(--color-bg-surface); + border-color: var(--color-border-primary); + } + + &--icon:hover .card__icon { + border-color: var(--color-border-focus); + } + + li { + border-bottom-color: rgba(255, 255, 255, 0.175); + + a { + color: var(--color-text-primary); + + &:hover { + color: var(--near-brand-primary); + } + } + } +} + +// Mobile responsive adjustments +@media (max-width: 768px) { + .card { + margin-bottom: var(--space-component-md); + + &__body { + padding: var(--space-component-sm) var(--space-component-md); + } + + &__title { + font-size: var(--text-lg); + margin-bottom: var(--space-component-sm); + } + + &__description, + &__content { + font-size: var(--text-sm); + } + + &--icon { + padding-top: var(--space-8); + margin-top: var(--space-6); + } + + &__icon { + top: calc(-1 * var(--space-6)); + left: var(--space-4); + width: var(--space-12); + height: var(--space-12); + } + + &__iconSvg { + width: var(--space-8); + height: var(--space-8); + } + + &__image { + height: var(--space-40); + } + } +} \ No newline at end of file diff --git a/website/src/components/UI/Card/index.jsx b/website/src/components/UI/Card/index.jsx new file mode 100644 index 0000000..6031c24 --- /dev/null +++ b/website/src/components/UI/Card/index.jsx @@ -0,0 +1,111 @@ +import styles from './Card.module.scss'; + +const Card = ({ + children, + title, + description, + image, + icon, + href, + target, + onClick, + variant = 'default', // 'default', 'icon', 'image' + color = 'default', // 'default', 'mint', 'purple', 'orange' + className = '', + links, + ...props +}) => { + // Determine if card should be clickable + const isClickable = href || onClick; + + // Build CSS classes + const cardClasses = [ + styles.card, + variant === 'icon' ? styles['card--icon'] : '', + variant === 'image' ? styles['card--image'] : '', + color === 'mint' ? styles['card--mint'] : '', + color === 'purple' ? styles['card--purple'] : '', + color === 'orange' ? styles['card--orange'] : '', + color === 'blue' ? styles['card--blue'] : '', + isClickable ? styles['card--clickable'] : '', + className + ].filter(Boolean).join(' '); + + // Card content + const cardContent = ( + <> + {/* Icon for icon variant */} + {variant === 'icon' && icon && ( +
+
+ {icon} +
+
+ )} + + {/* Image for image variant */} + {variant === 'image' && image && ( +
+ {typeof image === 'string' ? ( + {title + ) : ( + image + )} +
+ )} + + {/* Card body */} +
+ {title && ( +

{title}

+ )} + + {description && ( +

{description}

+ )} + + {children && ( +
+ {children} +
+ )} +
+ + ); + + // Render appropriate component + if (href) { + return ( + + {cardContent} + + ); + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick && onClick(e); + } + } : undefined} + {...props} + > + {cardContent} +
+ ); +}; + +export default Card; diff --git a/website/src/components/UI/CarouselNft/Carousel.module.scss b/website/src/components/UI/CarouselNft/Carousel.module.scss new file mode 100644 index 0000000..34c7fd3 --- /dev/null +++ b/website/src/components/UI/CarouselNft/Carousel.module.scss @@ -0,0 +1,138 @@ +// Carousel component styles using Bootstrap and Sass +.carouselContainer { + display: flex; + scroll-behavior: smooth; + white-space: nowrap; + padding: 18px 12px 12px 12px; + gap: 12px; + scrollbar-width: thin; + + &::-webkit-scrollbar { + height: 8px; + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.3); + border-radius: 4px; + } + + &::-webkit-scrollbar-track { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } +} + +.imgCard { + flex: 0 0 auto; + cursor: pointer; + border-radius: 50%; + border: none; + transform: scale(1); + transition: transform 0.3s ease, border 0.3s ease; + position: relative; + + &:hover { + transform: scale(1.05); + } + + &.selected { + border: solid 2px #604cc8CC; + transform: scale(1.1) translateY(-10px); + } + + // Tooltip styles for hover title + &[data-title]:hover::after { + content: attr(data-title); + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 8px; + background-color: rgba(0, 0, 0, 0.9); + color: white; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + z-index: 1000; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + animation: tooltipFadeIn 0.2s ease-out; + } + + // Small arrow pointing up to the image + &[data-title]:hover::before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 2px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid rgba(0, 0, 0, 0.9); + z-index: 1001; + pointer-events: none; + animation: tooltipFadeIn 0.2s ease-out; + } +} + +// Smooth fade-in animation for tooltip +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(-4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +// Dark theme support +[data-theme='dark'] { + .imgCard[data-title]:hover::after { + background-color: rgba(255, 255, 255, 0.95); + color: #1a1a1a; + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2); + } + + .imgCard[data-title]:hover::before { + border-bottom-color: rgba(255, 255, 255, 0.95); + } +} + +// Responsive design +@media (max-width: 768px) { + .carouselContainer { + padding: 12px 8px 35px 8px; // Adjusted for mobile + gap: 8px; + } + + .imgCard { + &.selected { + transform: scale(1.05) translateY(-5px); + } + + // Adjust tooltip for mobile + &[data-title]:hover::after { + font-size: 11px; + padding: 6px 10px; + max-width: 150px; + margin-top: 6px; + } + + &[data-title]:hover::before { + margin-top: 1px; + border-left-width: 5px; + border-right-width: 5px; + border-bottom-width: 5px; + } + } +} \ No newline at end of file diff --git a/website/src/components/UI/CarouselNft/index.jsx b/website/src/components/UI/CarouselNft/index.jsx new file mode 100644 index 0000000..a30216b --- /dev/null +++ b/website/src/components/UI/CarouselNft/index.jsx @@ -0,0 +1,34 @@ +import { ImgNft } from '../ImgNft'; +import styles from './Carousel.module.scss'; + +const empty = (nft) => { + console.log(nft); +}; + +const Carousel = ({ nfts, onSelect = empty, nftSelected }) => { + return ( +
+ {nfts.map((nft) => ( +
onSelect(nft)} + data-title={nft?.metadata?.title} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(nft); + } + }} + aria-label={`Select NFT: ${nft?.metadata?.title || 'Untitled'}`} + > + +
+ ))} +
+ ); +}; + +export default Carousel; \ No newline at end of file diff --git a/website/src/components/UI/Codetabs/index.js b/website/src/components/UI/Codetabs/index.js new file mode 100644 index 0000000..c5795e0 --- /dev/null +++ b/website/src/components/UI/Codetabs/index.js @@ -0,0 +1,71 @@ +import React from 'react'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import GitHubInternal from '../../github'; + +const lang2label = { + rust: '🦀 Rust', + js: '🌐 Javascript', + ts: '🌐 Typescript', +}; + +export function CodeTabs({ children }) { + if (!Array.isArray(children)) { + children = [children]; + } + + return ( + + {children.map((component, index) => { + return ( + + {component} + + ); + })} + + ); +} + +export function Language({ children, language, showSingleFName }) { + if (!Array.isArray(children)) { + children = [children]; + } + + children = children.map((component) => change_language_to(component, language)); + + if (children.length == 1 && !showSingleFName) { + return ( + + {children[0]} + + ); + } else { + return ( + + {children.map((component, index) => { + return ( + + {component} + + ); + })} + + ); + } +} + +export function Github({ url, start, end, language, fname, metastring, withSourceLink }) { + return GitHubInternal({ url, start, end, language, fname, metastring, withSourceLink }); +} + +/* AUX function */ +function change_language_to(component, language) { + const { children, url, start, end, fname } = component.props; + + if (component.type === Github) { + return Github({ url, start, end, language, fname }); + } + + return component; +} diff --git a/website/src/components/UI/ImgNft/RoundedImage.jsx b/website/src/components/UI/ImgNft/RoundedImage.jsx new file mode 100644 index 0000000..4c336e3 --- /dev/null +++ b/website/src/components/UI/ImgNft/RoundedImage.jsx @@ -0,0 +1,22 @@ +import { useCallback, useEffect, useState } from 'react'; + +export const DEFAULT_IMAGE = + 'https://ipfs.near.social/ipfs/bafkreibmiy4ozblcgv3fm3gc6q62s55em33vconbavfd2ekkuliznaq3zm'; + +const RoundedImage = ({ src, alt }) => { + const [imageUrl, setImageUrl] = useState(src); + + useEffect(() => { + setImageUrl(src); + }, [src]); + + const handleError = useCallback(() => { + setImageUrl(DEFAULT_IMAGE); + }, []); + + return {alt}; +}; + +export default RoundedImage; \ No newline at end of file diff --git a/website/src/components/UI/ImgNft/index.jsx b/website/src/components/UI/ImgNft/index.jsx new file mode 100644 index 0000000..f6a5890 --- /dev/null +++ b/website/src/components/UI/ImgNft/index.jsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +import RoundedImage from './RoundedImage'; + +export const ImgNft = ({ nft }) => { + // const { wallet } = useWalletSelector(); + const [imageUrl, setImageUrl] = useState(''); + + useEffect(() => { + const fetchNftData = async () => { + if (!nft || !nft.token_id) return; + + const tokenMedia = nft.metadata?.media || ''; + + if (tokenMedia.startsWith('https://') || tokenMedia.startsWith('http://')) { + setImageUrl(tokenMedia); + } else if (tokenMedia.startsWith('data:image')) { + setImageUrl(tokenMedia); + } else if (nft.metadata?.base_uri) { + setImageUrl(`${nft.metadata.base_uri}/${tokenMedia}`); + } else if (tokenMedia.startsWith('Qm') || tokenMedia.startsWith('ba')) { + setImageUrl(`https://ipfs.near.social/ipfs/${tokenMedia}`); + } + }; + + fetchNftData(); + }, [nft, imageUrl]); + + return ; +}; + +export const NftImage = ImgNft; diff --git a/website/src/components/UI/Input/Input.module.scss b/website/src/components/UI/Input/Input.module.scss new file mode 100644 index 0000000..67c70d8 --- /dev/null +++ b/website/src/components/UI/Input/Input.module.scss @@ -0,0 +1,230 @@ +// ============================================================================ +// INPUT COMPONENT +// Uses CSS variables from custom.scss +// ============================================================================ + +.inputWrapper { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.inputWrapper--fullWidth { + width: 100%; +} + +// ============================================================================ +// LABEL +// ============================================================================ + +.label { + display: flex; + align-items: center; + gap: var(--space-1); + font-weight: var(--font-weight-medium); + font-size: var(--text-sm); + color: var(--color-text-primary); +} + +.label__text { + display: inline-block; +} + +.label__required { + color: var(--color-danger); + font-size: var(--text-base); +} + +// ============================================================================ +// INPUT CONTAINER +// ============================================================================ + +.inputContainer { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +// ============================================================================ +// INPUT BASE STYLES +// ============================================================================ + +.input { + // Base styles + width: 100%; + display: block; + + // Typography + font-family: var(--bs-body-font-family); + font-weight: var(--font-weight-normal); + line-height: var(--leading-normal); + color: var(--color-text-primary); + + // Layout + border-radius: var(--radius-md); + border: 1px solid var(--color-border-primary); + background: var(--color-bg-surface); + + // Transitions + transition: var(--transition-all); + + // Remove default styles + appearance: none; + + &::placeholder { + color: var(--color-text-tertiary); + } + + &:hover:not(:disabled):not(:focus) { + border-color: var(--color-border-secondary); + } + + &:focus { + outline: none; + border-color: var(--near-brand-primary); + box-shadow: 0 0 0 3px var(--near-brand-primary-100); + } + + &:disabled { + background: var(--color-bg-subtle); + color: var(--color-text-tertiary); + cursor: not-allowed; + opacity: 0.6; + } + + &:read-only { + background: var(--color-bg-subtle); + cursor: default; + } +} + +// ============================================================================ +// SIZE VARIANTS +// ============================================================================ + +.input--small { + padding: calc(var(--space-1) + 2px) var(--space-3); + font-size: var(--text-sm); + min-height: 32px; +} + +.input--medium { + padding: var(--space-2) var(--space-4); + font-size: var(--text-base); + min-height: 40px; +} + +.input--large { + padding: var(--space-component-md) var(--space-4); + font-size: var(--text-lg); + min-height: 48px; +} + +// ============================================================================ +// STATE VARIANTS +// ============================================================================ + +// Error State +.input--error { + border-color: var(--color-danger); + + &:focus { + border-color: var(--color-danger); + box-shadow: 0 0 0 3px var(--color-danger-subtle, rgba(220, 38, 38, 0.1)); + } +} + +// Success State +.input--success { + border-color: var(--color-success); + + &:focus { + border-color: var(--color-success); + box-shadow: 0 0 0 3px var(--color-success-subtle, rgba(34, 197, 94, 0.1)); + } +} + +// Disabled State +.input--disabled { + opacity: 0.5; +} + +// ============================================================================ +// INPUT WITH ICONS +// ============================================================================ + +.input--withLeftIcon { + padding-left: var(--space-10); +} + +.input--withRightIcon { + padding-right: var(--space-10); +} + +.input__icon { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-tertiary); + pointer-events: none; + + &:first-child { + left: var(--space-3); + } + + &:last-child { + right: var(--space-3); + } + + svg { + width: 1.25em; + height: 1.25em; + } +} + +// ============================================================================ +// MESSAGES (ERROR, SUCCESS, HELPER) +// ============================================================================ + +.message { + display: flex; + align-items: flex-start; + gap: var(--space-1); + font-size: var(--text-sm); + line-height: var(--leading-tight); + min-height: 20px; +} + +.message--error { + color: var(--color-danger); +} + +.message--success { + color: var(--color-success); +} + +.message--helper { + color: var(--color-text-secondary); +} + +// ============================================================================ +// RESPONSIVE ADJUSTMENTS +// ============================================================================ + +@media (max-width: 768px) { + .input--large { + padding: var(--space-2) var(--space-4); + font-size: var(--text-base); + min-height: 44px; + } + + .label { + font-size: var(--text-xs); + } + + .message { + font-size: var(--text-xs); + } +} diff --git a/website/src/components/UI/Input/index.jsx b/website/src/components/UI/Input/index.jsx new file mode 100644 index 0000000..e016067 --- /dev/null +++ b/website/src/components/UI/Input/index.jsx @@ -0,0 +1,119 @@ +import { forwardRef } from 'react'; +import styles from './Input.module.scss'; + +const Input = forwardRef(({ + id, + name, + type = 'text', + value, + defaultValue, + placeholder, + label, + helperText, + error, + success, + required = false, + disabled = false, + readOnly = false, + size = 'medium', // 'small', 'medium', 'large' + fullWidth = false, + leftIcon, + rightIcon, + className = '', + labelClassName = '', + onChange, + onBlur, + onFocus, + ...props +}, ref) => { + // Build CSS classes + const wrapperClasses = [ + styles.inputWrapper, + fullWidth ? styles['inputWrapper--fullWidth'] : '', + ].filter(Boolean).join(' '); + + const inputClasses = [ + styles.input, + styles[`input--${size}`], + error ? styles['input--error'] : '', + success ? styles['input--success'] : '', + disabled ? styles['input--disabled'] : '', + leftIcon ? styles['input--withLeftIcon'] : '', + rightIcon ? styles['input--withRightIcon'] : '', + className + ].filter(Boolean).join(' '); + + const labelClasses = [ + styles.label, + labelClassName + ].filter(Boolean).join(' '); + + return ( +
+ {label && ( + + )} + +
+ {leftIcon && ( + + {leftIcon} + + )} + + + + {rightIcon && ( + + {rightIcon} + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {!error && success && ( +
+ {success} +
+ )} + + {!error && !success && helperText && ( +
+ {helperText} +
+ )} +
+ ); +}); + +Input.displayName = 'Input'; + +export default Input; diff --git a/website/src/components/github.js b/website/src/components/github.js new file mode 100644 index 0000000..7354cc1 --- /dev/null +++ b/website/src/components/github.js @@ -0,0 +1,102 @@ +import CodeBlock from '@theme/CodeBlock'; +import { useEffect, useState } from 'react'; + +function toRaw(ref) { + const fullUrl = ref.slice(ref.indexOf('https')); + const [url, loc] = fullUrl.split('#'); + const [org, repo, blob, branch, ...pathSeg] = new URL(url).pathname.split('/').slice(1); + return `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${pathSeg.join('/')}`; +} + +async function fetchCode(url, fromLine, toLine) { + let res; + + // check if stored in cache + const validUntil = localStorage.getItem(`${url}-until`); + + if (validUntil && validUntil > Date.now()) { + res = localStorage.getItem(url); + } else { + try { + res = await (await fetch(url)).text(); + localStorage.setItem(url, res); + localStorage.setItem(`${url}-until`, Date.now() + 60000); + } catch { + return 'Error fetching code, please try reloading'; + } + } + + let body = res.split('\n'); + fromLine = fromLine ? Number(fromLine) - 1 : 0; + toLine = toLine ? Number(toLine) : body.length; + body = body.slice(fromLine, toLine); + + // Remove indentation on nested code + const preceedingSpace = body.reduce((prev, line) => { + if (line.length === 0) return prev; + + const spaces = line.match(/^\s+/); + if (spaces) return Math.min(prev, spaces[0].length); + + return 0; + }, Infinity); + + return body.map((line) => line.slice(preceedingSpace)).join('\n'); +} + +export function GitHubInternal({ + url, + start, + end, + language, + fname, + metastring, + withSourceLink = true, + height="460px", +}) { + const [code, setCode] = useState('Loading...'); + + useEffect(() => { + const rawUrl = toRaw(url); + const promise = fetchCode(rawUrl, start, end); + promise.then((res) => setCode(res)); + }); + + // remove all # from url + url = url.split('#')[0]; + + // transform url to point to the specific lines + if (start && end) { + url = `${url}#L${start}-L${end}`; + } else if (start) { + url = `${url}#L${start}`; + } + + return ( +
+
+ + {code} + +
+ {withSourceLink && ( + + )} +
+ ); +} + +export default GitHubInternal; diff --git a/website/src/css/custom.css b/website/src/css/custom.css new file mode 100644 index 0000000..2bc6a4c --- /dev/null +++ b/website/src/css/custom.css @@ -0,0 +1,30 @@ +/** + * Any CSS included here will be global. The classic template + * bundles Infima by default. Infima is a CSS framework designed to + * work well for content-centric websites. + */ + +/* You can override the default Infima variables here. */ +:root { + --ifm-color-primary: #2e8555; + --ifm-color-primary-dark: #29784c; + --ifm-color-primary-darker: #277148; + --ifm-color-primary-darkest: #205d3b; + --ifm-color-primary-light: #33925d; + --ifm-color-primary-lighter: #359962; + --ifm-color-primary-lightest: #3cad6e; + --ifm-code-font-size: 95%; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); +} + +/* For readability concerns, you should choose a lighter palette in dark mode. */ +[data-theme='dark'] { + --ifm-color-primary: #25c2a0; + --ifm-color-primary-dark: #21af90; + --ifm-color-primary-darker: #1fa588; + --ifm-color-primary-darkest: #1a8870; + --ifm-color-primary-light: #29d5b0; + --ifm-color-primary-lighter: #32d8b4; + --ifm-color-primary-lightest: #4fddbf; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); +} diff --git a/website/src/pages/index.module.css b/website/src/pages/index.module.css new file mode 100644 index 0000000..9f71a5d --- /dev/null +++ b/website/src/pages/index.module.css @@ -0,0 +1,23 @@ +/** + * CSS files with the .module.css suffix will be treated as CSS modules + * and scoped locally. + */ + +.heroBanner { + padding: 4rem 0; + text-align: center; + position: relative; + overflow: hidden; +} + +@media screen and (max-width: 996px) { + .heroBanner { + padding: 2rem; + } +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/website/static/.nojekyll b/website/static/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/website/static/assets/docs/tutorials/nfts/empty-nft-in-wallet.png b/website/static/assets/docs/tutorials/nfts/empty-nft-in-wallet.png new file mode 100644 index 0000000..97f0135 Binary files /dev/null and b/website/static/assets/docs/tutorials/nfts/empty-nft-in-wallet.png differ diff --git a/website/static/assets/docs/tutorials/nfts/explorer-payout-series-owner.png b/website/static/assets/docs/tutorials/nfts/explorer-payout-series-owner.png new file mode 100644 index 0000000..c38f7ff Binary files /dev/null and b/website/static/assets/docs/tutorials/nfts/explorer-payout-series-owner.png differ diff --git a/website/static/assets/docs/tutorials/nfts/filled-nft-in-wallet.png b/website/static/assets/docs/tutorials/nfts/filled-nft-in-wallet.png new file mode 100644 index 0000000..bda3faf Binary files /dev/null and b/website/static/assets/docs/tutorials/nfts/filled-nft-in-wallet.png differ diff --git a/website/static/assets/docs/tutorials/nfts/nft-wallet-token.png b/website/static/assets/docs/tutorials/nfts/nft-wallet-token.png new file mode 100644 index 0000000..f55a278 Binary files /dev/null and b/website/static/assets/docs/tutorials/nfts/nft-wallet-token.png differ diff --git a/website/static/assets/docs/tutorials/nfts/nft-wallet.png b/website/static/assets/docs/tutorials/nfts/nft-wallet.png new file mode 100644 index 0000000..6053747 Binary files /dev/null and b/website/static/assets/docs/tutorials/nfts/nft-wallet.png differ diff --git a/website/static/assets/docs/tutorials/nfts/series-wallet-collectibles.png b/website/static/assets/docs/tutorials/nfts/series-wallet-collectibles.png new file mode 100644 index 0000000..8591c05 Binary files /dev/null and b/website/static/assets/docs/tutorials/nfts/series-wallet-collectibles.png differ diff --git a/website/static/img/docusaurus-social-card.jpg b/website/static/img/docusaurus-social-card.jpg new file mode 100644 index 0000000..ffcb448 Binary files /dev/null and b/website/static/img/docusaurus-social-card.jpg differ diff --git a/website/static/img/favicon.ico b/website/static/img/favicon.ico new file mode 100644 index 0000000..c3315c2 Binary files /dev/null and b/website/static/img/favicon.ico differ diff --git a/website/static/img/near_logo.svg b/website/static/img/near_logo.svg new file mode 100644 index 0000000..c68a7de --- /dev/null +++ b/website/static/img/near_logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/website/static/img/near_logo_white.svg b/website/static/img/near_logo_white.svg new file mode 100644 index 0000000..a2c2d42 --- /dev/null +++ b/website/static/img/near_logo_white.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + +