ERC-721: tokenURI и metadata
Как ERC-721 связывает tokenId с metadata: записать tokenURI при mint, прочитать URI через ethers v6 и разобрать JSON.
tokenURI указывает на metadata
tokenURI(tokenId) возвращает ссылку на JSON metadata конкретного NFT.
Контракт хранит владение токеном в Ethereum, а metadata обычно лежит отдельно: в IPFS, Arweave, HTTPS API или прямо в data: URI.
Минтим NFT с metadata
Создайте проект:
mkdir erc721-token-uri-example
cd erc721-token-uri-example
npm init -y
npm pkg set type="module"
npm install ethers solc tsx
npm install --save-dev hardhat typescript @types/node
mkdir contracts scriptsСоздайте файл hardhat.config.ts:
import { defineConfig } from "hardhat/config";
export default defineConfig({
solidity: {
version: "0.8.20",
},
});Создайте файл contracts/DemoNFT.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DemoNFT {
string public name = "Demo NFT";
string public symbol = "DNFT";
uint256 public nextTokenId = 1;
mapping(uint256 => address) private owners;
mapping(address => uint256) private balances;
mapping(uint256 => string) private tokenUris;
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
function mint(address to, string calldata uri) external returns (uint256 tokenId) {
require(to != address(0), "mint to zero address");
require(bytes(uri).length > 0, "empty token URI");
tokenId = nextTokenId;
nextTokenId += 1;
owners[tokenId] = to;
balances[to] += 1;
tokenUris[tokenId] = uri;
emit Transfer(address(0), to, tokenId);
}
function ownerOf(uint256 tokenId) public view returns (address) {
address owner = owners[tokenId];
require(owner != address(0), "token does not exist");
return owner;
}
function tokenURI(uint256 tokenId) external view returns (string memory) {
ownerOf(tokenId);
return tokenUris[tokenId];
}
function balanceOf(address owner) external view returns (uint256) {
require(owner != address(0), "balance query for zero address");
return balances[owner];
}
}Создайте файл scripts/mint-with-metadata.ts:
import { readFileSync } from "node:fs";
import { ethers } from "ethers";
import solc from "solc";
type NftMetadata = {
name: string;
description: string;
image: string;
attributes: Array<{
trait_type: string;
value: string;
}>;
};
function createDataTokenUri(metadata: NftMetadata): string {
return `data:application/json,${encodeURIComponent(JSON.stringify(metadata))}`;
}
function parseDataTokenUri(tokenUri: string): NftMetadata {
const prefix = "data:application/json,";
if (!tokenUri.startsWith(prefix)) {
throw new Error("Ожидался data:application/json tokenURI");
}
const json = decodeURIComponent(tokenUri.slice(prefix.length));
return JSON.parse(json) as NftMetadata;
}
const source = readFileSync("contracts/DemoNFT.sol", "utf8");
const input = {
language: "Solidity",
sources: {
"DemoNFT.sol": {
content: source,
},
},
settings: {
outputSelection: {
"*": {
"*": ["abi", "evm.bytecode.object"],
},
},
},
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
const contract = output.contracts["DemoNFT.sol"].DemoNFT;
const abi = contract.abi;
const bytecode = `0x${contract.evm.bytecode.object}`;
const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");
// Первый тестовый аккаунт Hardhat. Не используйте этот ключ в mainnet.
const minter = new ethers.Wallet(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
provider,
);
// Второй тестовый аккаунт Hardhat. Он получит NFT.
const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const metadata: NftMetadata = {
name: "Demo NFT #1",
description: "Учебный NFT с metadata внутри data URI",
image: "ipfs://bafybeigdyrzt/example-image.png",
attributes: [
{
trait_type: "Network",
value: "Hardhat Local",
},
{
trait_type: "Type",
value: "Demo",
},
],
};
const tokenUri = createDataTokenUri(metadata);
const factory = new ethers.ContractFactory(abi, bytecode, minter);
const nft = await factory.deploy();
await nft.waitForDeployment();
const nftAddress = await nft.getAddress();
const tokenId = await nft.nextTokenId();
const tx = await nft.mint(recipient, tokenUri);
console.log("NFT address:", nftAddress);
console.log("Token ID:", tokenId.toString());
console.log("Mint hash:", tx.hash);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error("Транзакция mint() не была успешно выполнена");
}
const owner = await nft.ownerOf(tokenId);
const storedTokenUri = await nft.tokenURI(tokenId);
const parsedMetadata = parseDataTokenUri(storedTokenUri);
console.log("Block:", receipt.blockNumber);
console.log("Owner:", owner);
console.log("tokenURI:", storedTokenUri);
console.log("Metadata name:", parsedMetadata.name);
console.log("Metadata image:", parsedMetadata.image);
console.log("Attributes:", parsedMetadata.attributes);Запустите локальную сеть Hardhat в отдельном терминале:
npx hardhat nodeВо втором терминале выполните скрипт:
npx tsx scripts/mint-with-metadata.tsСкрипт задеплоит NFT-контракт, создаст токен #1, прочитает tokenURI(1) и распарсит metadata JSON из data: URI.
Как это работает
tokenUris[tokenId] = uri — связывает конкретный NFT с URI metadata.
mint(recipient, tokenUri) — создаёт NFT и записывает URI в storage контракта.
tokenURI(tokenId) — read-вызов. Он возвращает строку, по которой приложение найдёт metadata.
createDataTokenUri(metadata) — превращает JSON metadata в data:application/json URI для локального примера.
parseDataTokenUri(storedTokenUri) — декодирует URI обратно в JSON-объект.
image: "ipfs://..." — показывает обычный формат ссылки на изображение NFT. В реальном проекте файл должен быть загружен и закреплён в IPFS.
Что лежит в metadata
ERC-721 metadata обычно выглядит так:
{
"name": "Demo NFT #1",
"description": "Учебный NFT с metadata внутри data URI",
"image": "ipfs://bafybeigdyrzt/example-image.png",
"attributes": [
{
"trait_type": "Network",
"value": "Hardhat Local"
}
]
}Контракт не обязан понимать эти поля. Их читают wallets, marketplaces, explorers и индексаторы.
Где хранить tokenURI
Для учебного примера удобно использовать data: URI: всё лежит в одной строке и не зависит от внешнего сервера.
В production чаще используют:
| Формат | Пример | Когда подходит |
|---|---|---|
ipfs:// | ipfs://bafy.../1.json | metadata должна быть привязана к содержимому |
https:// | https://api.example.com/1 | metadata должна обновляться на сервере |
data: | data:application/json,... | маленькие on-chain или учебные NFT |
Если metadata лежит по https://, владелец сервера может изменить ответ. Если metadata лежит в IPFS по CID, ссылка указывает на конкретное содержимое.
Частые ошибки
Путать tokenURI и image → tokenURI указывает на JSON metadata, а поле image внутри JSON указывает на картинку.
Вызывать tokenURI для несуществующего tokenId → функция должна сделать revert, если NFT ещё не был создан.
Хранить metadata на случайном сервере → если сервер выключится или изменит JSON, NFT-интерфейсы покажут битые или другие данные.
Что дальше
- Что такое IPFS и зачем он в Web3 — разобраться, почему NFT metadata часто хранится в IPFS
- ERC-721: setApprovalForAll — разрешить маркетплейсу управлять NFT пользователя
- ERC-721: mint и ownerOf — повторить создание NFT и проверку владельца tokenId