Skip to content

Latest commit

 

History

History
436 lines (373 loc) · 16.1 KB

sep-0050.md

File metadata and controls

436 lines (373 loc) · 16.1 KB

Preamble

SEP: 0050
Title: Non-Fungible Tokens
Author: OpenZeppelin, Boyan Barakov <@brozorec>, Özgün Özerk <@ozgunozerk>
Status: Draft
Created: 2025-03-10
Updated: 2025-03-10
Version: 0.1.0
Discussion: https://github.com/stellar/stellar-protocol/discussions/1674

Summary

This proposal defines a standard contract interface for non-fungible tokens. The interface is a similar to ERC721, but factors in the differences between Ethereum and Stellar.

Motivation

A non-fungible asset (NFT) is a fundamental concept on blockchains, representing unique and indivisible digital assets. While most blockchain ecosystems have established standards for NFTs, such as ERC-721 and ERC-1155 in Ethereum, the Stellar ecosystem lacks a standardized interface for NFTs. This absence may lead to fragmentation, making it difficult to ensure interoperability between different NFT contracts and applications.

Currently, while it is technically possible to create NFTs on Stellar by issuing non-divisible assets with unique metadata as outlined in SEP-39 -and one can deploy the SAC for it afterwards-, this approach is unintuitive for developers, due to SAC is mainly designed for Fungible tokens, and comes with the following limitations:

  • Non Fungibility - There are two alternatives to issue NFTs via Classic Stellar Assets: one Asset per NFT, or one Asset per collection. One Asset per NFT requires the creation of a new Classic Stellar Asset for each NFT, which can be cumbersome and inefficient. One Asset per collection resolves this problem, but then the NFTs inside the collection are becoming fungible instead of non-fungible (one can exchange an NFT inside the collection for another NFT inside the same collection).
  • Metadata handling – SAC NFTs store metadata off-chain, whereas if wanted, smart contract NFTs can store more on-chain metadata with further customizations.
  • Permission controls – SACs follow the standard asset authorization model, while smart contract NFTs enable fine-grained access controls and custom rules.
  • Transfers & ownership – SAC NFTs rely on trust-lines and asset balances, whereas smart contract NFTs can implement more explicit and flexible ownership structures.
  • Customizability – SACs are limited to predefined asset behaviors, whereas smart contract NFTs allow advanced logic like royalties, leasing, or dynamic attributes.

By defining NFTs as smart contracts, developers gain more intuitive and straight-forward way to issue NFTs, along with greater flexibility, programmability, and interoperability, enabling use cases beyond simple asset issuance.

This proposal defines a non-fungible token interface that provides core NFT functionality, including ownership management, transfers, and approvals, without enforcing opinionated behaviors beyond the standard expectations for NFTs. By establishing this interface, NFT contracts can interact seamlessly with other contracts and applications that support the standard, ensuring broader usability and compatibility within the Stellar network.

Interface

NFTs can have diverse use cases, and there is no universal token_id format that fits all scenarios. Some implementations may use sequential numbers, while others may opt for UUIDs or hashes. To accommodate this variability, this interface remains agnostic to the specific token_id format, defining it as a generic TokenID type, which should be an unsigned integer.

Additionally, since a single account cannot hold more tokens than the total supply of TokenIDs, we introduce a Balance type. In most cases, Balance should use the same type as TokenID for consistency.

/// The `NonFungibleToken` trait defines the core functionality for non-fungible
/// tokens. It provides a standard interface for managing
/// transfers and approvals associated with non-fungible tokens.
pub trait NonFungibleToken {
    /// Returns the number of tokens in `owner`'s account.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `owner` - Account of the token's owner.
    fn balance(e: &Env, owner: Address) -> Balance;

    /// Returns the address of the owner of the given `token_id`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `token_id` - Token id as a number.
    ///
    /// # Notes
    ///
    /// If the token does not exist, this function is expected to panic.
    fn owner_of(e: &Env, token_id: TokenID) -> Address;

    /// Transfers `token_id` token from `from` to `to`.
    ///
    /// WARNING: Note that the caller is responsible to confirm that the
    /// recipient is capable of receiving the `Non-Fungible` or else the NFT
    /// may be permanently lost.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `from` - Account of the sender.
    /// * `to` - Account of the recipient.
    /// * `token_id` - Token id as a number.
    ///
    /// # Events
    ///
    /// * topics - `["transfer", from: Address, to: Address]`
    /// * data - `[token_id: TokenID]`
    fn transfer(e: &Env, from: Address, to: Address, token_id: TokenID);

    /// Transfers `token_id` token from `from` to `to` by using `spender`s
    /// approval.
    ///
    /// Unlike `transfer()`, which is used when the token owner initiates the transfer,
    /// `transfer_from()` allows an approved third party (`spender`) to transfer the token
    /// on behalf of the owner. This function includes an on-chain check to verify that
    /// `spender` has the necessary approval.
    ///
    /// WARNING: Note that the caller is responsible to confirm that the
    /// recipient is capable of receiving the `Non-Fungible` or else the NFT
    /// may be permanently lost.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `spender` - The address authorizing the transfer.
    /// * `from` - Account of the sender.
    /// * `to` - Account of the recipient.
    /// * `token_id` - Token id as a number.
    ///
    /// # Events
    ///
    /// * topics - `["transfer", from: Address, to: Address]`
    /// * data - `[token_id: TokenID]`
    fn transfer_from(e: &Env, spender: Address, from: Address, to: Address, token_id: TokenID);

    /// Gives permission to `approved` to transfer `token_id` token to another
    /// account. The approval is cleared when the token is transferred.
    ///
    /// Only a single account can be approved at a time for a `token_id`.
    /// To remove an approval, the approver can approve their own address,
    /// effectively removing the previous approved address.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to Soroban environment.
    /// * `approver` - The address of the approver (should be `owner` or `operator`).
    /// * `approved` - The address receiving the approval.
    /// * `token_id` - Token id as a number.
    /// * `live_until_ledger` - The ledger number at which the allowance
    ///   expires.
    ///
    /// # Events
    ///
    /// * topics - `["approve", from: Address, to: Address]`
    /// * data - `[token_id: TokenID, live_until_ledger: u32]`
    fn approve(e: &Env, approver: Address, approved: Address, token_id: TokenID, live_until_ledger: u32);

    /// Approve or remove `operator` as an operator for the owner.
    ///
    /// Operators can call `transfer_from()` for any token held by `owner`,
    /// and call `approve()` on behalf of `owner`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to Soroban environment.
    /// * `owner` - The address holding the tokens.
    /// * `operator` - Account to add to the set of authorized operators.
    /// * `live_until_ledger` - The ledger number at which the allowance
    ///   expires. If `live_until_ledger` is `0`, the approval is revoked.
    ///
    /// # Events
    ///
    /// * topics - `["approve_for_all", from: Address]`
    /// * data - `[operator: Address, live_until_ledger: u32]`
    fn approve_for_all(
        e: &Env,
        owner: Address,
        operator: Address,
        live_until_ledger: u32,
    );

    /// Returns the account approved for `token_id` token.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `token_id` - Token id as a number.
    ///
    /// # Notes
		///
		/// If the token does not exist, this function is expected to panic.
    fn get_approved(e: &Env, token_id: TokenID) -> Option<Address>;

    /// Returns whether the `operator` is allowed to manage all the assets of
    /// `owner`.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `owner` - Account of the token's owner.
    /// * `operator` - Account to be checked.
    fn is_approved_for_all(e: &Env, owner: Address, operator: Address) -> bool;

    /// Returns the token collection name.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn name(e: &Env) -> String;

    /// Returns the token collection symbol.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    fn symbol(e: &Env) -> String;

    /// Returns the Uniform Resource Identifier (URI) for `token_id` token.
    ///
    /// # Arguments
    ///
    /// * `e` - Access to the Soroban environment.
    /// * `token_id` - Token id as a number.
    ///
    /// # Notes
		///
		/// If the token does not exist, this function is expected to panic.
    fn token_uri(e: &Env, token_id: TokenID) -> String;
}

Events

Transfer Event

The transfer event is emitted when an NFT is transferred from one address to another.

Topics:

  • Symbol with value "transfer"
  • Address: the address holding the token that was transferred.
  • Address: the address that received the token.

Data:

  • TokenID: the identifier of the transferred token.

Approve Event

The "approve" event is emitted when an owner grants another address permission to manage a specific token.

Topics:

  • Symbol with value "approve"
  • Address: the owner of the token.
  • TokenID: the identifier of the token.

Data:

  • Address: the approved address.
  • u32: the expiration ledger.

Approve for All Event

The "approve for all event" is emitted when an owner grants or revokes permission for an operator to manage all of their NFTs.

Topics:

  • Symbol with value "approve_for_all"
  • Address: the owner of the tokens.

Data:

  • Address: the operator receiving or losing permission.
  • u32: the expiration ledger. If 0, the approval is revoked.

Mint Event

The mint event is emitted when a token is minted.

The event has topics:

  • Symbol with value "mint"
  • Address: the address to hold the newly minted token.

The event has data:

  • TokenID the identifier of the minted token.

Notes on mint():

  • Minting functionality is typically implemented as an internal function (see [OpenZeppelin Contracts](https://github.com/OpenZeppelin/openzeppelin-contracts)) to emphasize that the standard does not impose any specific minting logic. This allows developers to adopt an approach that best suits their contract’s requirements. In this SEP, we do not specify any standards for minting tokens.

Notes on safeTransferFrom:

  • In ERC-721, safeTransferFrom and onERC721Received exist to prevent the loss of NFTs when transferring them to a smart contract that does not support NFT-related operations.
  • However, this approach comes with significant overhead:
    • An additional on-chain transaction is required for verification.
    • The receiving contract must implement onERC721Received.
  • A more efficient alternative is the [permit](https://eips.ethereum.org/EIPS/eip-2612) mechanism (EIP-2612), which leverages off-chain signatures to authorize the recipient to claim the tokens, reducing on-chain transactions from two to one.

Notes on permit:

safeTransfer and onReceive ensure the recipient can handle NFTs, preventing accidental burns. Unlike fungible tokens, NFTs can’t be broken into tiny fractions, and test-transferred in small amounts.

Stellar’s trust lines offer similar safety but apply to specific assets, whereas safe transfer secures entire asset classes. Soroban removed trust line requirements for contract transfers.

safeTransferFrom is relevant for NFT transfers to smart contracts, while permit also works for EOAs, addressing the indivisibility issue. Stellar’s built-in signatures provide permit-like functionality at the protocol level. For added safety, developers can implement safe variants or leverage existing solutions.

Notes on transfer():

  • The transfer() function exists in ERC20 but is absent in ERC721, primarily for historical reasons. In both ERC20 and ERC721, transferFrom() can be used instead of transfer(), with the the only difference being an additional on-chain check. To avoid redundancy, transfer() was omitted from the ERC721 standard.
  • Despite this, transfer() retains a key advantage over transferFrom(): when the token owner initiates the transfer, transfer() is more efficient and cost-effective, as it eliminates the need for an extra on-chain verification.
  • While we aim for compatibility with other ecosystems, maintaining internal consistency within our own ecosystem takes precedence. The behavior and the use cases of transfer() remains the same for both fungible and non-fungible tokens, so we decided to include it for NFTs as well in this SEP.

Notes on name(), symbol() and token_uri()

  • Those methods are not part of ERC721, but including them in the standard, because exposing metadata through this API proved itself as a common practice for marketplaces to display NFT details. Furthermore, it’s important to be consistent within the Stellar ecosystem as SEP-41 also defines metadata as part of the core interface for fungible tokens.
  • [SEP-39](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0039.md#design-rationale) suggests a dual approach for describing NFTs. The first one proposes an example json schema, while the other suggests leveraging the defined in SEP-1 “Stellar.toml”. While this approach makes sense for NFTs represented by Classic Stellar Assets, it can create confusion for clients. To prevent this, token_uri() returns an url that points to a JSON file that conforms to the "Non-Fungible Metadata JSON Schema".
  • "Non-Fungible Metadata JSON Schema"
{
  "title": "Non-Fungible Metadata",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Identifies the asset to which this NFT represents"
    },
    "description": {
      "type": "string",
      "description": "Describes the asset to which this NFT represents"
    },
    "image": {
      "type": "string",
      "description": "A URI pointing to a resource with mime type image/* representing the asset to which this NFT represents."
    },
    "external_url": {
      "type": "string",
      "description": "A URL pointing to an external resource related to the asset, for example, to visualize the NFT on the collection's own site."
    },
    "attributes": {
      "type": "array",
      "description": "Array of attributes that describe the asset",
      "items": {
        "type": "object",
        "properties": {
          "display_type": {
            "type": "string",
            "description": "The type of display for the attribute"
          },
          "trait_type": {
            "type": "string",
            "description": "The type of trait this attribute represents"
          },
          "value": {
            "description": "The value of the attribute",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "max_value": {
            "type": "number",
            "description": "The maximum value for numeric traits"
          }
        }
      }
    }
  }
}

Changelog

  • 0.1.0: Initial draft