Розробка системи автоматичного листингу на DEX при досягненні ринкової капіталізації
Завдання: токен продається на presale, і при досягненні певної суми (hardcap або softcap) ліквідність автоматично листується на Uniswap або аналогічному DEX — без ручного втручання команди. Це усуває людський фактор та rug pull ризик: команда не може «затримати» додавання ліквідності або додати її на невигідних умовах.
Механіка: як це працює
Смарт-контракт presale утримує зібрані кошти (ETH або USDC) в escrow. При виконанні умови (досягнуто ціль) — автоматично викликає Uniswap Router для створення пулу та додавання ліквідності. LP токени (доказ ліквідності) відправляються на lock контракт або спалюються, запобігаючи виведенню ліквідності.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
interface IUniswapV2Router02 {
function addLiquidityETH(
address token,
uint amountTokenDesired,
uint amountTokenMin,
uint amountETHMin,
address to,
uint deadline
) external payable returns (uint amountToken, uint amountETH, uint liquidity);
function factory() external pure returns (address);
}
interface IUniswapV2Factory {
function createPair(address tokenA, address tokenB) external returns (address pair);
function getPair(address tokenA, address tokenB) external view returns (address pair);
}
contract AutoListingPresale is Ownable2Step, ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public immutable token;
IUniswapV2Router02 public immutable router;
uint256 public immutable softCap; // мінімум для успіху presale
uint256 public immutable hardCap; // максимум збору
uint256 public immutable tokenPrice; // wei за токен (18 decimals)
uint256 public immutable listingPercent; // % зібраного ETH для ліквідності
uint256 public immutable listingTokenPercent; // % токенів для ліквідності
uint256 public immutable saleEnd;
uint256 public totalRaised;
bool public finalized;
bool public listed;
address public liquidityPair;
mapping(address => uint256) public contributions;
event Contribution(address indexed contributor, uint256 ethAmount);
event Finalized(bool success, uint256 totalRaised);
event ListedOnDEX(address pair, uint256 ethLiquidity, uint256 tokenLiquidity);
event Refunded(address indexed contributor, uint256 amount);
constructor(
address _token,
address _router,
uint256 _softCap,
uint256 _hardCap,
uint256 _tokenPrice,
uint256 _listingPercent,
uint256 _listingTokenPercent,
uint256 _saleEndTimestamp,
address _owner
) Ownable2Step() {
token = IERC20(_token);
router = IUniswapV2Router02(_router);
softCap = _softCap;
hardCap = _hardCap;
tokenPrice = _tokenPrice;
listingPercent = _listingPercent;
listingTokenPercent = _listingTokenPercent;
saleEnd = _saleEndTimestamp;
_transferOwnership(_owner);
}
// Участь у presale
receive() external payable {
_contribute(msg.sender, msg.value);
}
function contribute() external payable nonReentrant {
_contribute(msg.sender, msg.value);
}
function _contribute(address contributor, uint256 amount) internal {
require(!finalized, "Presale finalized");
require(block.timestamp < saleEnd, "Sale ended");
require(totalRaised + amount <= hardCap, "Hard cap reached");
require(amount > 0, "Zero contribution");
contributions[contributor] += amount;
totalRaised += amount;
emit Contribution(contributor, amount);
// Автоматичний листинг при hardcap
if (totalRaised >= hardCap) {
_finalize();
}
}
// Завершення після закінчення продажу або при hardcap
function finalize() external {
require(block.timestamp >= saleEnd || totalRaised >= hardCap, "Too early");
require(!finalized, "Already finalized");
_finalize();
}
function _finalize() internal {
finalized = true;
bool success = totalRaised >= softCap;
emit Finalized(success, totalRaised);
if (success) {
_listOnDEX();
}
// Якщо softCap не досягнуто — користувачі отримають рефанд
}
function _listOnDEX() internal {
require(!listed, "Already listed");
listed = true;
// ETH для ліквідності
uint256 ethForLiquidity = totalRaised * listingPercent / 100;
// Токени для ліквідності
uint256 totalTokens = token.balanceOf(address(this));
uint256 tokensForLiquidity = totalTokens * listingTokenPercent / 100;
// Апрув роутеру
token.safeApprove(address(router), tokensForLiquidity);
// Додаємо ліквідність — LP токени іють на address(0) = burn
// Це робить ліквідність постійною та нерозривною
(uint256 addedToken, uint256 addedETH, uint256 lpTokens) = router.addLiquidityETH{
value: ethForLiquidity
}(
address(token),
tokensForLiquidity,
tokensForLiquidity * 95 / 100, // 5% слиппаж
ethForLiquidity * 95 / 100,
address(0xdead), // LP токени спалюються = заблоковані назавжди
block.timestamp + 600 // дедлайн 10 хвилин
);
// Отримуємо адресу пулу
address factory = router.factory();
liquidityPair = IUniswapV2Factory(factory).getPair(
address(token),
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 // WETH
);
emit ListedOnDEX(liquidityPair, addedETH, addedToken);
// Решта ETH (за вирахуванням ліквідності) — у казну
uint256 remaining = address(this).balance;
if (remaining > 0) {
payable(owner()).transfer(remaining);
}
}
// Рефанд при неудачі (softCap не досягнуто)
function claimRefund() external nonReentrant {
require(finalized, "Not finalized");
require(totalRaised < softCap, "Presale successful, no refund");
uint256 amount = contributions[msg.sender];
require(amount > 0, "No contribution");
contributions[msg.sender] = 0;
payable(msg.sender).transfer(amount);
emit Refunded(msg.sender, amount);
}
// Клейм токенів при успіху
function claimTokens() external nonReentrant {
require(finalized && listed, "Not listed yet");
uint256 contribution = contributions[msg.sender];
require(contribution > 0, "Nothing to claim");
// Токени учасника (пропорційно внеску)
uint256 participantTokens = token.balanceOf(address(this))
* contribution / totalRaised;
contributions[msg.sender] = 0;
token.safeTransfer(msg.sender, participantTokens);
}
}
Розрахунок листингової ціни
Листингова ціна на DEX визначається співвідношенням токенів та ETH у пулі при створенні. Це потрібно чітко коммуніцувати учасникам presale:
Ціна presale: 0.001 ETH за токен
Hardcap: 100 ETH
listingPercent: 70% (70 ETH у ліквідність)
listingTokenPercent: 20% (з alокації presale)
Припустимо, продано 100,000,000 токенів за 100 ETH
Токени для ліквідності: 20,000,000
ETH для ліквідності: 70 ETH
Листингова ціна: 70 / 20,000,000 = 0.0000035 ETH
Це вище ціни presale → учасники в прибутку з моменту листингу
Якщо листингова ціна нижче ціни presale — учасники presale одразу в убитку. Погано для репутації проекту. Розраховуйте параметри заздалегідь.
Листинг Uniswap V3
Uniswap V2 простіше для автолістингу (немає цінового діапазону). Uniswap V3 дозволяє зосереджену ліквідність, але вимагає вказання цінового діапазону:
import "@uniswap/v3-periphery/contracts/interfaces/INonfungiblePositionManager.sol";
function _listOnUniswapV3() internal {
INonfungiblePositionManager posManager = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88);
// Створюємо пул з початковою ціною
uint160 sqrtPriceX96 = calculateSqrtPrice(ethForLiquidity, tokensForLiquidity);
posManager.createAndInitializePoolIfNecessary(
address(token),
WETH_ADDRESS,
3000, // 0.3% fee tier
sqrtPriceX96
);
// Мінтим позицію з широким діапазоном (майже повний діапазон)
INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({
token0: address(token) < WETH_ADDRESS ? address(token) : WETH_ADDRESS,
token1: address(token) < WETH_ADDRESS ? WETH_ADDRESS : address(token),
fee: 3000,
tickLower: -887220, // майже мінімум
tickUpper: 887220, // майже максимум
amount0Desired: ...,
amount1Desired: ...,
amount0Min: 0,
amount1Min: 0,
recipient: address(0xdead), // спалюємо LP NFT
deadline: block.timestamp + 600
});
posManager.mint{value: ethForLiquidity}(params);
}
Для V3 потрібен WETH wrap токенів ETH через WETH.deposit{value: amount}(). Складніше, але дозволяє більш ефективну ліквідність.
LP Lock альтернативи
Замість спалювання LP токенів (address(0xdead)) — відправка на Lock контракт з часовою блокуванням:
contract LPLocker {
struct Lock {
address token; // адреса LP токена
uint256 amount;
uint256 unlockAt;
address owner;
}
mapping(uint256 => Lock) public locks;
uint256 public lockCount;
function lock(address lpToken, uint256 amount, uint256 unlockTimestamp)
external returns (uint256 lockId)
{
require(unlockTimestamp > block.timestamp, "Invalid unlock time");
lockId = ++lockCount;
IERC20(lpToken).safeTransferFrom(msg.sender, address(this), amount);
locks[lockId] = Lock({
token: lpToken,
amount: amount,
unlockAt: unlockTimestamp,
owner: msg.sender
});
}
function unlock(uint256 lockId) external {
Lock storage lk = locks[lockId];
require(msg.sender == lk.owner, "Not owner");
require(block.timestamp >= lk.unlockAt, "Still locked");
IERC20(lk.token).safeTransfer(lk.owner, lk.amount);
delete locks[lockId];
}
}
Публічний lock сервіс (UNCX, Team.Finance, PinkLock) зручніший з точки зору довіри — користувачі можуть перевірити lock у відомому інтерфейсі.
Безпека
Reentrancy: функції з ETH transfer + token transfer потенційно вразливі. nonReentrant на все публічні функції обов'язково.
Front-running листингу: MEV боти можуть front-run транзакцію листингу, купивши токени до додавання ліквідності. Якщо токен торгується до листингу (малоймовірно при правильній архітектурі) — використовуйте commit-reveal або flashbots bundle.
Slippage в addLiquidityETH: amountTokenMin та amountETHMin не повинні бути 0 — інакше sandwich-атака може додати непропорційну ліквідність. 3–5% слиппаж достатньо.
Час розробки: 2–3 тижні включаючи presale контракт, LP locker, тести на fork. Аудит обов'язковий — контракт утримує кошти учасників.







