LaunchFactory

0x37978084bc37bc0e68f50f2be7213b94fefcd916

Verification
Verified
v0.8.28+commit.7893614a
Type
Contract
23,289 bytes
ABI entries
50
20 read · 5 write
License
mit

Contract information

Address
0x37978084bc37bc0e68f50f2be7213b94fefcd916
Chain
Robinhood Chain (4663)
Compiler
v0.8.28+commit.7893614a
Optimization
Enabled
Creation tx
0xadba788b5a…5591ae04ed

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (20)

LAUNCH_FEE()uint256
MAX_DECAY_SECONDS()uint32
MAX_SELL_LOCK_SECONDS()uint32
MAX_START_FEE_BPS()uint16
MAX_STATIC_FEE_BPS()uint16
MIN_STATIC_FEE_BPS()uint16
PROTOCOL_SHARE_BPS()uint16
TICK_SPACING()int24
TOKEN_DECIMALS()uint8
TOKEN_SUPPLY()uint256
hook()address
launchFeeRecipient()address
maxInitialTick()int24
minInitialTick()int24
owner()address
permit2()address
poolManager()address
positionManager()address
protocol()address
protocolFeeRecipient()address

Events (5)

DevBuyExecutedLaunchCreatedLaunchFeePaidOwnershipTransferredProtocolFeeRecipientChanged

ABI

[
  {
    "inputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "poolManager_",
        "type": "address"
      },
      {
        "internalType": "contract IPositionManager",
        "name": "positionManager_",
        "type": "address"
      },
      {
        "internalType": "contract IAllowanceTransfer",
        "name": "permit2_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "protocol_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "launchFeeRecipient_",
        "type": "address"
      },
      {
        "internalType": "int24",
        "name": "minInitialTick_",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "maxInitialTick_",
        "type": "int24"
      },
      {
        "internalType": "bytes32",
        "name": "hookSalt",
        "type": "bytes32"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "BadTickBounds",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "startBps",
        "type": "uint16"
      },
      {
        "internalType": "uint16",
        "name": "endBps",
        "type": "uint16"
      }
    ],
    "name": "DecayGoesUp",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint32",
        "name": "requested",
        "type": "uint32"
      },
      {
        "internalType": "uint32",
        "name": "max",
        "type": "uint32"
      }
    ],
    "name": "DecayTooLong",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "DevBuyReturnedNothing",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "requested",
        "type": "uint16"
      },
      {
        "internalType": "uint16",
        "name": "cap",
        "type": "uint16"
      }
    ],
    "name": "FeeAboveCap",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "requested",
        "type": "uint16"
      },
      {
        "internalType": "uint16",
        "name": "floor",
        "type": "uint16"
      }
    ],
    "name": "FeeBelowFloor",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "hookAddr",
        "type": "address"
      }
    ],
    "name": "HookFlagsWrong",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "sent",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "required",
        "type": "uint256"
      }
    ],
    "name": "InsufficientLaunchFee",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "LaunchFeeTransferFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "OnlyPoolManager",
    "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": [],
    "name": "RefundFailed",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SellLockDisabled",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "int24",
        "name": "tick",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "spacing",
        "type": "int24"
      }
    ],
    "name": "TickNotAligned",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "int24",
        "name": "tick",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "min",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "max",
        "type": "int24"
      }
    ],
    "name": "TickOutOfBounds",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethIn",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "tokensOut",
        "type": "uint256"
      }
    ],
    "name": "DevBuyExecuted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "locker",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "positionTokenId",
        "type": "uint256"
      }
    ],
    "name": "LaunchCreated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "payer",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "LaunchFeePaid",
    "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"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previous",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "current",
        "type": "address"
      }
    ],
    "name": "ProtocolFeeRecipientChanged",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "LAUNCH_FEE",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_DECAY_SECONDS",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_SELL_LOCK_SECONDS",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_START_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_STATIC_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MIN_STATIC_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "PROTOCOL_SHARE_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TICK_SPACING",
    "outputs": [
      {
        "internalType": "int24",
        "name": "",
        "type": "int24"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TOKEN_DECIMALS",
    "outputs": [
      {
        "internalType": "uint8",
        "name": "",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TOKEN_SUPPLY",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "string",
            "name": "name",
            "type": "string"
          },
          {
            "internalType": "string",
            "name": "symbol",
            "type": "string"
          },
          {
            "internalType": "int24",
            "name": "initialTick",
            "type": "int24"
          },
          {
            "internalType": "uint16",
            "name": "poolFeeBps",
            "type": "uint16"
          },
          {
            "internalType": "uint16",
            "name": "feeDecayStartBps",
            "type": "uint16"
          },
          {
            "internalType": "uint32",
            "name": "feeDecayDurationSec",
            "type": "uint32"
          },
          {
            "internalType": "uint32",
            "name": "sellLockSeconds",
            "type": "uint32"
          }
        ],
        "internalType": "struct LaunchFactory.LaunchParams",
        "name": "p",
        "type": "tuple"
      }
    ],
    "name": "createLaunch",
    "outputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "internalType": "uint256",
        "name": "positionTokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "locker",
        "type": "address"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "hook",
    "outputs": [
      {
        "internalType": "contract LittleJohnHook",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "launchFeeRecipient",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "maxInitialTick",
    "outputs": [
      {
        "internalType": "int24",
        "name": "",
        "type": "int24"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "minInitialTick",
    "outputs": [
      {
        "internalType": "int24",
        "name": "",
        "type": "int24"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "permit2",
    "outputs": [
      {
        "internalType": "contract IAllowanceTransfer",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "positionManager",
    "outputs": [
      {
        "internalType": "contract IPositionManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "protocol",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "protocolFeeRecipient",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "setProtocolFeeRecipient",
    "outputs": [],
    "stateMutability": "nonpayable",
    "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"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

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 {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol";
import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol";
import {LiquidityAmounts} from "@uniswap/v4-periphery/src/libraries/LiquidityAmounts.sol";
import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol";

import {LittleJohnToken} from "./LittleJohnToken.sol";
import {LittleJohnHook} from "./LittleJohnHook.sol";
import {LpLockerV4} from "./LpLockerV4.sol";

/**
 * LaunchFactory — one transaction fires a complete LittleJohn Instant Launch
 * on Uniswap v4 (Robinhood Chain pairs launch pools against NATIVE ETH,
 * currency0 == address(0)):
 *
 *   1. deploy LittleJohnToken (fixed supply, no mint fn, no owner),
 *   2. register the pool's immutable fee/sell-lock config on the hook,
 *   3. initialize the v4 pool (dynamic-fee flag, LittleJohnHook attached),
 *   4. mint ONE single-sided position holding the entire supply, range
 *      [minUsableTick, initialTick], owner = a fresh per-launch LpLockerV4
 *      (the NFT is BORN inside the locker — it never passes through any
 *      other owner, so there is no window in which it could be diverted),
 *   5. optional atomic dev-buy: the creator's ETH (msg.value) is swapped
 *      into the token immediately after LP placement, tokens to the creator.
 *
 * STARTING PRICE / MCAP: the client computes `initialTick` from the wizard's
 * startingMcapUsd (Clanker's tick-from-mcap pattern, see
 * research/instant-launch-mcap-and-v4-hooks.md). Orientation on this chain:
 * the token is currency1, so tick = log1.0001(token per ETH) — a HIGHER tick
 * is a CHEAPER token (lower mcap). The factory enforces immutable sanity
 * bounds [minInitialTick, maxInitialTick] set at deploy from the research
 * band (min $2.5k / max $1M equivalents at deploy-time ETH/USD) so a
 * fat-fingered tick cannot create a $0 or $10B launch. Supply is FIXED so
 * the tick <-> mcap mapping is bijective (Clanker fixes supply for the same
 * reason).
 *
 * RUG-PROOF INVARIANTS enforced here (bounds re-checked by the hook):
 * - poolFeeBps hard cap 5% (MAX_STATIC_FEE_BPS), decay start capped at 40%
 *   (MAX_START_FEE_BPS, the global fee ceiling — operator directive 2026-07-09)
 *   and start >= end, decay window <= 1h, sell-lock bounded and creator-only.
 * - Every LAUNCH is immutable after createLaunch: the hook config registers
 *   once; the locker's recipients/split are immutable and the position NFT can
 *   never leave it. The factory itself is Ownable with EXACTLY ONE owner
 *   power: setProtocolFeeRecipient, which redirects where FUTURE launches'
 *   protocol fees accrue (each launch bakes the value into its LpLockerV4
 *   immutably at creation, so existing launches can never be touched). There
 *   are no other setters and no upgrade path. The approved mainnet manifest
 *   deliberately separates the operator owner from the protocol fee recipient.
 *
 * TIME: block.timestamp everywhere. Robinhood Chain is Arbitrum Orbit/Nitro —
 * block.number is an L1 estimate and is never used.
 */
contract LaunchFactory is IUnlockCallback, ReentrancyGuard, Ownable {
    using SafeERC20 for IERC20;

    // ---------- launch constants ----------

    /// Fixed supply per launch: 1,000,000,000 tokens, 18 decimals. Fixed so
    /// initialTick <-> starting mcap is bijective (see header).
    uint256 public constant TOKEN_SUPPLY = 1_000_000_000e18;
    /// Flat anti-spam fee paid once per launch. Immutable by bytecode: no setter.
    uint256 public constant LAUNCH_FEE = 0.0045 ether;
    uint8 public constant TOKEN_DECIMALS = 18;
    /// One spacing for all launch pools; initialTick must be a multiple.
    int24 public constant TICK_SPACING = 200;
    /// Protocol's share of launch-pool LP fees: 3333 bps = 1/3 (mirrors
    /// FEES.PROTOCOL_FEE_SHARE in packages/contract).
    uint16 public constant PROTOCOL_SHARE_BPS = 3333;

    // invariant bounds (duplicated from the hook for cheap pre-checks;
    // the hook re-enforces them — it does not trust this contract)
    uint16 public constant MAX_STATIC_FEE_BPS = 500; // 5% hard fee cap
    uint16 public constant MIN_STATIC_FEE_BPS = 100; // 1% operator revenue floor
    uint16 public constant MAX_START_FEE_BPS = 4000; // 40% global fee ceiling
    uint32 public constant MAX_DECAY_SECONDS = 3600;
    uint32 public constant MAX_SELL_LOCK_SECONDS = 0;

    // ---------- wiring (immutable, except the owner-settable protocol
    // recipient for FUTURE launches — see protocolFeeRecipient below) ----------

    IPoolManager public immutable poolManager;
    IPositionManager public immutable positionManager;
    IAllowanceTransfer public immutable permit2;
    LittleJohnHook public immutable hook;
    /// Protocol fee recipient baked into the locker of every FUTURE launch.
    /// Owner-settable (Ownable, owner = deployer) so the operator can redirect
    /// where NEW launches' protocol fees accrue — WITHOUT any power over
    /// existing launches: each launch bakes THIS value into its LpLockerV4 at
    /// creation, where it is immutable forever (LpLockerV4.protocol). Changing
    /// it here affects only launches created AFTER the change; already-launched
    /// tokens' fee recipients can never be redirected. The legacy `protocol()`
    /// getter (below) still returns the current value, so the treasury panel,
    /// keeper, and fork tests that read `protocol()` keep working unchanged.
    address public protocolFeeRecipient;
    /// Dedicated launch-fee wallet, immutable for the lifetime of this factory.
    address public immutable launchFeeRecipient;
    /// Tick sanity bounds (see header). minInitialTick = the $1M-equivalent
    /// cap (expensive token), maxInitialTick = the $2.5k-equivalent floor.
    int24 public immutable minInitialTick;
    int24 public immutable maxInitialTick;

    struct LaunchParams {
        string name;
        string symbol;
        /// Starting spot tick (token per ETH, see header). Client-computed
        /// from startingMcapUsd; must be spacing-aligned and inside bounds.
        int24 initialTick;
        /// Base pool trading fee in bps (this is the settled fee after any
        /// decay window; the terminal "poolFeeBps" of the wizard).
        uint16 poolFeeBps;
        /// Anti-snipe decay opening fee in bps; 0 = decay off.
        uint16 feeDecayStartBps;
        /// Decay window in seconds; 0 = decay off.
        uint32 feeDecayDurationSec;
        /// Reserved legacy field; must be zero for every new launch.
        uint32 sellLockSeconds;
    }

    struct DevBuyData {
        PoolKey key;
        uint256 ethIn;
        address recipient;
    }

    /// Fee/sell-lock config is in the hook's LaunchRegistered event and the
    /// starting tick in the PoolManager's Initialize event (same tx, same
    /// poolId) — the indexer joins on poolId, keeping each emit small.
    event LaunchCreated(
        PoolId indexed poolId, address indexed creator, address token, address locker, uint256 positionTokenId
    );
    event DevBuyExecuted(PoolId indexed poolId, uint256 ethIn, uint256 tokensOut);
    /// The owner redirected where FUTURE launches' protocol fees accrue. Existing
    /// launches are unaffected (their locker recipient is immutable).
    event ProtocolFeeRecipientChanged(address indexed previous, address indexed current);
    event LaunchFeePaid(address indexed payer, address indexed recipient, uint256 amount);

    error ZeroAddress();
    error BadTickBounds();
    error TickOutOfBounds(int24 tick, int24 min, int24 max);
    error TickNotAligned(int24 tick, int24 spacing);
    error FeeAboveCap(uint16 requested, uint16 cap);
    error FeeBelowFloor(uint16 requested, uint16 floor);
    error DecayGoesUp(uint16 startBps, uint16 endBps);
    error DecayTooLong(uint32 requested, uint32 max);
    error SellLockDisabled();
    error HookFlagsWrong(address hookAddr);
    error OnlyPoolManager();
    error DevBuyReturnedNothing();
    error RefundFailed();
    error InsufficientLaunchFee(uint256 sent, uint256 required);
    error LaunchFeeTransferFailed();

    constructor(
        IPoolManager poolManager_,
        IPositionManager positionManager_,
        IAllowanceTransfer permit2_,
        address protocol_,
        address launchFeeRecipient_,
        int24 minInitialTick_,
        int24 maxInitialTick_,
        bytes32 hookSalt
    ) Ownable(msg.sender) {
        if (
            address(poolManager_) == address(0) || address(positionManager_) == address(0)
                || address(permit2_) == address(0) || protocol_ == address(0) || launchFeeRecipient_ == address(0)
        ) revert ZeroAddress();
        if (
            minInitialTick_ >= maxInitialTick_ || minInitialTick_ % TICK_SPACING != 0
                || maxInitialTick_ % TICK_SPACING != 0 || minInitialTick_ <= TickMath.minUsableTick(TICK_SPACING)
                || maxInitialTick_ >= TickMath.maxUsableTick(TICK_SPACING)
        ) revert BadTickBounds();

        poolManager = poolManager_;
        positionManager = positionManager_;
        permit2 = permit2_;
        protocolFeeRecipient = protocol_;
        launchFeeRecipient = launchFeeRecipient_;
        minInitialTick = minInitialTick_;
        maxInitialTick = maxInitialTick_;

        // The factory deploys its own hook with a mined CREATE2 salt so the
        // hook's `factory` immutable is THIS address (no post-deploy wiring,
        // no latch, nothing mutable). The deploy script / tests mine `hookSalt`
        // with HookMiner against the predicted factory address.
        hook = new LittleJohnHook{salt: hookSalt}(poolManager_, address(this));
        if (
            uint160(address(hook)) & Hooks.ALL_HOOK_MASK
                != uint160(
                    Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG
                        | Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG
                )
        ) revert HookFlagsWrong(address(hook));
    }

    // ---------- protocol fee recipient (owner-settable, future launches only) ----------

    /// Legacy getter preserved: the treasury panel, keeper, and fork tests read
    /// `protocol()` on the factory to resolve the current protocol wallet. It now
    /// returns the (settable) `protocolFeeRecipient`; selector is unchanged.
    function protocol() external view returns (address) {
        return protocolFeeRecipient;
    }

    /// Owner-only: redirect where FUTURE launches' protocol fees accrue. Does NOT
    /// touch any existing launch — each launch baked the recipient into its
    /// immutable LpLockerV4 at creation, so this only changes the value future
    /// createLaunch calls bake in. Non-zero enforced.
    function setProtocolFeeRecipient(address newRecipient) external onlyOwner {
        if (newRecipient == address(0)) revert ZeroAddress();
        address previous = protocolFeeRecipient;
        protocolFeeRecipient = newRecipient;
        emit ProtocolFeeRecipientChanged(previous, newRecipient);
    }

    /**
     * Fire a launch. `msg.sender` is the creator (locker fee recipient +
     * sell-lock subject). `msg.value` is the optional atomic dev-buy.
     * Returns everything the caller/indexer needs to render the launch.
     */
    function createLaunch(LaunchParams calldata p)
        external
        payable
        nonReentrant
        returns (address token, PoolId poolId, uint256 positionTokenId, address locker)
    {
        if (msg.value < LAUNCH_FEE) revert InsufficientLaunchFee(msg.value, LAUNCH_FEE);
        _validate(p);

        // 1. token: fixed supply minted to this factory, no mint fn, no owner
        LittleJohnToken t = new LittleJohnToken(p.name, p.symbol, TOKEN_DECIMALS, TOKEN_SUPPLY, address(this));
        token = address(t);

        // native ETH (address(0)) sorts first: always currency0 on this chain
        PoolKey memory key = PoolKey({
            currency0: Currency.wrap(address(0)),
            currency1: Currency.wrap(token),
            fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
            tickSpacing: TICK_SPACING,
            hooks: IHooks(address(hook))
        });
        poolId = key.toId();

        // 2. per-launch locker FIRST, so its address can be baked into the
        //    hook's immutable config (the hook mints the ETH-leg fee claim to
        //    it). The position NFT id is deterministic — nothing else mints on
        //    the posm between here and the mint in step 5, so nextTokenId holds.
        positionTokenId = positionManager.nextTokenId();
        LpLockerV4 lock = new LpLockerV4(
            positionManager,
            poolManager,
            positionTokenId,
            key.currency0,
            key.currency1,
            msg.sender,
            protocolFeeRecipient, // CURRENT factory recipient, baked immutable into this locker
            PROTOCOL_SHARE_BPS
        );
        locker = address(lock);

        // 3. immutable per-pool config (hook re-validates every bound).
        // Decay off => start == end == poolFeeBps so the curve is flat.
        uint16 startBps = p.feeDecayDurationSec == 0 ? p.poolFeeBps : p.feeDecayStartBps;
        hook.register(poolId, msg.sender, startBps, p.poolFeeBps, p.feeDecayDurationSec, p.sellLockSeconds, locker);

        // 4. pool starts exactly at the creator-chosen tick
        poolManager.initialize(key, TickMath.getSqrtPriceAtTick(p.initialTick));

        // 5. single-sided position over [minUsableTick, initialTick] holding
        //    the whole supply, minted DIRECTLY into the locker
        _mintFullSupplyPosition(key, p.initialTick, token, locker);
        emit LaunchCreated(poolId, msg.sender, token, locker, positionTokenId);

        // 6. optional atomic dev-buy with the creator's ETH
        if (msg.value > LAUNCH_FEE) _devBuy(key, poolId, msg.value - LAUNCH_FEE);

        // rounding dust from the liquidity math (a few wei of token at most)
        // goes to the creator, so the factory ends the tx holding nothing.
        // SafeERC20 (audit 2026-07-09 P2-1): our own OZ token reverts on a
        // failed transfer and returns true, so this is benign — but use the
        // checked path for uniformity with the rest of the factory.
        uint256 dust = t.balanceOf(address(this));
        if (dust > 0) IERC20(token).safeTransfer(msg.sender, dust);

        (bool feePaid,) = launchFeeRecipient.call{value: LAUNCH_FEE}("");
        if (!feePaid) revert LaunchFeeTransferFailed();
        emit LaunchFeePaid(msg.sender, launchFeeRecipient, LAUNCH_FEE);
    }

    function _devBuy(PoolKey memory key, PoolId poolId, uint256 ethIn) internal {
        (uint256 ethUsed, uint256 tokensOut) =
            abi.decode(poolManager.unlock(abi.encode(DevBuyData(key, ethIn, msg.sender))), (uint256, uint256));
        if (tokensOut == 0) revert DevBuyReturnedNothing();
        if (ethIn > ethUsed) {
            // price-limit leftover (cannot happen inside the bounds, kept for safety)
            (bool ok,) = msg.sender.call{value: ethIn - ethUsed}("");
            if (!ok) revert RefundFailed();
        }
        emit DevBuyExecuted(poolId, ethUsed, tokensOut);
    }

    /// Dev-buy execution inside the PoolManager unlock. Only the PoolManager
    /// can call this, and it only runs while THIS contract holds the unlock.
    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert OnlyPoolManager();
        DevBuyData memory d = abi.decode(data, (DevBuyData));

        // buy: ETH (currency0) in, token out => zeroForOne, exact-in
        BalanceDelta delta = poolManager.swap(
            d.key,
            SwapParams({
                zeroForOne: true, amountSpecified: -int256(d.ethIn), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
            }),
            ""
        );
        uint256 ethUsed = uint256(-int256(delta.amount0()));
        uint256 tokensOut = uint256(uint128(delta.amount1()));

        poolManager.take(d.key.currency1, d.recipient, tokensOut);
        poolManager.settle{value: ethUsed}();
        return abi.encode(ethUsed, tokensOut);
    }

    // ---------- internals ----------

    function _validate(LaunchParams calldata p) internal view {
        if (p.initialTick < minInitialTick || p.initialTick > maxInitialTick) {
            revert TickOutOfBounds(p.initialTick, minInitialTick, maxInitialTick);
        }
        if (p.initialTick % TICK_SPACING != 0) revert TickNotAligned(p.initialTick, TICK_SPACING);
        if (p.poolFeeBps < MIN_STATIC_FEE_BPS) revert FeeBelowFloor(p.poolFeeBps, MIN_STATIC_FEE_BPS);
        if (p.poolFeeBps > MAX_STATIC_FEE_BPS) revert FeeAboveCap(p.poolFeeBps, MAX_STATIC_FEE_BPS);
        if (p.feeDecayDurationSec > MAX_DECAY_SECONDS) revert DecayTooLong(p.feeDecayDurationSec, MAX_DECAY_SECONDS);
        if (p.feeDecayDurationSec != 0) {
            if (p.feeDecayStartBps > MAX_START_FEE_BPS) revert FeeAboveCap(p.feeDecayStartBps, MAX_START_FEE_BPS);
            if (p.feeDecayStartBps < p.poolFeeBps) revert DecayGoesUp(p.feeDecayStartBps, p.poolFeeBps);
        }
        if (p.sellLockSeconds != 0) revert SellLockDisabled();
    }

    /// Mint the full-supply single-sided position (token side only: the
    /// range sits entirely at/below spot, so amount0Max — the ETH side — is
    /// zero) into the locker via the REAL PositionManager.
    function _mintFullSupplyPosition(PoolKey memory key, int24 initialTick, address token, address locker) internal {
        int24 tickLower = TickMath.minUsableTick(TICK_SPACING);
        uint128 liquidity = LiquidityAmounts.getLiquidityForAmount1(
            TickMath.getSqrtPriceAtTick(tickLower), TickMath.getSqrtPriceAtTick(initialTick), TOKEN_SUPPLY
        );

        // posm settles ERC20s through Permit2
        IERC20(token).forceApprove(address(permit2), TOKEN_SUPPLY);
        permit2.approve(token, address(positionManager), uint160(TOKEN_SUPPLY), uint48(block.timestamp));

        bytes memory actions = abi.encodePacked(uint8(Actions.MINT_POSITION), uint8(Actions.SETTLE_PAIR));
        bytes[] memory params = new bytes[](2);
        params[0] = abi.encode(
            key, tickLower, initialTick, uint256(liquidity), uint128(0), uint128(TOKEN_SUPPLY), locker, bytes("")
        );
        params[1] = abi.encode(key.currency0, key.currency1);
        positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);

        IERC20(token).forceApprove(address(permit2), 0);
    }

    // NOTE (deliberate): no receive()/fallback — the factory can only hold
    // ETH transiently inside createLaunch (msg.value), never at rest.
}
Chain explorer3427msChain node90ms