китаев.tech

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

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

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

typescript
import { defineConfig } from "hardhat/config";

export default defineConfig({
  solidity: {
    version: "0.8.20",
  },
});

Создайте файл contracts/PermitToken.sol:

solidity
// 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:

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

bash
npx hardhat node

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

bash
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 требует транзакцию от владельца:

text
owner -> approve(spender, value)
owner платит gas

permit делит процесс на две части:

text
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 ограничивает время жизни подписи:

text
block.timestamp <= deadline

Если срок истёк, permit должен сделать revert. Без deadline старая подпись могла бы всплыть через месяцы.


Где это используют

DeFi-приложение может объединить разрешение и действие в один пользовательский flow:

text
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() всё равно кто-то должен отправить.


Что дальше

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