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
- Creator
- 0x5D4961e638…5586Cb5E6D
- 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