ExpoLauncher
0xb135a62dcbced4ca137242333ebecf336b6a2daa
Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
6,412 bytes
ABI entries
41
13 read · 7 write
License
none
Contract information
- Address
- 0xb135a62dcbced4ca137242333ebecf336b6a2daa
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.24+commit.e11b9ed9
- Optimization
- Enabled
- Creator
- 0x536b2cEFAe…acb269baB2
- Creation tx
- 0xb256ab3eab…0f8bda6e0d
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (13)
FEE() → uint24
TICK_SPACING() → int24
initialized() → bool
key() → address, address, uint24, int24, address
liquidity() → uint128
locked() → bool
owner() → address
pendingOwner() → address
poolId() → bytes32
poolManager() → address
tickLower() → int24
tickUpper() → int24
token() → address
Events (5)
LiquidityAddedLiquidityBurnedLiquidityRemovedOwnershipTransferStartedOwnershipTransferred
ABI
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "AlreadyInitialized",
"type": "error"
},
{
"inputs": [],
"name": "EthTransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "Locked",
"type": "error"
},
{
"inputs": [],
"name": "NoLiquidity",
"type": "error"
},
{
"inputs": [],
"name": "NoPendingUnlock",
"type": "error"
},
{
"inputs": [],
"name": "NotInitialized",
"type": "error"
},
{
"inputs": [],
"name": "NotPoolManager",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "OwnableInvalidOwner",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "OwnableUnauthorizedAccount",
"type": "error"
},
{
"inputs": [],
"name": "ReentrancyGuardReentrantCall",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"inputs": [],
"name": "TickOutOfRange",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAmounts",
"type": "error"
},
{
"inputs": [],
"name": "ZeroToken",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethAdded",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokenAdded",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint128",
"name": "liquidity",
"type": "uint128"
},
{
"indexed": false,
"internalType": "uint160",
"name": "sqrtPriceX96",
"type": "uint160"
}
],
"name": "LiquidityAdded",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "LiquidityBurned",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethOut",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokenOut",
"type": "uint256"
}
],
"name": "LiquidityRemoved",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferStarted",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferred",
"type": "event"
},
{
"inputs": [],
"name": "FEE",
"outputs": [
{
"internalType": "uint24",
"name": "",
"type": "uint24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "TICK_SPACING",
"outputs": [
{
"internalType": "int24",
"name": "",
"type": "int24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "acceptOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token_",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenAmount",
"type": "uint256"
}
],
"name": "addLiquidity",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "burnLiquidity",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "initialized",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "key",
"outputs": [
{
"internalType": "address",
"name": "currency0",
"type": "address"
},
{
"internalType": "address",
"name": "currency1",
"type": "address"
},
{
"internalType": "uint24",
"name": "fee",
"type": "uint24"
},
{
"internalType": "int24",
"name": "tickSpacing",
"type": "int24"
},
{
"internalType": "address",
"name": "hooks",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "liquidity",
"outputs": [
{
"internalType": "uint128",
"name": "",
"type": "uint128"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "locked",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pendingOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolId",
"outputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolManager",
"outputs": [
{
"internalType": "contract IPoolManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "removeLiquidity",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "tickLower",
"outputs": [
{
"internalType": "int24",
"name": "",
"type": "int24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "tickUpper",
"outputs": [
{
"internalType": "int24",
"name": "",
"type": "int24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "token",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "unlockCallback",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IPoolManager, PoolKey} from "../interfaces/IUniswapV4.sol";
import {V4Lib} from "../launchpad/V4Lib.sol";
import {V4LiquidityMath} from "./V4LiquidityMath.sol";
/// @notice Uniswap V4 `ModifyLiquidityParams` — the exact tuple `PoolManager.modifyLiquidity`
/// expects. Verified field-for-field against the vendored `PoolManager.json` ABI
/// (`contracts/test/artifacts/v4/PINS.md`): {int24 tickLower, int24 tickUpper,
/// int256 liquidityDelta, bytes32 salt}.
struct ModifyLiquidityParams {
int24 tickLower;
int24 tickUpper;
int256 liquidityDelta;
bytes32 salt;
}
/// @notice The one `PoolManager` entry point missing from the repo's `IPoolManager`
/// (`contracts/src/interfaces/IUniswapV4.sol` only carries swap/settle/take/etc.).
/// Real signature confirmed against the vendored ABI: `modifyLiquidity(PoolKey,
/// ModifyLiquidityParams, bytes) returns (int256 callerDelta, int256 feesAccrued)` —
/// two packed `BalanceDelta`s. `callerDelta` already folds in `feesAccrued`, so it is
/// the single amount the caller settles (adds) or takes (removes).
interface IPoolManagerModifyLiquidity {
function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params, bytes calldata hookData)
external
returns (int256 callerDelta, int256 feesAccrued);
}
/// @title ExpoLauncher
/// @notice Team-owned launcher for the official EXPO Uniswap V4 pool. The owner (the team's
/// deployer / multisig) drives the whole launch from an admin panel:
///
/// 1. `addLiquidity` — create the ETH/EXPO pool and seed full-range liquidity. The
/// LP position is held BY THIS CONTRACT and is NOT locked.
/// 2. `removeLiquidity` — pull the entire position back to the owner. This is the
/// team's exit, available right up until they choose to burn.
/// 3. `burnLiquidity` — flip a one-way `locked` switch. After this, `removeLiquidity`
/// reverts forever, so the seeded liquidity can never be withdrawn: it is
/// permanently locked ("LP burned"). Irreversible and idempotent.
///
/// There is deliberately NO other path that can move the pool's funds: the contract
/// only ever touches the pool through these three owner-gated functions and the
/// PoolManager unlock callback they drive. `receive()` exists solely to accept the
/// native ETH the PoolManager sends back during a removal.
contract ExpoLauncher is Ownable2Step, ReentrancyGuard {
using SafeERC20 for IERC20;
// ---- fixed pool parameters (the official EXPO pool spec) ----
/// @notice 1% fee tier (fee in hundredths of a bip).
uint24 public constant FEE = 10000;
/// @notice Tick spacing paired with the 1% tier.
int24 public constant TICK_SPACING = 200;
/// @dev Uniswap V4 MAX_TICK; the aligned usable bounds are derived at runtime.
int24 internal constant MAX_TICK = 887272;
error NotPoolManager();
error NoPendingUnlock();
error ZeroToken();
error ZeroAmounts();
error AlreadyInitialized();
error NotInitialized();
error Locked();
error NoLiquidity();
error EthTransferFailed();
event LiquidityAdded(
address indexed token, uint256 ethAdded, uint256 tokenAdded, uint128 liquidity, uint160 sqrtPriceX96
);
event LiquidityRemoved(address indexed token, uint256 ethOut, uint256 tokenOut);
event LiquidityBurned(address indexed token);
uint8 private constant OP_ADD = 1;
uint8 private constant OP_REMOVE = 2;
IPoolManager public immutable poolManager;
// ---- launch state (all public so the admin panel can render it) ----
/// @notice The EXPO token seeded into the pool (currency1). address(0) until `addLiquidity`.
address public token;
/// @notice The pool key of the ETH/EXPO pool (currency0 = ETH = address(0)).
PoolKey public key;
/// @notice Full-range lower/upper ticks (aligned to `TICK_SPACING`).
int24 public tickLower;
int24 public tickUpper;
/// @notice The amount of V4 liquidity `L` currently held by this contract.
uint128 public liquidity;
/// @notice True once `addLiquidity` has created and seeded the pool.
bool public initialized;
/// @notice Once true, the LP is permanently locked: `removeLiquidity` reverts forever.
bool public locked;
/// @dev Guards `unlockCallback`: only true for the span of an unlock we ourselves started.
bool private _unlocking;
constructor() Ownable(msg.sender) {
// PoolManager (Robinhood Chain 4663 mainnet). Immutable: the launcher targets exactly
// one canonical V4 PoolManager and can never be pointed elsewhere.
poolManager = IPoolManager(0x8366a39CC670B4001A1121B8F6A443A643e40951);
}
// ================================================================= admin
/// @notice Create the ETH/EXPO V4 pool and seed full-range liquidity. The owner must
/// `approve` this contract for `tokenAmount` of `token` first, and send the ETH
/// side as `msg.value`. The pool is initialized at the price implied by
/// `msg.value : tokenAmount` (ETH is currency0, `token` is currency1). Liquidity
/// is held by this contract and is NOT locked — call `burnLiquidity` for that.
/// @param token_ the ERC-20 to pair against ETH (EXPO).
/// @param tokenAmount raw token units to pull from the owner and add.
function addLiquidity(address token_, uint256 tokenAmount) external payable onlyOwner nonReentrant {
if (initialized) revert AlreadyInitialized();
if (token_ == address(0)) revert ZeroToken();
if (msg.value == 0 || tokenAmount == 0) revert ZeroAmounts();
token = token_;
// ETH (address(0)) is always the lower currency, so currency0 = ETH, currency1 = token.
PoolKey memory k = PoolKey({
currency0: address(0),
currency1: token_,
fee: FEE,
tickSpacing: TICK_SPACING,
hooks: address(0)
});
key = k;
// Pull the token side up front so the contract can settle it inside the unlock.
IERC20(token_).safeTransferFrom(msg.sender, address(this), tokenAmount);
// sqrtPriceX96 = sqrt(currency1 / currency0) * 2^96 = sqrt(tokenAmount / msg.value) * 2^96.
// mulDiv(tokenAmount, 2^192, msg.value) then integer sqrt keeps full precision.
uint160 sqrtP = uint160(Math.sqrt(Math.mulDiv(tokenAmount, 1 << 192, msg.value)));
poolManager.initialize(k, sqrtP);
// Full-range bounds, aligned to TICK_SPACING. Typed int24 constants force real integer
// truncation on `/` (bare integer literals would fold as exact rationals and mis-align —
// a live `TickMisaligned` revert; see contracts/test/artifacts/v4/PINS.md).
int24 tu = (MAX_TICK / TICK_SPACING) * TICK_SPACING;
int24 tl = -tu;
tickLower = tl;
tickUpper = tu;
uint160 sqrtA = V4LiquidityMath.getSqrtRatioAtTick(tl);
uint160 sqrtB = V4LiquidityMath.getSqrtRatioAtTick(tu);
uint128 L = V4LiquidityMath.getLiquidityForAmounts(sqrtP, sqrtA, sqrtB, msg.value, tokenAmount);
if (L == 0) revert ZeroAmounts();
liquidity = L;
initialized = true;
uint256 ethBefore = address(this).balance; // includes msg.value
uint256 tokenBefore = IERC20(token_).balanceOf(address(this));
_unlocking = true;
poolManager.unlock(abi.encode(OP_ADD));
// Refund whatever the position did not consume back to the owner, so the contract holds
// ONLY the LP position (nothing strands here to be lost when the LP is later burned).
uint256 tokenLeft = IERC20(token_).balanceOf(address(this));
if (tokenLeft > 0) IERC20(token_).safeTransfer(owner(), tokenLeft);
uint256 ethLeft = address(this).balance;
if (ethLeft > 0) _sendEth(owner(), ethLeft);
emit LiquidityAdded(
token_, ethBefore - address(this).balance, tokenBefore - tokenLeft, L, sqrtP
);
}
/// @notice Pull the ENTIRE LP position back and forward the recovered ETH + token to the
/// owner. The team's exit — available only while the LP is not yet burned. Reverts
/// once `burnLiquidity` has been called.
function removeLiquidity() external onlyOwner nonReentrant {
if (locked) revert Locked();
if (!initialized) revert NotInitialized();
if (liquidity == 0) revert NoLiquidity();
liquidity = 0; // effects before interaction; the whole position is being withdrawn
_unlocking = true;
poolManager.unlock(abi.encode(OP_REMOVE));
// Sweep everything the PoolManager returned (principal + any accrued fees) to the owner.
address token_ = token;
uint256 tokenOut = IERC20(token_).balanceOf(address(this));
if (tokenOut > 0) IERC20(token_).safeTransfer(owner(), tokenOut);
uint256 ethOut = address(this).balance;
if (ethOut > 0) _sendEth(owner(), ethOut);
emit LiquidityRemoved(token_, ethOut, tokenOut);
}
/// @notice Permanently lock ("burn") the LP: sets `locked = true` for good. After this,
/// `removeLiquidity` always reverts, so the seeded liquidity can never leave the
/// pool. Idempotent and irreversible — the panel's "Burn LP" button.
function burnLiquidity() external onlyOwner {
if (!initialized) revert NotInitialized();
locked = true;
emit LiquidityBurned(token);
}
// ============================================================== callback
/// @notice V4 PoolManager unlock re-entry. Only callable by the PoolManager, and only while
/// an unlock we started is pending. Settles (on add) or takes (on remove) both
/// currencies of the position so no delta is left unsettled.
function unlockCallback(bytes calldata data) external returns (bytes memory) {
if (msg.sender != address(poolManager)) revert NotPoolManager();
if (!_unlocking) revert NoPendingUnlock();
_unlocking = false;
uint8 op = abi.decode(data, (uint8));
int256 delta = op == OP_ADD ? int256(uint256(liquidity)) : -int256(uint256(liquidity));
(int256 callerDelta,) = IPoolManagerModifyLiquidity(address(poolManager)).modifyLiquidity(
key, ModifyLiquidityParams(tickLower, tickUpper, delta, bytes32(0)), ""
);
// Reconcile each currency: negative delta => we owe it (settle), positive => pool owes
// us (take). Handles add and remove with the same code, and is robust to fee dust that
// can flip one side positive on a remove.
_reconcile(key.currency0, V4Lib.amount0(callerDelta));
_reconcile(key.currency1, V4Lib.amount1(callerDelta));
return "";
}
// ============================================================= internals
function _reconcile(address currency, int128 delta) internal {
if (delta < 0) {
uint256 owed = uint256(uint128(-delta));
if (currency == address(0)) {
// native ETH: settle with value, no sync/transfer (matches v4 CurrencySettler)
poolManager.settle{value: owed}();
} else {
poolManager.sync(currency);
IERC20(currency).safeTransfer(address(poolManager), owed);
poolManager.settle();
}
} else if (delta > 0) {
poolManager.take(currency, address(this), uint256(uint128(delta)));
}
}
function _sendEth(address to, uint256 amount) internal {
(bool ok,) = to.call{value: amount}("");
if (!ok) revert EthTransferFailed();
}
/// @notice The pool id of the ETH/EXPO pool, for the admin panel / off-chain reads.
function poolId() external view returns (bytes32) {
return V4Lib.toId(key);
}
/// @dev Accept the native ETH the PoolManager sends via `take` during a removal.
receive() external payable {}
}
Chain explorer1958msChain node91ms