ERC-721: setApprovalForAll
Как работает разрешение оператора в ERC-721: вызвать setApprovalForAll, проверить isApprovedForAll и передать NFT через operator.
setApprovalForAll разрешает управлять коллекцией
setApprovalForAll(operator, true) даёт адресу operator право управлять всеми NFT владельца в конкретном ERC-721 контракте.
Это не разрешение на один tokenId. Так работают маркетплейсы: пользователь один раз разрешает оператору переносить его NFT из коллекции, а маркетплейс позже вызывает transferFrom.
Даём operator approval и переносим NFT
Создайте проект:
mkdir erc721-approval-example
cd erc721-approval-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(address => mapping(address => bool)) private operatorApprovals;
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
function mint(address to) external returns (uint256 tokenId) {
require(to != address(0), "mint to zero address");
tokenId = nextTokenId;
nextTokenId += 1;
owners[tokenId] = to;
balances[to] += 1;
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 balanceOf(address owner) external view returns (uint256) {
require(owner != address(0), "balance query for zero address");
return balances[owner];
}
function setApprovalForAll(address operator, bool approved) external {
require(operator != msg.sender, "approve to caller");
operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
function isApprovedForAll(address owner, address operator) public view returns (bool) {
return operatorApprovals[owner][operator];
}
function transferFrom(address from, address to, uint256 tokenId) external {
require(to != address(0), "transfer to zero address");
require(ownerOf(tokenId) == from, "from is not owner");
require(
msg.sender == from || isApprovedForAll(from, msg.sender),
"caller is not owner nor approved"
);
balances[from] -= 1;
balances[to] += 1;
owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
}Создайте файл scripts/operator-approval.ts:
import { readFileSync } from "node:fs";
import { ethers } from "ethers";
import solc from "solc";
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. Он владеет NFT.
const owner = new ethers.Wallet(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
provider,
);
// Третий тестовый аккаунт Hardhat. В реальном приложении это мог бы быть marketplace.
const operator = new ethers.Wallet(
"0x5de4111a56fb4c77293da43f93d18f8ee7d99d6b201a790d22d7bce5863e604a",
provider,
);
// Второй тестовый аккаунт Hardhat. Он получит NFT.
const buyer = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const factory = new ethers.ContractFactory(abi, bytecode, owner);
const nft = await factory.deploy();
await nft.waitForDeployment();
const nftAddress = await nft.getAddress();
const tokenId = await nft.nextTokenId();
const mintTx = await nft.mint(owner.address);
await mintTx.wait();
console.log("NFT address:", nftAddress);
console.log("Token ID:", tokenId.toString());
console.log("Owner before:", await nft.ownerOf(tokenId));
console.log(
"Approved before:",
await nft.isApprovedForAll(owner.address, operator.address),
);
const approvalTx = await nft.setApprovalForAll(operator.address, true);
console.log("Approval hash:", approvalTx.hash);
const approvalReceipt = await approvalTx.wait();
if (!approvalReceipt || approvalReceipt.status !== 1) {
throw new Error("Транзакция setApprovalForAll() не была успешно выполнена");
}
console.log(
"Approved after:",
await nft.isApprovedForAll(owner.address, operator.address),
);
const nftAsOperator = new ethers.Contract(nftAddress, abi, operator);
const transferTx = await nftAsOperator.transferFrom(owner.address, buyer, tokenId);
console.log("Operator transfer hash:", transferTx.hash);
const transferReceipt = await transferTx.wait();
if (!transferReceipt || transferReceipt.status !== 1) {
throw new Error("Транзакция transferFrom() не была успешно выполнена");
}
console.log("Transfer block:", transferReceipt.blockNumber);
console.log("Owner after:", await nft.ownerOf(tokenId));
const revokeTx = await nft.setApprovalForAll(operator.address, false);
await revokeTx.wait();
console.log(
"Approved after revoke:",
await nft.isApprovedForAll(owner.address, operator.address),
);Запустите локальную сеть Hardhat в отдельном терминале:
npx hardhat nodeВо втором терминале выполните скрипт:
npx tsx scripts/operator-approval.tsСкрипт создаст NFT, включит operator approval, перенесёт NFT через operator и затем отзовёт разрешение.
Как это работает
setApprovalForAll(operator.address, true) — записывает разрешение для всей коллекции владельца.
isApprovedForAll(owner.address, operator.address) — читает текущее разрешение без транзакции и без gas.
new ethers.Contract(nftAddress, abi, operator) — подключает NFT-контракт к signer оператора.
nftAsOperator.transferFrom(owner.address, buyer, tokenId) — оператор переносит NFT владельца, потому что владелец дал ему разрешение.
setApprovalForAll(operator.address, false) — отзывает разрешение.
Почему это разрешение опаснее approve
approve(operator, tokenId) даёт право на один NFT.
setApprovalForAll(operator, true) даёт право на все NFT владельца в этом контракте:
isApprovedForAll(owner, operator) = true
operator может transferFrom(owner, ..., любой tokenId владельца)Если пользователь дал такое разрешение вредоносному контракту или фишинговому сайту, оператор сможет вывести все NFT этой коллекции.
Marketplace паттерн
Маркетплейсу нужно переносить NFT после продажи, но он не знает приватный ключ продавца.
Поэтому продавец заранее делает:
setApprovalForAll(marketplace, true)Когда появляется покупатель, маркетплейс вызывает:
transferFrom(seller, buyer, tokenId)Так маркетплейс не владеет NFT заранее, но может выполнить передачу в момент сделки.
Частые ошибки
Думать, что разрешение действует на один tokenId → setApprovalForAll действует на всю коллекцию для пары owner + operator.
Не отзывать старые approvals → если marketplace больше не используется, вызовите setApprovalForAll(operator, false).
Проверять approval в обратном порядке → правильный вызов: isApprovedForAll(owner, operator), а не isApprovedForAll(operator, owner).
Что дальше
- ERC-721: tokenURI и metadata — связать NFT с metadata и изображением
- ERC-721: mint и ownerOf — повторить, где хранится владелец tokenId
- Events и логи — разобрать
ApprovalForAllиTransferevents