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.
솔라나에서는 NFT 컬렉션마다 새로운 스마트 컨트랙트가 생성되지 않습니다. 대신, NFT는 대체 가능한 토큰에 사용되는 것과 동일한 단일 온체인 Token Program에서 생성되며, 단지 공급량이 1로 발행됩니다. NFT의 추가 세부 정보(이름, 심볼, 이미지 URI 등)를 저장하기 위해 개발자는 Metaplex Token Metadata Program을 사용합니다.
Metaplex Metadata는 NFT(대체 불가능한 토큰) 및 디지털 자산을 위한 오픈 소스 프로토콜이자 플랫폼입니다. 솔라나 블록체인을 기반으로 구축된 Metaplex는 솔라나의 토큰에 추가 메타데이터를 추가하는 것을 용이하게 합니다.
이는 일반적으로 각 컬렉션마다 새로운 ERC721 컨트랙트를 배포하는 이더리움과는 다릅니다. 왜 솔라나에는 NFT를 위한 단일 메인 프로그램이 있을까요? 이는 상태(데이터)와 실행 로직(프로그램)을 분리하는 솔라나 계정 모델 때문입니다. 솔라나는 모든 토큰에 대해 Token Program을 재사용하기 때문에 새로운 NFT 컨트랙트를 작성하고 배포할 필요가 없으며, 공급량 = 1인 새로운 mint account만 생성하면 됩니다.
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.
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.).
A very rough translation of the Solana Token Program would look like this:
// 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.
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.
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
Solana NFT (SPL + Metaplex)
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) | 솔라나 (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를 설정합니다. 가스 수수료는 변동될 수 있습니다. | spl-token mint <MINT_ADDRESS> 1을 실행하거나 Metaplex의 민트 명령어를 사용하여 정확히 1개의 공급량을 생성합니다. 솔라나의 트랜잭션 수수료는 일반적으로 매우 저렴합니다. |
| 4. 수신자 생성 | 일반적으로 일반 이더리움 주소입니다. 사용자는 많은 지갑에서 NFT를 보려면 NFT 컨트랙트 주소를 추가해야 합니다. | 각 사용자는 연관 토큰 계정(ATA)을 가지고 있습니다. Phantom과 같은 지갑은 NFT용 ATA를 자동으로 인식합니다. 토큰을 "추가"하는 별도의 단계가 필요하지 않지만, 인식되면 "수집품" 탭에 나타날 수 있습니다. |
| 5. 결과 확인 | Etherscan에서 보거나 OpenSea와 같은 NFT 마켓플레이스를 사용합니다. 일부 지갑에서 토큰을 보려면 사용자가 수동으로 컨트랙트 주소를 추가해야 하는 경우가 많습니다. | solana balance <ADDRESS> 또는 spl-token accounts를 사용하여 토큰 보유량을 확인할 수 있습니다. 솔라나 익스플로러 또는 NFT 마켓플레이스(Magic Eden, OpenSea Solana 등)를 사용하여 NFT의 존재를 확인할 수도 있습니다. |
| 6. 고유 식별자 | 컨트랙트 주소 + tokenId. | 민트 주소. 각 NFT는 단순히 공급량=1인 SPL 토큰입니다. |
| 7. 컬렉션 | 하나의 컨트랙트에 있는 모든 토큰은 일반적으로 단일 컬렉션을 나타냅니다. | 컬렉션은 Metaplex의 "컬렉션 주소"를 통해 할당됩니다. 전용 컨트랙트를 배포하지 않습니다. |
| 8. 코드 업데이트 | 주요 업그레이드의 경우 프록시 패턴을 사용하거나 컨트랙트를 재배포할 수 있습니다. 일반적으로 고급 개발자만 이를 수행합니다. | Token Program은 고정되어 있습니다. 고급 기능(로열티, 동적 메타데이터 등)이 필요한 경우 별도의 온체인 프로그램을 사용하거나 구축할 수 있습니다. Metaplex는 프로그래밍 가능한 NFT와 같은 확장 기능도 지원합니다. |
| 9. 감사 요구사항 | 각 컨트랙트는 일반적으로 감사가 필요하며, 특히 커스텀 민팅 로직, 마켓플레이스 등을 추가하는 경우 더욱 그렇습니다. | 주요 Token Program과 Metaplex 메타데이터 프로그램은 여러 차례 감사를 받았습니다. 표준 NFT 민팅의 경우 일반적으로 추가 컨트랙트 감사가 필요하지 않습니다. |
Want to mint your own NFT on Solana? Check out this guide!
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.
name()function name() external view returns (string); // Returns the token collection nameYou 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.
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);
});
symbol()function symbol() external view returns (string); // Returns the token collection symbolYou can also retrieve the symbol in the same way as name(), using @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata.
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);
});
tokenURI()function tokenURI(uint256 _tokenId) external view returns (string); // Returns the tokenURI of the tokenYou can also retrieve the symbol in the same way as name(), using @metaplex-foundation/umi-bundle-defaults and @metaplex-foundation/mpl-token-metadata.
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);
});
ownerOf()function ownerOf(uint256 _tokenId) public view returns (address) // Returns the owner of the tokenId tokenWe can check the owner of an NFT using the mint address instead of the tokenId.
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);
});
transferFrom()function transferFrom(address _from, address _to, uint256 _tokenId) external payable; // Transfers tokenId token from _from to _toFirst, 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.
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.
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!