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