китаев.tech

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

Создайте проект:

bash
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:

typescript
import { defineConfig } from "hardhat/config";

export default defineConfig({
  solidity: {
    version: "0.8.20",
  },
});

Создайте файл contracts/DemoNFT.sol:

solidity
// 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:

typescript
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 в отдельном терминале:

bash
npx hardhat node

Во втором терминале выполните скрипт:

bash
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 обычно выглядит так:

json
{
  "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.jsonmetadata должна быть привязана к содержимому
https://https://api.example.com/1metadata должна обновляться на сервере
data:data:application/json,...маленькие on-chain или учебные NFT

Если metadata лежит по https://, владелец сервера может изменить ответ. Если metadata лежит в IPFS по CID, ссылка указывает на конкретное содержимое.


Частые ошибки

Путать tokenURI и imagetokenURI указывает на JSON metadata, а поле image внутри JSON указывает на картинку.

Вызывать tokenURI для несуществующего tokenId → функция должна сделать revert, если NFT ещё не был создан.

Хранить metadata на случайном сервере → если сервер выключится или изменит JSON, NFT-интерфейсы покажут битые или другие данные.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.