NFT Standard Discussion

Why?

We have a lot of questions floating around various groups and stakeholders about the NFT standard. I would like to align those discussions somewhere official, here on the forum, to establish the following things:

Goal: achieve soft consensus here on the interface and high level architecture. Take it to GH for implementation.

Here’s what we would love to see happen:

  • everyone bring their requirements
  • lay all ideas out on the table
  • look for patterns in the possible solutions
  • choose solution(s)

Here’s what we don’t want:

  • promoting a particular approach
  • not listening to others
  • trying to “own” the standard
  • starting my own standard

Discussion

1. Standard interface for NFTs

  • account ownership of tokens and transfer of ownership
  • approval of other accounts to transfer ownership
  • markets (stretch goal for this discussion)

2. Suggested architecture and reference implementation that tackles how to:

  • split up the interface across contracts
  • deal with storage
  • deal with UX
  • maximize for extensibility future use cases

NFT Basic Requirements

At the core NFT contracts have a unique account owning 1 or many unique token IDs. The ownership can be transferred from account to account. That’s it.

The original creator of the NFT has additional functionality e.g. mint, burn, etc… but that’s outside the scope of this discussion.

Metadata

NFTs may have some associated metadata per token e.g. a URL.

Enumeration (optional)

A user can retrieve an array of all their tokens from the contract.

Markets & Sales

NFTs have a popular function in secondary markets. If you trust a market you give it approval to transfer your token after the sale has been completed.

Market should handle the buying and selling of NFTs. This is also outside the scope of this discussion, but needs to be mentioned within the context of transfer approvals per token.

Note in NEAR the contract pays for how much data is stored, so a dynamically expanding and contracting list of approvals will affect the NEAR tokens locked for the NFT contract owner, which is not always the owner of an individual token ID.

Requirements (incomplete, please discuss)

  1. a token ID (token) can only be owned by one account at a time
  2. accounts can own many tokens
  3. accounts can transfer tokens that they own to other accounts (by passing ownership)
  4. accounts can approve many other accounts (markets) on a per token basis, to transfer their token
  5. one or more tokens can be associated with one or more metadata records

???

Interface (INCOMPLETE)

Data Structures

Token {
    owner_id: AccountId,
    approved_id: AccountId,
    metadata: String,
}

???

Fields

owner_id: AccountId,
tokens: UnorderedMap<TokenId, Token>,

???

Methods

nft_transfer(new_owner_id: AccountId, token_id: TokenId),
nft_transfer_call: (new_owner_id: AccountId, token_id: TokenId, memo: Option<String>),
on_nft_transfer: (sender_id: ValidAccountId, token_id: TokenId, memo: String)

???

Implementation Discussion (INCOMPLETE)

As I understand, from discussions with stakeholders, there are a few implementations in the wild and some ideas that need to be reconciled, or we will end up with a fairly fractured ecosystem.

Storage Issues

Storage is dynamic means for the NFT contract, when a user adds an approval for a marketplace, the contract’s NEAR tokens are locked up to reserve that storage. This means users must “pre-pay” storage by “registering”.

Unless the contract pays all storage for users, in A & B a user must “register” with each NFT contract and provide storage deposits for functionality that has “dynamic” storage (e.g. ever expanding list of approved markets). This also means a user must register with a contract when they are receiving NFTs from airdrops or as gifts.

A) Some implementations in the wild have the NFT contract and markets rolled into one monolithic contract
B) Many proposals make NFTs handle the approvals of the markets they are sold on, on a per token basis. Storage is dynamic.
C) A proposal where many common (from the same developer) NFTs share a single “approver” contract that manages the approval of many markets where the NFT can be sold. The user can grant permissions on a per token basis. Storage for NFTs is static. Storage for approver is dynamic.

A pseudo code example of C is here:

Summary

This is by no means incomplete. I am calling on the community for feedback, ideas and a healthy discussion to reach soft consensus. We must band together and quickly around the best (basic) standards so we don’t face composability issues down the road.

Rules

  • be kind, open, flexible and fair
  • critique ideas not people
  • ???
4 Likes

As we discussed before, the nft_transfer(new_owner_id: AccountId, token_id: TokenId) might need to have a field to enforce the previous owner if it’s called by escrow. Because the owner might have changed when after escrow issued a transaction, and the new owner can set the same escrow account again. This will allow the transfer to complete from the new owner account which is not desired behavior.
So whenever we’re dealing with any approval system, we have to make sure the escrow contract is acting on behalf of the specific owner.

Example of the problem:

  1. token1 is owned by alice
  2. alice sets approved_escrow_id to escrow
  3. alice list token on the market through escrow
  4. alice transfer token to bob (this resets approved_escrow_id to null)
  5. bob sets approved_escrow_id to escrow (they use same escrow contract)
  6. market sells token for alice through escrow, escrow calls token contract nft_transfer(new_owner_id: "charlie", token_id: "token1")
  7. this gets approved, because token1 has approved_escrow_id == "escrow"

Solution.

Introduce nft_transfer(new_owner_id: AccountId, token_id: TokenId, enforce_owner_id: Option<AccountId>), which will check that the owner is alice at the example item #6 and fail, so the transfer will not be completed.

2 Likes

Also we prefer to use GH discussions for NEP proposals, since it allows to link code and other github issues better. E.g. Account Storage Standard (formerly Account Registration) · Discussion #145 · near/NEPs · GitHub

1 Like

As a note, Bowen has looked at how a couple large projects do their proposal workflow and he’s advocating that we start with the free-form discussions on this site. This is something we’re planning to address more seriously in about a month.

1 Like

Here’s a good reference to keep, Thor made this which lines up some ERC-721 core and extensions (like metadata) and what they’d look like in Rust.

2 Likes

And also a NFT draft in core-contracts that Eugene made. I believe we’ll want to build a library similar to how we did with fungible tokens, but a good reference when reviewing current approaches. This uses the storage management standard.

2 Likes

I feel like it’s a very good idea to seriously consider moving away from the unique identifier as being an autoincrementing number. I’ve brought this up a few times, after first hearing the founder of Rarible discuss it on a podcast. As mentioned by Mykle Hansen from the Telegram group, the truly unique combination would be:

BlockchainIdentifier:ContractName:TokenType:TokenID

If we hash this value using SHA256 and take the first 20 bytes, we can keep the size predictable. In a perfect world, this is the only unique identifier needed, but as we enter a world where bridging is likely, we may want to keep the simple autoincrementing number as well, or as optional.

BlockchainIdentifier could use a little thought. I believe it would be where the NFT was originally created. (Not bridged.) If the NFT was created on a Layer 1, use the token symbol of that chain. (Ex. “NEAR”) If the NFT was created on a Layer 2 solution it would be the symbol of the Layer 1 followed by a dash, and the chain ID as cataloged in https://chainid.network. So for XDai it would be “ETH-100”

TokenType would be for contracts that use a multi-token approach like EIP-1155 shows here: EIP-1155: ERC-1155 Multi Token Standard. Perhaps TokenType is conditional based on if the contract uses the multi-token approach. If not, it can be omitted and instead hashes BlockchainIdentifier:ContractName:TokenID.

TokenID at the end is the autoincrementing large number that we’re familiar with. And again, for the sake of bridges and backwards compatibility with Ethereum’s ERC-721, perhaps store this as well or as optional.

In a few years when The Graph (or something similar) has indexed the various competing blockchains, having a cross-chain identifier will be tremendously useful and help avoid collisions.

Avoid this kind of scenario with the current setup where numbers are the unique identifiers… A musician starts using Ethereum and has audio NFTs. Uses a future version of the Rainbow Bridge to bring over 2-3 NFTs to NEAR. Realizes that minting on NEAR is his preferred choice, and mints several new NFTs that auto-increment a number. Another band member accidentally mints several NFTs on Ethereum and now there are competing token ID numbers to resolve.

1 Like

Not a fan of escrow. I’m pro keeping it simple and handling it just like Ethereum, add `function approve(address _approved, uint256 _tokenId) external payable;

Mintbase will be putting the burden of paying the storage of listing an item on the user listing it. An approver can only be added if an amount paid is put in. Adding a proxy for the markets adds a ton of complexity and not a direction we are heading.

We strongly believe a simple approved by token Id needs to be on the NFT near standard ASAP.

As it stands in NEP4 now, if a contract owner approves Mintbase to do this, we will be able to move all the tokens on the contract regardless if the owner of one NFT wants MIntbase to be able to transfer it whenever we please.

Looking at your approved_escrow_id on the token struct
You still need to add storage to the contract when the token approved_escrow_id gets added right? It’s the same as adding to a hashmap and removing it after transfer or purchase.

or is the idea to mint it with a fake string to fill the storage? (Got it @mikedotexe filled me in on the 64-character account name)

Also, can we list on multiple markets at a time with this setup?

Great question. Not a big fan of calling this “escrow” anymore. Instead think of this extra contract as the “approvals / approver” contract, not sure on the naming here. But it is NOT an escrow because the NFT isn’t transferred to it, ever.

The idea is each token have 1 contract that manages approvals. The approvals contract can be used by many NFT contracts (Mintbase or otherwise) This can be set at time of minting e.g. all Mintbase contracts set the MintbaseApprover contract when minting tokens.

If this architecture is used then the storage for the NFT can be static and so each NFT contract will not allow owners to arbitrarily increase size of storage rent by approving multiple markets for multiple tokens.

The users only have to register for dynamic storage with 1 escrow contract to handle multiple NFTs they might own from multiple contracts (e.g. all the Mintbase contracts).

And yes you can list any tokens on multiple markets with this approach, but the approvals are all managed by the “approver” contract.

Will use GH for implementation, but we need proper documentation of the “high level” discussion, which is currently being lost in TG Chats and Calls.

There’s various ways to encode those fields, of course. I’ve been looking at a few other efforts including CAIP-19 by this group called CASA. Their standard is a little vague about TokenType, but they do add another field to describe the contract standard (“nep4”, “erc721”, etc) which is helpful. Rewriting your example in their syntax would give something like:

BlockchainId:Standard/ContractName:TokenId

or (maybe)

BlockchainId:Standard/ContractName:TokenID;TokenType

(Actually they haven’t defined how to split TokenType and TokenId, or I can’t find where they did. The semicolon is just an example. Anything other than slash or colon or reserved chars would work.)

The difference is slight, but starts to improve legibility when fields are left blank – which you can do when there’s an obvious default value for that field. For instance, a contract could leave out the BlockchainID field when referring to other contracts on the same blockchain; that’s probably the 99% case anyway. And if a token contract only knowns one type of token, TokenType has no meaning there. If you can assume defaults for Blockchain and TokenType, you get a chain-agnostic ID that looks like:

standard/contractID:tokenID

Or for instance:

nep4/contract.near:340289309

That ends up needing only slightly more storage than the non-universal version, but can be resolved universally. And the positions of the sigils make it clear what’s there & what’s missing, whereas if you did the same thing with just colons, it would be vague how to resolve it.

1 Like

It looks like forum doesn’t support proper threading. So it’s not as good for discussion as GH discussions.

1 Like

Over the weekend I was spitballing with Nate and want to clarify something. The terms “escrow” and “approvals” are, in my mind, synonymous, but I believe in this discussion “escrow” is used to refer to the separate contract keeping track of approvals. That’s okay and I can work with that. I think this is the core item we’re discussing: to separate or to not separate the approvals.

We can separate these concerns but not enforce that they live in separate contracts, letting developers do as they prefer. So you can put both standards in the same contract if you want, the same way that a fungible token has the “core” and “metadata” standards in one contract. The two standards:

  1. NFT Core standard — only the basic mapping between owners and tokens, and the necessary token info, including nft_transfer and nft_transfer_call. (May or may not use Storage Management standard)
  2. Approval standard — this uses the familiar approve per Token ID (which is critical and missing from NEP4) and would include the Storage Management standard.

NFT Core standard only

pub struct Token {
    pub owner_id: AccountId,
    pub metadata: String,
}

pub struct Core {
    /// TokenID » Token object
    pub tokens: LookupMap<TokenId, Token>,
    …
}

NFT Core + Approval standards (same contract)

pub struct Token {
  pub owner_id: AccountId,
  pub approver_id: AccountId, // Always env::current_account_id()
  pub metadata: String,
  pub approvals: Vec<AccountId>, // Can add this field here or elsewhere
}

impl Contract {
  pub fn get_approvals(&self, token_id: String) -> Vec<AccountId> {}
  …
}

NFT Core + Approval standards (separate contracts)

NFT Core contract

pub struct Token {
  pub owner_id: AccountId,
  pub approver_id: AccountId, // Points to another contract
  pub metadata: String,
}

Approvals contract

// NFT Contract + ID. Example: "volcano-art:19"
pub type ContractTokenId = String;

pub struct Approvals {
  pub approval_map: LookupMap<ContractTokenId, UnorderedSet<AccountId>>,
}

Let’s remember that standards are primarily defining the methods. What parameters they take, what functionality they do, when they should fail, what they should return. As long as the required functions on the standard are behaving in a way consistent with the spec, we can have both the options outlined above.

3 Likes

From meeting 09-03-2021

Requirements

  1. TokenID
    1. Incrementing number? String? CAIPs?
    1. What is returned from a view method? Can it meet a universal standard?
    1. chainID:typeID:contractID:tokenID ???
  1. Multiple approvals by token ID
  2. NFT without approvals should be able to be simple transferred to UserX without UserX having to pre-register
  3. Contract must charge for storage per approvals
    1. 1 yocto NEAR anyway to redirect and require FAK (some wallet) to add approval
    1. Contract manages approval storage themselves
  1. Recommendation: use storage manager and refund storage for approvals
    1. Won’t break interface if not followed
    1. Risk of not following is Wallets won’t be able to show accurate storage user has locked per contract if not using standard storage manager (Potentially can show something but will be a bit awkward)
    1. Not refunding will be viewed as a “fee” for approvals
  1. DO NOT want approvals contract as “middleman” for approvals/storage management
    1. Increased complexity of standard
  1. Must enforce owner during transfer (e.g. ERC721 transfer_from provides address)
    1. Risk: can send 2 transfers simul.
    1. One is approved but transfers are not removed
    1. Second transfer is approved
    1. We’ll add another field to enforce the from owner that the marketplace thinks it’s transferring from so we don’t get a race condition / second transfer
  1. Bridging NFTs will require a lockup step (need for escrow contract anyway?)
    1. Bridge factory?
  1. Contract Level Metadata
    1. Format?
2 Likes