Improving customizable NEAR Contract Standards

Currently the way NEAR Contract Standards work aren’t as great as they could be. Some thoughts have been noted here:

We want developers to have:

  • Low boilerplate
  • The ability to use Rust traits that are in line with the standards at https://nomicon.io
  • The ability to use tested and trusted implementations of the trait functions

Currently we have a situation where the FT and NFT example have low boilerplate, but make assumptions that are causing issues.

The main problems I’ve identified for developers:

  • Folks want to customize the implementations of certain methods
  • Mint and burn functions are not in the standards
  • Projects want to “batch” logic such as minting and transfers, and indexers can’t pick these up in a conventional way.

At the time of this writing, we’ll focus on the first bullet point.

Customize implementations

How it was

A crate in near-sdk-rs exists call near-contract-standards that has the traits and the implementations of the functions on the traits specified by the standard.

After a lot of discussion, we landed on a somewhat useful implementation that would allow people to customize certain methods, while most of the logic is “taken care of” by the implementation from near-contract-standards.

NFT example:

The lib.rs file is fairly lightweight in the sense that not every function defined by the NFT standard needs to be implemented in this file. There is a function that isn’t in the standard (yet) for minting. We kept this out as ERC-721 also doesn’t define it, but now we are realizing it’s very important for indexers to know what’s going on, and generally to have a convention around this. But that aside, we’re focus on the first bullet point from the list earlier and will move past that issue in this post.

Here’s that function in the current, canonical version of the NFT standard:

Note that it calls a mint method that’s contained inside the near-contract-standards. If a person wants to modify the functionality of the mint function, they could do so by simply removing the line:

self.tokens.mint(token_id, receiver_id, Some(token_metadata))

and replacing it with custom code. For instance, this snippet will allow anyone to call the mint function, unlike the implementation before that would assert the owner is calling it.

#[payable]
pub fn nft_mint(
    &mut self,
    token_id: TokenId,
    receiver_id: ValidAccountId,
    token_metadata: TokenMetadata,
) -> Token {
    let owner_id: AccountId = AccountId::from(receiver_id);

    // Core behavior: every token must have an owner
    self.tokens.owner_by_id.insert(&token_id, &owner_id);

    // Metadata extension: Save metadata, keep variable around to return later.
    // Note that check above already panicked if metadata extension in use but no metadata
    // provided to call.
    self.tokens.token_metadata_by_id
        .as_mut()
        .and_then(|by_id| by_id.insert(&token_id, &token_metadata));

    // Enumeration extension: Record tokens_per_owner for use with enumeration view methods.
    if let Some(tokens_per_owner) = &mut self.tokens.tokens_per_owner {
        let mut token_ids = tokens_per_owner.get(&owner_id).unwrap_or_else(|| {
            UnorderedSet::new(env::sha256(owner_id.as_bytes()))
        });
        token_ids.insert(&token_id);
        tokens_per_owner.insert(&owner_id, &token_ids);
    }

    // Approval Management extension: return empty HashMap as part of Token
    let approved_account_ids =
        if self.tokens.approvals_by_id.is_some() { Some(HashMap::new()) } else { None };

    Token { token_id, owner_id, metadata: Some(token_metadata), approved_account_ids }
}

Note: the previous snippet was created quickly but tested using NEAR CLI and shown to work using the command:

near call $CONTRACT_NAME nft_mint '{"token_id": "1", "receiver_id": "mike.testnet", "token_metadata": { "title": "Love Letters to God", "description": "Great Song", "media": "media here", "copies": 19}}' --accountId mike.testnet --depositYocto 1

Fungible token example:

A different approach was taken in the fungible token standard. In the same way that mint was seen as common functionality but didn’t belong in the standard at the time, a couple of different ideas seemed to apply to fungible tokens. For instance, how is a fungible token supposed to deal with someone removing their account from the contract when they have non-zero balance? Should their balance be “burned” and subtracted from the total supply? Should it go back to the owner_id? Other clever mechanisms? The way we deal with this was to pass methods into a macro system:

The NFT and FT approaches outlined above added some manner of customizability, but it’s clear it didn’t go far enough. If a developer wants customize only one method from a trait, they can’t really do that and will essentially have to copy/paste implementation code from near-contract-standards and place it in their own smart contract, then remove the reliance on the crate.

In hindsight, I believe we may have been too concerned with how to accomplish customizability using Rust. It’s possible we missed the forest for the trees and should now take a step back and look at how to accomplish differently.

Suggested improvement

I propose we change the way the near-contract-standards crate works.

Let’s have two crates:

  1. near-contract-standardscontains the traits with functions that match the spec
  2. A new crate that contains trusted implementations for these methods. (Perhaps call it near-contract-standards-impls)

This would mean that there would be more boilerplate to the lib.rs file, as those would have:

use near_contract_standards::non_fungible_token::core::NonFungibleTokenCore as NFTCore;

impl NFTCore for Contract {
  fn nft_token(&self, token_id: TokenId) -> Option<Token> {
    // use the tested implementation on this function
    near_contract_standards_impls::impl_nft_token!(Contract, tokens);
  }

  fn nft_transfer(
      &mut self,
      receiver_id: AccountId,
      token_id: TokenId,
      approval_id: Option<u64>,
      memo: Option<String>,
  ) {
    // write a custom implementation here
  }
  …
}

As shown above, the first function just uses the logic from the proposed, new crate. The second function will not, and contain a completely custom implementation.

Suggestions welcome on this approach.

4 Likes

I don’t think separate crate impls are needed.
The near-contract-standards should and does contain both traits and impls.
We should make sure that these impls expose internal structure and logic and don’t enforce any permissions (e.g. don’t use env) to make it easy to customize.

For example instead of mint method, there should be internal_mint as suggested here: https://github.com/near/near-sdk-rs/issues/542#issue-972687436

1 Like