What is ERC721 on Solana?

If you're coming from the EVM ecosystem, one thing you know well is ERC721. NFTs (non-fungible tokens) are at the heart of many blockchain applications, and ERC721 is one of the first standards developers learn when deploying NFT collections. On Solana, what is the ERC721 equivalent and how does it differ?

ERC721 sets a standard for Non-Fungible Tokens (NFTs), meaning each token is unique (in type and value) compared to another token. The ERC721 standard is utilized to represent individual tokens with distinct identifiers. Unlike ERC20 tokens, ERC721 tokens can possess unique characteristics and functions.

Token Program(附带元数据)

在 Solana 上,每个 NFT 系列不需要部署新的智能合约。相反,NFT 是在单个链上 Token Program 下创建的,该程序与用于同质化代币的程序相同——只是铸造时供应量设为 1。为了存储额外的 NFT 详细信息(如名称、符号、图像 URI 等),开发者使用 Metaplex Token Metadata Program。

Metaplex Metadata 是一个用于 NFT(非同质化代币)和数字资产的开源协议和平台。基于 Solana 区块链构建,Metaplex 便于为 Solana 上的代币添加额外的元数据。

这与以太坊不同,在以太坊上通常需要为每个系列部署全新的 ERC721 合约。为什么 Solana 上的 NFT 使用单个主程序?这是由于 Solana 账户模型,它将状态(数据)与执行逻辑(程序)分离。因为 Solana 对所有代币重用 Token Program,您无需编写和部署新的 NFT 合约——只需创建一个供应量 = 1 的新铸币账户即可。

NFTs Don't Have Contract Addresses, Only Mint Addresses

On Ethereum, each NFT collection is identified by its Contract Address, and each token has a unique tokenId within that contract. On Solana, each NFT is identified by a Mint Address. The difference is that you don't deploy a separate "NFT contract" - you just instruct the universal Token Program to create a new mint.

Because Solana's Token Program is universal, there's no concept of a unique "NFT contract address." Instead, each NFT is a special SPL token (supply of 1) with its own Mint Address.

If you want a "collection," you group multiple mint addresses together via a Collection Address in Metaplex, but each minted NFT is still just an SPL token with a unique Mint Address.

No Approval Flow Needed

On Ethereum, ERC721 often relies on approve or setApprovalForAll for external marketplaces or dApps to transfer your NFTs. On Solana, each NFT is stored in a user's ATA(Associated token account), and a single transaction can include any necessary steps. There's no separate approval flow - one atomic transaction can handle all instructions (e.g. transfer ownership, update metadata, etc.).

What Does A Solana Token Program Look Like?

A very rough translation of the Solana Token Program would look like this:

solidity
// SPDX-License-Identifier: MIT license
pragma solidity =0.8.28;

struct Mint {
    uint8 decimals;
    uint256 supply;
    address mintAuthority;
    address freezeAuthority;
    address mintAddress;
}

struct TokenAccount {
    address mintAddress;
    address owner;
    uint256 balance;
    bool isFrozen;
}

struct Metadata {
    string name;
    string symbol;
    string tokenURI;
}

contract Spl721 {
    mapping(address => Mint) public mints;
    mapping(address => TokenAccount) public tokenAccounts;
    mapping(address => Metadata) public nftMetadata;
    mapping(address => bool) public mintAddresses;
    mapping(address => bool) public tokenAddresses;

    function initializeMint(
        uint8 decimals,
        address mintAuthority,
        address freezeAuthority,
        address mintAddress
    )
        public
        returns (Mint memory)
    {
        require(!mintAddresses[mintAddress], "Mint already exists");
        mints[mintAddress] = Mint(decimals, 0, mintAuthority, freezeAuthority, mintAddress);
        mintAddresses[mintAddress] = true;
        return mints[mintAddress];
    }

    function setMetadata(
        address mintAddress,
        string memory name,
        string memory symbol,
        string memory tokenURI
    )
        public
    {
        require(mintAddresses[mintAddress], "Mint does not exist");
        nftMetadata[mintAddress] = Metadata(name, symbol, tokenURI);
    }

    function mintNFT(address toMintTokens, address mintAddress) public {
        require(mintAddresses[mintAddress], "NFT mint does not exist");
        require(mints[mintAddress].mintAuthority == msg.sender, "Only the mint authority can mint");
        require(mints[mintAddress].supply == 0, "NFT already minted");
        mints[mintAddress].supply = 1;
        address tokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(toMintTokens, mintAddress)))));
        if (!tokenAddresses[tokenAddress]) {
            tokenAccounts[tokenAddress] = TokenAccount(mintAddress, toMintTokens, 0, false);
            tokenAddresses[tokenAddress] = true;
        }
        tokenAccounts[tokenAddress].balance = 1;
        tokenAccounts[tokenAddress].owner = toMintTokens;
    }

    function transfer(address to, address mintAddress, uint256 amount) public {
        address toTokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(to, mintAddress)))));
        address fromTokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, mintAddress)))));
        require(tokenAccounts[fromTokenAddress].balance >= amount, "Insufficient balance");
        require(amount == 1, "Only transferring 1 NFT at a time");
        require(tokenAccounts[fromTokenAddress].owner == msg.sender, "Not the NFT owner");
        require(!tokenAccounts[fromTokenAddress].isFrozen, "Sender token account is frozen");
        if (tokenAddresses[toTokenAddress]) {
            require(!tokenAccounts[toTokenAddress].isFrozen, "Receiver token account is frozen");
        }
        if (!tokenAddresses[toTokenAddress]) {
            tokenAccounts[toTokenAddress] = TokenAccount(mintAddress, to, 0, false);
            tokenAddresses[toTokenAddress] = true;
        }
        tokenAccounts[fromTokenAddress].balance -= amount;
        tokenAccounts[toTokenAddress].balance += amount;
        tokenAccounts[toTokenAddress].owner = to;
    }

    function freezeAccount(address owner, address mintAddress) public {
        require(mintAddresses[mintAddress], "Mint does not exist");
        require(mints[mintAddress].freezeAuthority == msg.sender, "Only the freeze authority can freeze");
        address tokenAddress = address(uint160(uint256(keccak256(abi.encodePacked(owner, mintAddress)))));
        require(tokenAddresses[tokenAddress], "Token account not found");
        tokenAccounts[tokenAddress].isFrozen = true;
    }

    function getMint(address token) public view returns (Mint memory) {
        return mints[token];
    }

    function getTokenAccount(address owner, address token) public view returns (TokenAccount memory) {
        return tokenAccounts[address(uint160(uint256(keccak256(abi.encodePacked(owner, token)))))];
    }

    function getMetadata(address mintAddress) public view returns (Metadata memory) {
        return nftMetadata[mintAddress];
    }
}

As you can see in the contract, instead of an approval flow, there is only a transfer function to move tokens. On Solana, a single transaction can call multiple smart contract functions atomically, removing the need for the approval flow. Another difference is that everything is stored in a token account on Solana, keeping the details of the owner's tokens separate from the other state.

Where's the NFT Metadata?

On Ethereum, ERC721 contracts often store a metadata URI or embed logic for fetching token metadata. On Solana, the Token Metadata Program (by Metaplex) handles NFT metadata in a separate account. You can include fields like name, symbol, description, image URI, and more--all without changing the underlying Token Program. This does mean there's a separate instruction in your minting transaction to attach metadata. Since Solana can handle multiple program calls atomically in one transaction, you can easily create the mint and set up metadata in a single go.

Tradeoffs of Each Approach

Now that you have a general idea of how the token program and metadata program on Solana works, let's go through some of the tradeoffs of each NFT standard.

ERC721

  • Highly customizable contracts
  • New contract deployment for each collection
  • Hard to index across many contracts
  • Metadata often defined in contract or via URIs
  • Requires individual audits

Solana NFT (SPL + Metaplex)

  • Single program for all NFTs
  • New NFTs created by transactions, not by new contracts
  • Easier to index since there's one main program
  • Metadata stored in a separate program
  • Reuses existing audited code

High-Level Comparison

Below is a table comparing the basic steps of deploying an ERC-721 token on Ethereum vs. creating an NFT (SPL token with metadata) on Solana. (On Solana, you can freely mint SPL tokens by using the spl-token command in the command-line tool.)

步骤以太坊 (ERC-721)Solana (SPL + Metaplex)
1. 准备代币代码通常使用 OpenZeppelin 的 ERC721 库。您需要创建一个 Solidity 文件(例如 MyNFT.sol)。基本 NFT 功能无需自定义合约。Token Program 已经部署完成。您只需创建一个铸造账户(供应量 = 1)。
2. 编译和部署使用 Hardhat/Truffle 进行编译和部署(例如 npx hardhat run deploy.js --network ...)。使用 CLI(例如 spl-token create-token --decimals 0)或 SDK 工具(如 Metaplex 的 Candy Machine)。无需单独部署合约。
3. 铸造 NFT调用合约的 mint() 函数,将 NFT 分配给某个地址,通常还会设置代币元数据 URI。Gas 费用可能有所不同。运行 spl-token mint <MINT_ADDRESS> 1 或使用 Metaplex 的铸造指令创建恰好 1 个供应量。Solana 上的交易费用通常非常低。
4. 创建接收方通常只是一个普通的以太坊地址。用户必须在许多钱包中添加 NFT 合约地址才能看到它。每个用户都有一个 Associated Token Account (ATA)。像 Phantom 这样的钱包会自动识别 NFT 的 ATA。无需额外步骤来"添加"代币,但一旦被识别,它可能会显示在"收藏品"标签下。
5. 检查结果在 Etherscan 上查看或使用 NFT 市场(如 OpenSea)。通常,用户需要手动添加合约地址才能在某些钱包中看到代币。使用 solana balance <ADDRESS>spl-token accounts 查看代币持有情况。您还可以使用 Solana 浏览器或 NFT 市场(Magic Eden、OpenSea Solana 等)来确认 NFT 的存在。
6. 唯一标识符合约地址 + tokenId铸造地址。每个 NFT 只是一个 supply=1 的 SPL 代币。
7. 集合一个合约中的所有代币通常代表一个集合。集合通过 Metaplex 中的"集合地址"分配。您无需部署专门的合约。
8. 代码更新对于重大升级,您可能会使用代理模式或重新部署合约。通常只有高级开发者才会这样做。Token Program 是固定的。如果您需要高级功能(版税、动态元数据等),可以使用或构建单独的链上程序。Metaplex 还支持可编程 NFT 等扩展。
9. 审计要求每个合约通常都需要审计,特别是如果您添加了自定义铸造逻辑、市场等功能。主要的 Token Program 和 Metaplex Metadata Program 已经过多次审计。对于标准 NFT 铸造,通常不需要额外的合约审计。

Want to mint your own NFT on Solana? Check out this guide!

How to do ERC721 on Solana

Let's explore how to interact with the Token Program using solana/web3.js based on Ethereum's ERC721 interface structure.

As mentioned above, since NFT Collections on Solana are not tied to a specific contract, the concept of a tokenId does not exist. Therefore, to find the specific data of a token, you should use Mint Address or Collection Address.

How to do name()

solidity
function name() external view returns (string); // Returns the token collection name

You can use @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata to fetch and parse an NFT's off-chain metadata from its mint address.

jsx
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { fetchDigitalAsset, mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata";
import { PublicKey } from "@metaplex-foundation/js";
const mintAddress = new PublicKey("Token Address");

async function name() {
  try {
    const umi = createUmi("https://api.devnet.solana.com");
    umi.use(mplTokenMetadata());
    const digitalAsset = await fetchDigitalAsset(umi, mintAddress);
    return digitalAsset.metadata.name;
  } catch (error) {
    console.error("Error fetching NFT name:", error);
    return null;
  }
}

name().then(nftName => {
    console.log("NFT Name:", nftName);
  })
  .catch(error => {
    console.error("Error:", error);
  });

How to do symbol()

solidity
function symbol() external view returns (string); // Returns the token collection symbol

You can also retrieve the symbol in the same way as name(), using @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata.

jsx
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { fetchDigitalAsset, mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata";
import { PublicKey } from "@metaplex-foundation/js";
const mintAddress = new PublicKey("Token Address");

async function symbol() {
  try {
    const umi = createUmi("https://api.devnet.solana.com");
    umi.use(mplTokenMetadata());
    const digitalAsset = await fetchDigitalAsset(umi, mintAddress);
    return digitalAsset.metadata.symbol;
  } catch (error) {
    console.error("Error fetching NFT symbol:", error);
    return null;
  }
}

symbol().then(nftSymbol => {
  console.log("NFT Symbol:", nftSymbol);
}).catch(error => {
  console.error("Error:", error);
});

How to do tokenURI()

bash
function tokenURI(uint256 _tokenId) external view returns (string); // Returns the tokenURI of the token

You can also retrieve the symbol in the same way as name(), using @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata.

jsx
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { fetchDigitalAsset, mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata";
import { PublicKey } from "@metaplex-foundation/js";
const mintAddress = new PublicKey("Token Address");

async function tokenURI( /* no tokenId */ ) {
  try {
    const umi = createUmi("https://api.devnet.solana.com");
    umi.use(mplTokenMetadata());
    const digitalAsset = await fetchDigitalAsset(umi, mintAddress);
    return digitalAsset.metadata.uri;
  } catch (error) {
    console.error("Error fetching token URI:", error);
    return null;
  }
}

tokenURI()
  .then(uri => {
    console.log("Token URI:", uri);
  })
  .catch(error => {
    console.error("Error:", error);
  });

How to do ownerOf()

bash
function ownerOf(uint256 _tokenId) public view returns (address) // Returns the owner of the tokenId token

We can check the owner of an NFT using the mint address instead of the tokenId.

jsx
import { Connection, PublicKey } from "@solana/web3.js";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");
const mintAddress = new PublicKey("Token Address");

async function ownerOf( /*no tokenId*/ ){
  const largestAccounts = await connection.getTokenLargestAccounts(new PublicKey(mintAddress));
  const largestAccountInfo = await connection.getParsedAccountInfo(largestAccounts.value[0].address);
  return largestAccountInfo.value.data.parsed.info.owner;
}

ownerOf().then(owner => {
  console.log(owner);
}).catch(error => {
  console.error("Error:", error);
});

How to do transferFrom()

solidity
function transferFrom(address _from, address _to, uint256 _tokenId) external payable; // Transfers tokenId token from _from to _to

First, Let's look at this code for understanding logic:

On Solana, each token is identified by its unique Mint address, and users store their balances in an Associated Token Account (ATA). In a transaction, the ATA address is used as the sender or receiver for transferring tokens. Unlike on Ethereum, where you directly call an ERC-20 contract, Solana handles token transfer logic through its native system program.

We can make a transaction's transfer instruction through createTransferCheckedInstruction and send it through sendTransaction from @solana/spl-token.


jsx
import { Keypair, Transaction, Connection, PublicKey } from "@solana/web3.js";
import { createTransferCheckedInstruction } from "@solana/spl-token";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");

// Replace with your real private key (as a Uint8Array)
const ownerSecretkey = [];
const ownerPrivatekeypair = Keypair.fromSecretKey(new Uint8Array(ownerSecretkey));

const fromPublicKey = ownerPrivatekeypair.publicKey; // The sender's public key
const toPublicKey = new PublicKey("Receiver's Wallet Address");

const mintAddress = new PublicKey("Token Address");

// Pre-existing ATA addresses (for `_from` and `_to`)
const ownerTokenAccount = new PublicKey("Associated Token Account of _from");
const receiverTokenAccount = new PublicKey("Associated Token Account of _to");

// For an NFT, decimals = 0 and amount = 1
async function transferFrom(_from, _to) {
  try {
    const tx = new Transaction().add(
      createTransferCheckedInstruction(
        ownerTokenAccount,              // _from's ATA
        mintAddress,
        receiverTokenAccount,           // _to's ATA
        ownerPrivatekeypair.publicKey,  // Authority for `_from` (the private key must match this public key)
        1,                              // amount = 1 NFT
        0                               // decimals = 0 for NFT
      )
    );
    
    // Send transaction (simple version)
    await connection.sendTransaction(tx, [ownerPrivatekeypair]);
    return true;
  } catch (error) {
    console.error("Error in transferFrom:", error);
    return false;
  }
}

transferFrom(fromPublicKey, toPublicKey)
  .then(result => {
    console.log("transferFrom result:", result);
  })
  .catch(error => {
    console.error("Error:", error);
  });

How we can check receiverTokenAddress through receiverAddress? We can use getOrCreateAssociatedTokenAccount. It will retrieve the associated token account, or create it if it doesn't exist.

jsx
import { Keypair, Transaction, Connection, PublicKey } from "@solana/web3.js";
import { createTransferCheckedInstruction, getOrCreateAssociatedTokenAccount } from "@solana/spl-token";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");

// Insert your private key as a Uint8Array
const ownerSecretkey = [];
const ownerPrivatekeypair = Keypair.fromSecretKey(new Uint8Array(ownerSecretkey));

const fromAddress = ownerPrivatekeypair.publicKey;  // The sender's public key
const toAddress = new PublicKey("Receiver's Wallet Address");

const mintAddress = new PublicKey("Token Address");

// For an NFT: decimals = 0, amount = 1
async function transferFrom(_from, _to) {
  try {
    // Retrieve (or create if missing) the sender's ATA
    const ownerTokenAccount = await getOrCreateAssociatedTokenAccount(
      connection,
      ownerPrivatekeypair,    // Fee payer
      mintAddress,
      _from
    );

    // Retrieve (or create if missing) the receiver's ATA
    const receiverTokenAccount = await getOrCreateAssociatedTokenAccount(
      connection,
      ownerPrivatekeypair,    // Fee payer (use the receiver if needed)
      mintAddress,
      _to
    );

    // Create transaction with a transfer of 1 NFT (decimals=0)
    const tx = new Transaction().add(
      createTransferCheckedInstruction(
        ownerTokenAccount.address,
        mintAddress,
        receiverTokenAccount.address,
        ownerPrivatekeypair.publicKey,
        1, // Always transfer exactly 1 (NFT)
        0  // decimals = 0 for an NFT
      )
    );

    // Send the transaction
    await connection.sendTransaction(tx, [ownerPrivatekeypair]);
    return true;
  } catch (error) {
    console.error("Error in transferFrom:", error);
    return false;
  }
}

transferFrom(fromAddress, toAddress)
  .then(result => {
    console.log("Transaction result:", result);
  })
  .catch(error => {
    console.error("Error:", error);
  });

Want to explore more features? Check out this guide!

Node storing all data and participating in consensus

  • Ethereum: Archive Node
  • Solana: [n/a]

Node storing some data and participating in consensus

  • Ethereum: Full Node
  • Solana: Consensus Node

Node storing some data and not participating in consensus

  • Ethereum: Light Node
  • Solana: RPC Node
EVM TO SVM

Start building on Solana

管理者

©️ 2026 Solana 基金会版权所有
取得联系
What is ERC721 on Solana? | Solana