ERC-20: approve и allowance
Как работает разрешение ERC-20 токенов: вызвать approve через ethers v6, прочитать allowance и понять, почему это не перевод токенов.
approve даёт разрешение
approve(spender, amount) не отправляет токены.
Он записывает лимит: сколько токенов spender сможет списать с адреса владельца позже через transferFrom. Этот лимит хранится в allowance(owner, spender).
Даём разрешение на списание токенов
Создайте проект:
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:
import { defineConfig } from "hardhat/config";
export default defineConfig({
solidity: {
version: "0.8.20",
},
});Создайте файл contracts/DemoToken.sol:
// 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:
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 в отдельном терминале:
npx hardhat nodeВо втором терминале выполните скрипт:
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 сразу двигает баланс:
owner balance - amount
recipient balance + amountapprove меняет только разрешение:
allowance[owner][spender] = amountПосле approve spender ещё не получил токены. Он получил право вызвать transferFrom(owner, recipient, amount) в пределах лимита.
Зачем нужен allowance
DeFi-протоколы не знают приватный ключ пользователя и не могут сами вызвать transfer от его имени.
Поэтому пользователь сначала делает approve на адрес протокола. Потом протокол вызывает transferFrom и списывает разрешённую сумму:
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).
Что дальше
- ERC-20: transferFrom (DeFi паттерн) — списать токены после approve
- ERC-20: transfer — сравнить разрешение с обычным переводом токенов
- EIP-2612: permit — дать allowance подписью без отдельной approve-транзакции