Разработка системы защиты от rug pull
Rug pull — это когда команда проекта намеренно выводит ликвидность и/или ETH/токены из контракта, оставляя держателей с обесцененными токенами. Это не технический баг — это умысел. Но техника, которая делает rug pull возможным, вполне конкретна и предсказуема. Система защиты строится на двух уровнях: on-chain механики, которые физически не дают команде вывести ликвидность, и off-chain мониторинг, который алертирует о подозрительных действиях до момента исполнения.
Классификация rug pull векторов
Liquidity removal
Самый распространённый вектор. Команда добавляет ликвидность в Uniswap/PancakeSwap, запускает маркетинг, цена растёт → команда выводит ликвидность. LP токены у команды, ограничений нет.
Mint без ограничений
Команда деплоит токен с функцией mint(address, amount) доступной owner-у. В любой момент можно выминтить миллиарды токенов и продать их на рынке.
// ОПАСНЫЙ ПАТТЕРН
contract HoneypotToken is ERC20, Ownable {
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount); // Неограниченный mint для owner
}
}
Hidden transfer restrictions
Контракт позволяет только определённым адресам продавать токен (sell restriction), блокируя обычных холдеров от продажи. Owner может добавлять/удалять адреса из blacklist или whitelist.
// ОПАСНЫЙ ПАТТЕРН — sell restriction
mapping(address => bool) private _blacklisted;
function _beforeTokenTransfer(address from, address to, uint256 amount) internal override {
if (from != owner() && to != uniswapPair) {
require(!_blacklisted[from], "Transfer restricted");
}
super._beforeTokenTransfer(from, to, amount);
}
Proxy upgrade backdoor
Контракт за UUPS или Transparent proxy с owner-ом способным upgrade без governance. После TGE и накопления ликвидности — owner делает upgrade который добавляет drain функцию.
Fee manipulation
Стандартный deflationary токен с fee. Команда может изменить fee до 99% — любая продажа отдаёт почти все средства команде.
uint256 public sellFee = 3; // 3% — кажется разумно
// Позже:
function setSellFee(uint256 fee) external onlyOwner {
sellFee = fee; // Может стать 99%
}
On-chain защитные механики
Locked liquidity через Unicrypt / PinkLock
Простейшая защита: LP токены локируются в smart contract с timelock. Команда физически не может вывести ликвидность до истечения срока.
// Интерфейс Unicrypt Liquidity Locker
interface IUnicryptLocker {
function lockLPToken(
address lpToken,
uint256 amount,
uint256 unlockDate, // UNIX timestamp
address payable referral,
bool feeInEth,
address payable withdrawer
) external payable;
function withdraw(
address lpToken,
uint256 lockIndex,
uint256 lockId,
uint256 amount
) external;
}
// При запуске: локируем LP на минимум 1 год
function lockInitialLiquidity(address lpToken, uint256 amount) external onlyOwner {
IERC20(lpToken).approve(address(unicryptLocker), amount);
unicryptLocker.lockLPToken{value: lockFee}(
lpToken,
amount,
block.timestamp + 365 days, // 1 год lock
payable(address(0)), // no referral
true, // fee in ETH
payable(msg.sender)
);
emit LiquidityLocked(lpToken, amount, block.timestamp + 365 days);
}
Проверка для пользователей: на Unicrypt или Team.Finance можно проверить lock по адресу токена или LP токена.
Renounced ownership и Ownable2Step
Если команда renounce ownership — никто не может вызывать onlyOwner функции. Это максимальная защита, но делает контракт immutable полностью, включая полезные функции.
Компромисс — ограниченный owner через Ownable2Step с ограниченными полномочиями:
contract ProtectedToken is ERC20, Ownable2Step {
// Owner может ТОЛЬКО менять marketing wallet и fee параметры
// в заранее определённых пределах
uint256 public constant MAX_SELL_FEE = 500; // максимум 5% — hardcoded, неизменно
uint256 public sellFee = 300; // текущие 3%
function setSellFee(uint256 newFee) external onlyOwner {
require(newFee <= MAX_SELL_FEE, "Fee exceeds maximum");
sellFee = newFee;
emit SellFeeUpdated(newFee);
}
// Нет mint функции
// Нет blacklist/whitelist
// Нет функции изменения transfer restrictions
}
Mint cap и fixed supply
Ограничение максимального supply hardcoded в контракте — не через переменную, а через константу:
uint256 public constant MAX_SUPPLY = 1_000_000_000 * 1e18; // 1 billion, immutable
function mint(address to, uint256 amount) external onlyMinter {
require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
_mint(to, amount);
}
// После TGE — minter role отзывается
function renounceMinterRole() external onlyOwner {
minter = address(0);
emit MinterRevoked();
}
Timelock для critical functions
Любые изменения критических параметров должны проходить через timelock — задержку с публичным объявлением.
contract TimelockProtectedToken is ERC20 {
uint256 public constant TIMELOCK_DURATION = 48 hours;
struct PendingChange {
bytes32 changeType;
uint256 newValue;
uint256 executableAt;
bool executed;
}
mapping(bytes32 => PendingChange) public pendingChanges;
function proposeFeeChange(uint256 newFee) external onlyOwner {
require(newFee <= 500, "Too high");
bytes32 changeId = keccak256(abi.encodePacked("fee", newFee, block.timestamp));
pendingChanges[changeId] = PendingChange({
changeType: "fee",
newValue: newFee,
executableAt: block.timestamp + TIMELOCK_DURATION,
executed: false
});
emit FeeChangeProposed(changeId, newFee, block.timestamp + TIMELOCK_DURATION);
}
function executeFeeChange(bytes32 changeId) external onlyOwner {
PendingChange storage change = pendingChanges[changeId];
require(!change.executed, "Already executed");
require(block.timestamp >= change.executableAt, "Timelock not passed");
require(change.changeType == "fee", "Wrong type");
change.executed = true;
sellFee = change.newValue;
emit FeeChanged(change.newValue);
}
}
Timelock даёт сообществу 48 часов чтобы увидеть изменение и выйти до его применения.
Off-chain мониторинг: ранняя детекция
Анализ контракта перед покупкой
Автоматический scanner контракта до взаимодействия с токеном:
interface ContractRiskAssessment {
address: string;
risks: RiskItem[];
score: number; // 0 (safe) to 100 (high risk)
}
interface RiskItem {
severity: 'critical' | 'high' | 'medium' | 'low';
type: string;
description: string;
}
async function analyzeToken(tokenAddress: string): Promise<ContractRiskAssessment> {
const risks: RiskItem[] = [];
// 1. Проверяем наличие mint функции
const hasMintFunction = await checkFunctionExists(tokenAddress, 'mint(address,uint256)');
if (hasMintFunction) {
const mintRestricted = await checkMintHasSupplyCap(tokenAddress);
if (!mintRestricted) {
risks.push({
severity: 'critical',
type: 'UNLIMITED_MINT',
description: 'Owner can mint unlimited tokens'
});
}
}
// 2. Проверяем owner
const owner = await getContractOwner(tokenAddress);
const ownerRenounced = owner === ZERO_ADDRESS;
if (!ownerRenounced) {
// Проверяем что может делать owner
const ownerCapabilities = await getOwnerCapabilities(tokenAddress);
if (ownerCapabilities.canChangeTransferRestrictions) {
risks.push({
severity: 'high',
type: 'TRANSFER_RESTRICTION',
description: 'Owner can restrict token transfers (potential honeypot)'
});
}
}
// 3. LP lock status
const lpToken = await getUniswapLPToken(tokenAddress);
if (lpToken) {
const lockStatus = await checkLiquidityLock(lpToken);
if (!lockStatus.isLocked) {
risks.push({
severity: 'high',
type: 'UNLOCKED_LIQUIDITY',
description: 'Liquidity is not locked'
});
} else if (lockStatus.unlockTimestamp < Date.now() / 1000 + 30 * 24 * 3600) {
risks.push({
severity: 'medium',
type: 'SHORT_LOCK',
description: `Liquidity unlocks in ${lockStatus.daysRemaining} days`
});
}
}
// 4. Proxy check
const isProxy = await isProxyContract(tokenAddress);
if (isProxy) {
const proxyAdmin = await getProxyAdmin(tokenAddress);
const proxyAdminIsTimelocked = await isTimelocked(proxyAdmin);
if (!proxyAdminIsTimelocked) {
risks.push({
severity: 'high',
type: 'UPGRADEABLE_NO_TIMELOCK',
description: 'Upgradeable contract without timelock on admin'
});
}
}
const score = calculateRiskScore(risks);
return { address: tokenAddress, risks, score };
}
Real-time мониторинг транзакций
После покупки токена — продолжаем мониторинг:
// Следим за critical событиями
async function monitorToken(tokenAddress: string, alertCallback: (alert: Alert) => void) {
const client = createPublicClient({ transport: webSocket(WS_RPC_URL) });
// 1. Мониторинг OwnershipTransferred — renounce или смена owner
client.watchContractEvent({
address: tokenAddress,
abi: OWNABLE_ABI,
eventName: 'OwnershipTransferred',
onLogs: (logs) => {
const newOwner = logs[0].args.newOwner;
if (newOwner === ZERO_ADDRESS) {
alertCallback({ type: 'OWNERSHIP_RENOUNCED', severity: 'info', tokenAddress });
} else {
alertCallback({ type: 'OWNERSHIP_TRANSFERRED', severity: 'high', tokenAddress, data: { newOwner } });
}
}
});
// 2. Мониторинг крупных transfer от deployer/team адресов
const deployer = await getContractDeployer(tokenAddress);
client.watchContractEvent({
address: tokenAddress,
abi: ERC20_ABI,
eventName: 'Transfer',
args: { from: deployer },
onLogs: async (logs) => {
for (const log of logs) {
const balance = await getBalance(tokenAddress, deployer);
const totalSupply = await getTotalSupply(tokenAddress);
const pct = (balance * 100n) / totalSupply;
if (pct < 5n) { // deployer продал >95% своих токенов
alertCallback({ type: 'DEPLOYER_DUMP', severity: 'critical', tokenAddress });
}
}
}
});
// 3. Мониторинг LP removals
const lpToken = await getUniswapLPToken(tokenAddress);
if (lpToken) {
client.watchContractEvent({
address: UNISWAP_V2_ROUTER,
abi: UNISWAP_V2_ROUTER_ABI,
eventName: 'RemoveLiquidity',
onLogs: async (logs) => {
// Проверяем что это наш токен
for (const log of logs) {
if (log.args.tokenA === tokenAddress || log.args.tokenB === tokenAddress) {
alertCallback({ type: 'LIQUIDITY_REMOVED', severity: 'critical', tokenAddress, data: log.args });
}
}
}
});
}
}
DEXTools / Token Sniffer интеграция
Вместо написания всего с нуля — интеграция с существующими API:
| Сервис | API | Что проверяет |
|---|---|---|
| Token Sniffer | /api/v2/tokens/{chain}/{address} |
Honeypot, mint, blacklist, proxies |
| GoPlus Security | api.gopluslabs.io/api/v1/token_security/{chainId} |
30+ security checks |
| DEXTools | API (платный) | Locks, holder distribution, trading patterns |
| Rugcheck.xyz | rugcheck.xyz/api/tokens/{address}/report |
Solana и EVM токены |
async function getGoPlus SecurityReport(chainId: number, tokenAddress: string) {
const response = await fetch(
`https://api.gopluslabs.io/api/v1/token_security/${chainId}?contract_addresses=${tokenAddress}`
);
const data = await response.json();
const report = data.result[tokenAddress.toLowerCase()];
return {
isHoneypot: report.is_honeypot === '1',
canTakeBackOwnership: report.can_take_back_ownership === '1',
ownerCanChangeBalance: report.owner_change_balance === '1',
hasMintFunction: report.is_mintable === '1',
isProxy: report.is_proxy === '1',
sellTax: parseFloat(report.sell_tax || '0'),
buyTax: parseFloat(report.buy_tax || '0'),
liquidityLocked: report.lp_locked === '1',
holders: report.holder_count,
top10HolderPercent: parseFloat(report.token_name || '0')
};
}
Honeypot detection: симуляция продажи
Лучший способ проверить honeypot — симулировать sell транзакцию через fork:
async function simulateSell(
tokenAddress: string,
amount: bigint,
holderAddress: string
): Promise<{ canSell: boolean; receivedAmount: bigint; errorReason?: string }> {
// Форкаем mainnet локально
const anvil = await startAnvil({ forkUrl: MAINNET_RPC, forkBlockNumber: 'latest' });
try {
// Имперсонируем holder адрес
await anvil.impersonateAccount(holderAddress);
const router = getContract({ address: UNISWAP_V2_ROUTER, abi: ROUTER_ABI });
const token = getContract({ address: tokenAddress, abi: ERC20_ABI });
// Approve
await token.write.approve([UNISWAP_V2_ROUTER, amount], { account: holderAddress });
// Simulate sell
const ethBalanceBefore = await anvil.getBalance(holderAddress);
await router.write.swapExactTokensForETHSupportingFeeOnTransferTokens(
[amount, 0n, [tokenAddress, WETH], holderAddress, BigInt(Date.now()) + 1000n],
{ account: holderAddress }
);
const ethBalanceAfter = await anvil.getBalance(holderAddress);
const receivedETH = ethBalanceAfter - ethBalanceBefore;
return { canSell: true, receivedAmount: receivedETH };
} catch (error) {
return { canSell: false, receivedAmount: 0n, errorReason: error.message };
} finally {
await anvil.close();
}
}
Эта симуляция ловит honeypot который технически позволяет транзакцию пройти, но transfer fee = 99%.
Интеграция в продукт
Система защиты от rug pull может быть реализована как:
- Browser extension — предупреждение при попытке approve или swap для risky токена
- DEX frontend middleware — проверка перед подтверждением swap транзакции
- Portfolio tracker — мониторинг уже купленных токенов
- Telegram/Discord bot — алерты для токенов из watchlist
Ни один автоматический инструмент не даёт 100% защиты — опытный мошенник может создать контракт, который проходит все автоматические проверки. Но поднятие планки усилий делает атаки менее выгодными и отсекает большинство opportunistic rug pulls.
Компоненты системы и сроки
| Компонент | Описание | Срок |
|---|---|---|
| Contract analyzer | Статический анализ bytecode + ABI | 3–4 нед |
| Honeypot simulator | Anvil fork + симуляция продажи | 2–3 нед |
| Real-time monitor | WebSocket event listener + алерты | 2–3 нед |
| LP lock checker | Интеграция с Unicrypt, PinkLock, Team.Finance | 1–2 нед |
| 3rd party integration | GoPlus, Token Sniffer API | 1 нед |
| Frontend/bot | UI или Telegram бот для алертов | 2–4 нед |
| База данных | История проверок, кеширование | 1–2 нед |







