китаев.tech

ERC-20: approve и allowance

Как работает разрешение ERC-20 токенов: вызвать approve через ethers v6, прочитать allowance и понять, почему это не перевод токенов.

approve даёт разрешение

approve(spender, amount) не отправляет токены.

Он записывает лимит: сколько токенов spender сможет списать с адреса владельца позже через transferFrom. Этот лимит хранится в allowance(owner, spender).


Даём разрешение на списание токенов

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

bash
mkdir erc20-approve-example
cd erc20-approve-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/DemoToken.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract DemoToken {
    string public name = "Demo Token";
    string public symbol = "DEMO";
    uint8 public decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        require(spender != address(0), "approve to zero address");

        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);

        return true;
    }
}

Создайте файл scripts/approve-token.ts:

typescript
import { readFileSync } from "node:fs";
import { ethers } from "ethers";
import solc from "solc";

const source = readFileSync("contracts/DemoToken.sol", "utf8");

const input = {
  language: "Solidity",
  sources: {
    "DemoToken.sol": {
      content: source,
    },
  },
  settings: {
    outputSelection: {
      "*": {
        "*": ["abi", "evm.bytecode.object"],
      },
    },
  },
};

const output = JSON.parse(solc.compile(JSON.stringify(input)));
const contract = output.contracts["DemoToken.sol"].DemoToken;

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 owner = new ethers.Wallet(
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
  provider,
);

// Третий тестовый аккаунт Hardhat. В DeFi это мог бы быть адрес Router или Vault.
const spender = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC";

const factory = new ethers.ContractFactory(abi, bytecode, owner);
const initialSupply = ethers.parseUnits("1000", 18);
const token = await factory.deploy(initialSupply);

await token.waitForDeployment();

const tokenAddress = await token.getAddress();
const decimals = await token.decimals();
const symbol = await token.symbol();

const allowanceBefore = await token.allowance(owner.address, spender);
const ownerBalance = await token.balanceOf(owner.address);

console.log("Token address:", tokenAddress);
console.log("Owner:", owner.address);
console.log("Spender:", spender);
console.log("Owner balance:", ethers.formatUnits(ownerBalance, decimals), symbol);
console.log("Allowance before:", ethers.formatUnits(allowanceBefore, decimals), symbol);

const approveAmount = ethers.parseUnits("25", decimals);
const tx = await token.approve(spender, approveAmount);

console.log("Approve hash:", tx.hash);

const receipt = await tx.wait();

if (!receipt || receipt.status !== 1) {
  throw new Error("Транзакция approve() не была успешно выполнена");
}

const allowanceAfter = await token.allowance(owner.address, spender);
const ownerBalanceAfter = await token.balanceOf(owner.address);

console.log("Block:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Allowance after:", ethers.formatUnits(allowanceAfter, decimals), symbol);
console.log("Owner balance after:", ethers.formatUnits(ownerBalanceAfter, decimals), symbol);

Запустите локальную сеть Hardhat в отдельном терминале:

bash
npx hardhat node

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

bash
npx tsx scripts/approve-token.ts

Скрипт задеплоит тестовый ERC-20, даст spender разрешение на 25 DEMO и покажет, что баланс владельца не изменился.


Как это работает

owner — signer, который владеет токенами и подписывает approve.

spender — адрес, которому владелец разрешает списание. Это может быть другой EOA, Router, Vault или любой смарт-контракт.

token.allowance(owner.address, spender) — читает текущий лимит без транзакции и без gas.

ethers.parseUnits("25", decimals) — переводит 25 DEMO в raw uint256.

token.approve(spender, approveAmount) — отправляет write-транзакцию, которая записывает новый лимит.

tx.wait() — ждёт, пока транзакция попадёт в блок.

token.balanceOf(owner.address) после approve — показывает, что токены остались у владельца.


Чем approve отличается от transfer

transfer сразу двигает баланс:

text
owner balance     - amount
recipient balance + amount

approve меняет только разрешение:

text
allowance[owner][spender] = amount

После approve spender ещё не получил токены. Он получил право вызвать transferFrom(owner, recipient, amount) в пределах лимита.


Зачем нужен allowance

DeFi-протоколы не знают приватный ключ пользователя и не могут сами вызвать transfer от его имени.

Поэтому пользователь сначала делает approve на адрес протокола. Потом протокол вызывает transferFrom и списывает разрешённую сумму:

text
1. Пользователь: approve(router, 100 USDC)
2. Router: transferFrom(user, pool, 100 USDC)

Так работают swap, deposit, repay, staking и многие другие действия с ERC-20.


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

Думать, что approve переводит токеныapprove меняет только allowance. Баланс владельца не уменьшается.

Давать unlimited approve без причины → разрешение на MaxUint256 удобно, но если spender уязвим или вредоносен, он сможет списать все токены в рамках этого разрешения.

Проверять allowance в обратном порядке → правильный порядок аргументов: allowance(owner, spender), а не allowance(spender, owner).


Что дальше

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