EIP-2612: permit и gasless approve
Как ERC-20 permit заменяет approve-транзакцию: подписать EIP-712 разрешение, отправить permit через relayer и получить allowance.
permit — это approve через подпись
EIP-2612 добавляет в ERC-20 функцию permit, которая записывает allowance по EIP-712 подписи владельца.
Владелец токенов не отправляет approve транзакцию и не платит gas. Он подписывает typed data off-chain, а транзакцию permit() может отправить spender, backend или relayer.
Подписываем permit и записываем allowance
Создайте проект:
mkdir eip2612-permit-example
cd eip2612-permit-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/PermitToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PermitToken {
string public constant name = "Permit Demo Token";
string public constant symbol = "PDT";
string public constant version = "1";
uint8 public constant decimals = 18;
uint256 public totalSupply;
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 public constant PERMIT_TYPEHASH =
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
mapping(address => uint256) public nonces;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
constructor(uint256 initialSupply) {
uint256 chainId;
assembly {
chainId := chainid()
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes(name)),
keccak256(bytes(version)),
chainId,
address(this)
)
);
totalSupply = initialSupply;
balanceOf[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender, initialSupply);
}
function approve(address spender, uint256 value) external returns (bool) {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
return true;
}
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "permit expired");
require(owner != address(0), "zero owner");
uint256 nonce = nonces[owner];
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonce, deadline)
);
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
);
address recovered = ecrecover(digest, v, r, s);
require(recovered == owner, "invalid signature");
nonces[owner] = nonce + 1;
allowance[owner][spender] = value;
emit Approval(owner, spender, value);
}
}Создайте файл scripts/permit-token.ts:
import { readFileSync } from "node:fs";
import { ethers } from "ethers";
import solc from "solc";
const source = readFileSync("contracts/PermitToken.sol", "utf8");
const input = {
language: "Solidity",
sources: {
"PermitToken.sol": {
content: source,
},
},
settings: {
outputSelection: {
"*": {
"*": ["abi", "evm.bytecode.object"],
},
},
},
};
const output = JSON.parse(solc.compile(JSON.stringify(input)));
const contract = output.contracts["PermitToken.sol"].PermitToken;
const abi = contract.abi;
const bytecode = `0x${contract.evm.bytecode.object}`;
const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");
// Первый тестовый аккаунт Hardhat. Он владеет токенами и подписывает permit.
const owner = new ethers.Wallet(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
provider,
);
// Второй тестовый аккаунт Hardhat. Он получит allowance.
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
// Третий тестовый аккаунт Hardhat. Он отправит permit-транзакцию и заплатит gas.
const relayer = new ethers.Wallet(
"0x5de4111a56fb4c77293da43f93d18f8ee7d99d6b201a790d22d7bce5863e604a",
provider,
);
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 network = await provider.getNetwork();
const value = ethers.parseUnits("75", 18);
const nonce = await token.nonces(owner.address);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
const domain = {
name: "Permit Demo Token",
version: "1",
chainId: network.chainId,
verifyingContract: tokenAddress,
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const permit = {
owner: owner.address,
spender,
value,
nonce,
deadline,
};
const signature = await owner.signTypedData(domain, types, permit);
const { v, r, s } = ethers.Signature.from(signature);
console.log("Token address:", tokenAddress);
console.log("Owner:", owner.address);
console.log("Spender:", spender);
console.log("Relayer:", relayer.address);
console.log("Allowance before:", ethers.formatUnits(await token.allowance(owner.address, spender), 18));
console.log("Owner nonce before:", nonce.toString());
const tokenAsRelayer = new ethers.Contract(tokenAddress, abi, relayer);
const tx = await tokenAsRelayer.permit(
owner.address,
spender,
value,
deadline,
v,
r,
s,
);
console.log("Permit hash:", tx.hash);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) {
throw new Error("Транзакция permit() не была успешно выполнена");
}
console.log("Block:", receipt.blockNumber);
console.log("Allowance after:", ethers.formatUnits(await token.allowance(owner.address, spender), 18));
console.log("Owner nonce after:", (await token.nonces(owner.address)).toString());Запустите локальную сеть Hardhat в отдельном терминале:
npx hardhat nodeВо втором терминале выполните скрипт:
npx tsx scripts/permit-token.tsСкрипт задеплоит ERC-20 с permit, подпишет разрешение на 75 PDT от имени владельца и отправит permit() транзакцию от relayer. Владелец токенов не отправляет approve.
Как это работает
owner.signTypedData(domain, types, permit) — создаёт EIP-712 подпись разрешения.
domain — привязывает подпись к имени токена, версии, chainId и адресу контракта.
Permit — структура данных, которую понимает EIP-2612: owner, spender, value, nonce, deadline.
ethers.Signature.from(signature) — разбирает подпись на v, r, s для Solidity-функции.
tokenAsRelayer.permit(...) — отправляет транзакцию от relayer. Gas платит relayer, но allowance записывается от имени owner.
nonces[owner] — защищает подпись от повторного использования.
Почему это gasless approve
Обычный approve требует транзакцию от владельца:
owner -> approve(spender, value)
owner платит gaspermit делит процесс на две части:
1. owner подписывает typed data off-chain
2. relayer отправляет permit(owner, spender, value, deadline, v, r, s)Для владельца это gasless: он только подписывает сообщение. Но сама запись в блокчейн всё равно стоит gas, просто платит другой адрес.
Nonce и deadline
nonce делает подпись одноразовой.
После успешного permit контракт увеличивает nonces[owner]. Если кто-то попробует отправить ту же подпись второй раз, проверка подписи уже не совпадёт с новым nonce.
deadline ограничивает время жизни подписи:
block.timestamp <= deadlineЕсли срок истёк, permit должен сделать revert. Без deadline старая подпись могла бы всплыть через месяцы.
Где это используют
DeFi-приложение может объединить разрешение и действие в один пользовательский flow:
1. Пользователь подписывает permit на 100 USDC
2. Backend или router отправляет permit
3. Router сразу вызывает transferFrom(user, pool, 100 USDC)Пользователь не делает отдельную approve транзакцию перед swap, deposit или repay.
Частые ошибки
Менять domain после подписи → name, version, chainId и verifyingContract входят в подпись. Любое отличие сделает подпись недействительной.
Использовать старый nonce → перед подписью всегда читайте актуальный nonces(owner) из контракта.
Думать, что gas вообще не нужен → gas не платит владелец подписи, но транзакцию permit() всё равно кто-то должен отправить.
Что дальше
- Typed Data подпись — EIP-712 — понять формат подписи, на котором построен permit
- ERC-20: approve и allowance — сравнить permit с обычным approve
- ERC-20: transferFrom (DeFi паттерн) — использовать allowance после permit