ERC-20: transferFrom и DeFi паттерн
Как работает ERC-20 transferFrom: сначала approve, затем spender списывает токены владельца в пределах allowance.
transferFrom списывает по разрешению
transferFrom(from, to, amount) переводит токены не с адреса отправителя транзакции, а с адреса from.
Такой вызов сработает только если from заранее сделал approve для адреса, который вызывает transferFrom. Это базовый паттерн DeFi: пользователь разрешает протоколу списать токены, а протокол забирает их в нужный момент.
Списываем токены после approve
Создайте проект:
mkdir erc20-transfer-from-example
cd erc20-transfer-from-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;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
require(to != address(0), "transfer to zero address");
require(balanceOf[from] >= amount, "insufficient balance");
uint256 currentAllowance = allowance[from][msg.sender];
require(currentAllowance >= amount, "insufficient allowance");
allowance[from][msg.sender] = currentAllowance - amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Approval(from, msg.sender, allowance[from][msg.sender]);
emit Transfer(from, to, amount);
return true;
}
}Создайте файл scripts/transfer-from-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. Он владеет токенами.
const owner = new ethers.Wallet(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
provider,
);
// Третий тестовый аккаунт Hardhat. В DeFi это мог бы быть Router или Vault.
const spender = new ethers.Wallet(
"0x5de4111a56fb4c77293da43f93d18f8ee7d99d6b201a790d22d7bce5863e604a",
provider,
);
// Второй тестовый аккаунт Hardhat. Он получит токены.
const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
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 tokenAsSpender = new ethers.Contract(tokenAddress, abi, spender);
const decimals = await token.decimals();
const symbol = await token.symbol();
const approveAmount = ethers.parseUnits("100", decimals);
const spendAmount = ethers.parseUnits("40", decimals);
const approveTx = await token.approve(spender.address, approveAmount);
await approveTx.wait();
console.log("Token address:", tokenAddress);
console.log("Owner:", owner.address);
console.log("Spender:", spender.address);
console.log("Recipient:", recipient);
console.log(
"Allowance before transferFrom:",
ethers.formatUnits(await token.allowance(owner.address, spender.address), decimals),
symbol,
);
console.log(
"Owner balance before:",
ethers.formatUnits(await token.balanceOf(owner.address), decimals),
symbol,
);
console.log(
"Recipient balance before:",
ethers.formatUnits(await token.balanceOf(recipient), decimals),
symbol,
);
const tx = await tokenAsSpender.transferFrom(owner.address, recipient, spendAmount);
console.log("transferFrom hash:", tx.hash);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error("Транзакция transferFrom() не была успешно выполнена");
}
console.log("Block:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log(
"Allowance after transferFrom:",
ethers.formatUnits(await token.allowance(owner.address, spender.address), decimals),
symbol,
);
console.log(
"Owner balance after:",
ethers.formatUnits(await token.balanceOf(owner.address), decimals),
symbol,
);
console.log(
"Recipient balance after:",
ethers.formatUnits(await token.balanceOf(recipient), decimals),
symbol,
);Запустите локальную сеть Hardhat в отдельном терминале:
npx hardhat nodeВо втором терминале выполните скрипт:
npx tsx scripts/transfer-from-token.tsСкрипт даст spender разрешение на 100 DEMO, затем spender спишет 40 DEMO с owner на recipient. После этого allowance уменьшится до 60 DEMO.
Как это работает
owner — адрес, на котором лежат токены.
spender — signer, которому owner разрешил списание через approve.
recipient — адрес, который получает токены после transferFrom.
token.approve(spender.address, approveAmount) — записывает allowance[owner][spender].
new ethers.Contract(tokenAddress, abi, spender) — подключает тот же токен к signer spender.
tokenAsSpender.transferFrom(owner.address, recipient, spendAmount) — списывает токены с owner, потому что вызов подписывает разрешённый spender.
allowance(owner.address, spender.address) после вызова — показывает оставшийся лимит.
DeFi паттерн
Обычный пользовательский сценарий выглядит так:
1. Пользователь делает approve(router, 100 USDC)
2. Router вызывает transferFrom(user, pool, 100 USDC)
3. Pool получает USDC и выполняет swap, deposit или repayПользователь не отдаёт приватный ключ протоколу. Он только записывает лимит в ERC-20 контракте. Протокол может списать токены только в пределах этого лимита.
Чем transferFrom отличается от transfer
transfer списывает токены с msg.sender:
msg.sender -> recipienttransferFrom списывает токены с адреса from, но только если msg.sender получил allowance:
from -> recipient
spender = msg.senderПоэтому transferFrom нужен контрактам: они не могут подписать транзакцию от имени пользователя, но могут действовать в рамках его разрешения.
Частые ошибки
Вызывать transferFrom без approve → транзакция сделает revert с ошибкой вроде insufficient allowance.
Одобрить один адрес, а списывать другим → allowance привязан к паре owner + spender. Если approve был на Router A, Router B не сможет списать токены.
Путать from и spender → from владеет токенами, а spender подписывает транзакцию и тратит allowance.
Что дальше
- ERC-20: approve и allowance — повторить, где хранится разрешение на списание
- ERC-20: transfer — сравнить прямой перевод с DeFi-паттерном
- EIP-2612: permit — заменить approve-транзакцию подписью пользователя