китаев.tech

ERC-20: transferFrom и DeFi паттерн

Как работает ERC-20 transferFrom: сначала approve, затем spender списывает токены владельца в пределах allowance.

transferFrom списывает по разрешению

transferFrom(from, to, amount) переводит токены не с адреса отправителя транзакции, а с адреса from.

Такой вызов сработает только если from заранее сделал approve для адреса, который вызывает transferFrom. Это базовый паттерн DeFi: пользователь разрешает протоколу списать токены, а протокол забирает их в нужный момент.


Списываем токены после approve

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

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

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;
    }

    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:

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

bash
npx hardhat node

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

bash
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 паттерн

Обычный пользовательский сценарий выглядит так:

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

text
msg.sender -> recipient

transferFrom списывает токены с адреса from, но только если msg.sender получил allowance:

text
from -> recipient
spender = msg.sender

Поэтому transferFrom нужен контрактам: они не могут подписать транзакцию от имени пользователя, но могут действовать в рамках его разрешения.


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

Вызывать transferFrom без approve → транзакция сделает revert с ошибкой вроде insufficient allowance.

Одобрить один адрес, а списывать другим → allowance привязан к паре owner + spender. Если approve был на Router A, Router B не сможет списать токены.

Путать from и spenderfrom владеет токенами, а spender подписывает транзакцию и тратит allowance.


Что дальше

  • ERC-20: approve и allowance — повторить, где хранится разрешение на списание
  • ERC-20: transfer — сравнить прямой перевод с DeFi-паттерном
  • EIP-2612: permit — заменить approve-транзакцию подписью пользователя

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