китаев.tech

ERC-721: setApprovalForAll

Как работает разрешение оператора в ERC-721: вызвать setApprovalForAll, проверить isApprovedForAll и передать NFT через operator.

setApprovalForAll разрешает управлять коллекцией

setApprovalForAll(operator, true) даёт адресу operator право управлять всеми NFT владельца в конкретном ERC-721 контракте.

Это не разрешение на один tokenId. Так работают маркетплейсы: пользователь один раз разрешает оператору переносить его NFT из коллекции, а маркетплейс позже вызывает transferFrom.


Даём operator approval и переносим NFT

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

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

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

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

bash
npx hardhat node

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

bash
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 владельца в этом контракте:

text
isApprovedForAll(owner, operator) = true
operator может transferFrom(owner, ..., любой tokenId владельца)

Если пользователь дал такое разрешение вредоносному контракту или фишинговому сайту, оператор сможет вывести все NFT этой коллекции.


Marketplace паттерн

Маркетплейсу нужно переносить NFT после продажи, но он не знает приватный ключ продавца.

Поэтому продавец заранее делает:

text
setApprovalForAll(marketplace, true)

Когда появляется покупатель, маркетплейс вызывает:

text
transferFrom(seller, buyer, tokenId)

Так маркетплейс не владеет NFT заранее, но может выполнить передачу в момент сделки.


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

Думать, что разрешение действует на один tokenIdsetApprovalForAll действует на всю коллекцию для пары owner + operator.

Не отзывать старые approvals → если marketplace больше не используется, вызовите setApprovalForAll(operator, false).

Проверять approval в обратном порядке → правильный вызов: isApprovedForAll(owner, operator), а не isApprovedForAll(operator, owner).


Что дальше

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