Разработка системы управления одобрениями токенов (revoke)
approve(spender, type(uint256).max) — строка в транзакции, которую большинство пользователей подписывают не глядя, потому что без неё dApp не работает. Результат: сотни контрактов с неограниченным доступом к токенам кошелька. Когда один из них взламывают, атакующий дренирует всё — не только ту транзакцию, для которой выдавалось разрешение. Revoke.cash и Etherscan Token Approvals решают эту проблему для end users, но если нужна кастомная система для конкретного протокола, white-label продукта или корпоративного кошелька — это отдельная разработка.
Технические основы: как работают approvals
ERC-20 allowance
Стандарт ERC-20 определяет allowance(owner, spender) — сколько токенов spender может тратить от имени owner. Устанавливается через approve(spender, amount). Значение type(uint256).max (2^256-1) означает «бесконечно» — большинство протоколов требуют именно это для удобства.
Проблема: allowance не имеет срока истечения. Нет механизма автоматической отмены. Если протокол скомпрометирован через год после вашего approve — allowance всё ещё активна.
ERC-721 и ERC-1155 approvals
Для NFT два типа approvals:
-
approve(operator, tokenId)— разрешение на конкретный токен -
setApprovalForAll(operator, true)— полный доступ ко всей коллекции
setApprovalForAll используется OpenSea, blur.io и другими маркетплейсами. Это наиболее опасный тип — один взломанный маркетплейс с активным setApprovalForAll = вся коллекция потеряна.
EIP-2612: Permit
permit(owner, spender, value, deadline, v, r, s) — подпись вместо транзакции. Не создаёт постоянного allowance, работает разово с конкретным deadline. Правильно спроектированные dApps используют permit вместо approve.
Но permit имеет нюанс: если DAI, USDC или другой токен поддерживает permit — allowance через permit тоже можно посмотреть через allowance(). Они неотличимы от обычных approve.
Чтение данных об approvals
Через событие Approval
Прямой запрос allowance(owner, spender) требует знать адрес spender. Чтобы получить все активные approvals кошелька — нужно читать события:
import { createPublicClient, http, parseAbi } from 'viem';
const ERC20_ABI = parseAbi([
'event Approval(address indexed owner, address indexed spender, uint256 value)',
'function allowance(address owner, address spender) view returns (uint256)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
]);
async function getTokenApprovals(ownerAddress: `0x${string}`) {
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) });
// Получаем все Approval события где owner = наш адрес
const approvalLogs = await client.getLogs({
event: ERC20_ABI[0], // Approval event
args: { owner: ownerAddress },
fromBlock: 0n,
toBlock: 'latest'
});
// Дедупликация: оставляем только последний Approval для каждой пары token+spender
const latestApprovals = new Map<string, typeof approvalLogs[0]>();
for (const log of approvalLogs) {
const key = `${log.address}-${log.args.spender}`;
latestApprovals.set(key, log); // более поздние перезаписывают более ранние
}
// Проверяем текущий allowance для каждой пары
const results = await Promise.all(
Array.from(latestApprovals.values()).map(async (log) => {
const [allowance, symbol, decimals] = await Promise.all([
client.readContract({
address: log.address,
abi: ERC20_ABI,
functionName: 'allowance',
args: [ownerAddress, log.args.spender!]
}),
client.readContract({ address: log.address, abi: ERC20_ABI, functionName: 'symbol' }),
client.readContract({ address: log.address, abi: ERC20_ABI, functionName: 'decimals' }),
]);
return {
tokenAddress: log.address,
spenderAddress: log.args.spender!,
allowance,
symbol,
decimals,
isUnlimited: allowance === BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'),
};
})
);
// Фильтруем нулевые allowances (уже отозванные)
return results.filter(r => r.allowance > 0n);
}
Проблема с историческими данными
getLogs с fromBlock: 0n — медленный и дорогой запрос для публичных RPC. Решения:
- The Graph: индексируем Approval события через субграф, GraphQL запросы мгновенные
-
Etherscan/Alchemy API: готовые endpoint для token approvals (
alchemy_getTokenAllowances) - Incremental indexing: отслеживаем последний проиндексированный блок, при каждом обновлении запрашиваем только новые события
Для production системы The Graph субграф — оптимальное решение. Один запрос возвращает все активные approvals с метаданными.
Revoke операции
ERC-20 revoke
Revoke = approve(spender, 0). Одна транзакция на каждую пару token+spender.
async function revokeERC20Approval(
tokenAddress: `0x${string}`,
spenderAddress: `0x${string}`
) {
const { writeContract } = useWriteContract();
writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [spenderAddress, 0n]
});
}
Batch revoke через Multicall
Отзыв 10 approvals = 10 транзакций, 10 подписей пользователя. Это неприемлемо. Но! ERC-20 approve нельзя вызвать от имени пользователя без его подписи — нет multicall способа сделать batch approve/revoke за один клик без кастомного контракта или Permit2.
Permit2 batch revoke (если пользователь использует Permit2): Permit2 поддерживает lockdown(TokenSpenderPair[] calldata approvals) — отзывает несколько Permit2 allowances в одном вызове. Но не отзывает прямые ERC-20 approvals.
Practical решение: очередь revoke транзакций с автоматической отправкой следующей после подтверждения предыдущей. UI показывает прогресс Отзываю 3 из 8.... Пользователь подписывает каждую, но не ждёт вручную — pop-up следующей появляется автоматически.
async function batchRevoke(approvals: Approval[]) {
for (const approval of approvals) {
await writeContractAsync({
address: approval.tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [approval.spenderAddress, 0n]
});
// Ждём подтверждение перед следующей
await waitForTransaction({ hash: txHash });
}
}
ERC-721 / ERC-1155 revoke
setApprovalForAll(operator, false) — отзыв полного доступа к коллекции. Более критично, поэтому в UI выделяем красным. approve(operator, tokenId) с последующим revoke менее критичен — доступ к конкретному токену.
UI дизайн системы
Таблица approvals
Основной компонент — таблица с сортировкой и фильтрацией:
| Токен | Spender | Allowance | Риск | Действие |
|---|---|---|---|---|
| USDC | Uniswap V3 | Unlimited | Средний | Revoke |
| WETH | Old Protocol (deprecated) | Unlimited | Высокий | Revoke |
| DAI | Aave V3 | 1,000 DAI | Низкий | Revoke |
Risk scoring — важная часть UX. Spender адреса идентифицируются через:
- Etherscan Labels API
- DefiLlama протокол базы
- Собственный whitelist известных протоколов
Verified протокол = средний риск (approve существует, но протокол надёжный). Неизвестный контракт = высокий риск. Deprecated/мёртвый контракт = критический риск.
Filters: по сети, по типу (ERC-20 / NFT), по уровню риска, только unlimited approvals.
Multi-chain
Пользователи имеют approvals на Ethereum, Base, Arbitrum, Polygon и других сетях. Система должна агрегировать данные со всех поддерживаемых chain. Параллельные запросы через Promise.all к RPC каждой сети, результаты объединяются в единый список с сетевой иконкой.
Стек разработки
React + Next.js, wagmi 2.x + viem для on-chain операций, TanStack Query для кэширования и фоновых обновлений, TanStack Table для таблицы approvals, The Graph для индексирования событий (или Alchemy Transfers API). TypeScript.
Для multi-chain: конфиг wagmi с поддерживаемыми цепями, отдельные viem publicClient для каждой сети.
Ориентиры по срокам
ERC-20 revoke система для одной сети с чтением через RPC Events, риск-скоринг по whitelist и batch revoke очередью — 2 дня. С multi-chain поддержкой (5+ сетей), ERC-721/ERC-1155 approvals, The Graph индексером и кастомным risk scoring — 3-5 дней.







