ERC-20: transfer
Как отправить ERC-20 токены через ethers v6: подготовить amount через parseUnits, подписать transfer и дождаться подтверждения транзакции.
transfer отправляет токены
transfer(to, amount) — функция ERC-20, которая переводит токены с адреса отправителя на адрес получателя.
Это write-вызов. Он меняет State Ethereum, поэтому нужен signer, ETH для gas и подтверждённая транзакция.
Отправляем ERC-20 в локальной сети
Создайте проект:
mkdir erc20-transfer-example
cd erc20-transfer-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;
event Transfer(address indexed from, address indexed to, uint256 value);
constructor(uint256 initialSupply) {
totalSupply = initialSupply;
balanceOf[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender, initialSupply);
}
function transfer(address to, uint256 amount) external returns (bool) {
require(to != address(0), "transfer to zero address");
require(balanceOf[msg.sender] >= amount, "insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
}Создайте файл scripts/transfer-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 sender = new ethers.Wallet(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
provider,
);
// Второй тестовый аккаунт Hardhat.
const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const factory = new ethers.ContractFactory(abi, bytecode, sender);
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 senderBalanceBefore = await token.balanceOf(sender.address);
const recipientBalanceBefore = await token.balanceOf(recipient);
console.log("Token address:", tokenAddress);
console.log("Sender before:", ethers.formatUnits(senderBalanceBefore, decimals), symbol);
console.log(
"Recipient before:",
ethers.formatUnits(recipientBalanceBefore, decimals),
symbol,
);
const amount = ethers.parseUnits("12.5", decimals);
const tx = await token.transfer(recipient, amount);
console.log("Transfer hash:", tx.hash);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error("Транзакция transfer() не была успешно выполнена");
}
const senderBalanceAfter = await token.balanceOf(sender.address);
const recipientBalanceAfter = await token.balanceOf(recipient);
console.log("Block:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Sender after:", ethers.formatUnits(senderBalanceAfter, decimals), symbol);
console.log(
"Recipient after:",
ethers.formatUnits(recipientBalanceAfter, decimals),
symbol,
);Запустите локальную сеть Hardhat в отдельном терминале:
npx hardhat nodeВо втором терминале выполните скрипт:
npx tsx scripts/transfer-token.tsСкрипт задеплоит тестовый ERC-20, отправит 12.5 DEMO на второй аккаунт и прочитает балансы после подтверждения.
Как это работает
new ethers.Wallet(privateKey, provider) — создаёт signer. Только signer может подписать transfer.
ethers.parseUnits("1000", 18) — переводит 1000 DEMO в raw-значение для контракта.
new ethers.ContractFactory(abi, bytecode, sender) — деплоит токен от имени отправителя.
token.balanceOf(sender.address) — читает баланс без транзакции и без gas.
ethers.parseUnits("12.5", decimals) — готовит amount в минимальных единицах токена.
token.transfer(recipient, amount) — отправляет write-транзакцию в ERC-20 контракт.
tx.wait() — ждёт, пока транзакцию включат в блок и вернут receipt.
receipt.status !== 1 — проверяет, что EVM не откатила выполнение.
transfer списывает токены с msg.sender
ERC-20 transfer всегда берёт токены с адреса, который подписал транзакцию.
В примере это sender, потому что контракт подключён к signer:
const token = await factory.deploy(initialSupply);
const tx = await token.transfer(recipient, amount);Если подключить тот же контракт к другому signer, transfer попытается списать токены уже с другого адреса. Для перевода чужих токенов нужен не transfer, а связка approve + transferFrom.
Почему нужен parseUnits
Контракт не принимает "12.5" как число токенов.
ERC-20 работает с uint256, поэтому в транзакцию нужно передать raw-значение:
12.5 DEMO при decimals = 18
= 12500000000000000000ethers.parseUnits("12.5", decimals) делает это преобразование без потери точности.
Частые ошибки
Передавать 12.5 как JavaScript number → используйте строку и ethers.parseUnits("12.5", decimals).
Думать, что tx.hash означает успешный перевод → hash означает только отправку транзакции. Проверяйте результат через tx.wait() и receipt.status.
Вызывать transfer через provider → provider умеет читать сеть, но не подписывает транзакции. Для transfer нужен signer.
Что дальше
- ERC-20: approve и allowance — разрешить контракту списывать токены пользователя
- ERC-20: transferFrom (DeFi паттерн) — понять, как DeFi-контракты забирают токены после approve
- Статусы транзакции — отличать pending, confirmed и failed после отправки write-вызова