// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IERC20Minimal} from "./interfaces/IERC20Minimal.sol"; import {IPairAssetRegistry} from "./interfaces/IPairAssetRegistry.sol"; import {IStankV3Factory, IStankV3Pool, IStankV3PositionManager, IStankV3SwapRouter, IWNative} from "./interfaces/IStankV3.sol"; import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; import {TickMath} from "./libraries/TickMath.sol"; import {LaunchPriceMath} from "./libraries/LaunchPriceMath.sol"; import {OwnedTwoStep} from "./utils/OwnedTwoStep.sol"; import {ReentrancyGuard} from "./utils/ReentrancyGuard.sol"; import {StankLaunchToken} from "./StankLaunchToken.sol"; import {LaunchRewardVault} from "./LaunchRewardVault.sol"; import {HolderRewardsDistributor} from "./HolderRewardsDistributor.sol"; import {PermanentLiquidityLocker} from "./PermanentLiquidityLocker.sol"; import {StankComponentDeployer} from "./StankComponentDeployer.sol"; /// @title STANK Launch Factory (PancakeSwap V3) /// @notice Deploys fixed-supply tokens and permanently locks one-sided PancakeSwap V3 liquidity at a /// fixed 1% pool fee. That fee is charged on every buy and sell, collected by the locker, and /// split (paired asset to the reward vault; launch token burned). No graduation. /// @dev Runs on stock PancakeSwap V3: the V3 factory is owned by PancakeSwap governance (not renounced) /// and may carry a pool protocol fee. STANK tolerates both and simply splits the LP fees it /// collects. Only the 1% fee amount needs to be enabled on the V3 factory. contract StankLaunchFactory is OwnedTwoStep, ReentrancyGuard { using SafeTransferLib for IERC20Minimal; /// @notice Every STANK V3 launch uses the fixed 1% pool fee tier. uint24 public constant POOL_FEE = 10_000; uint16 public constant CREATOR_AND_HOLDERS_BPS = 8_000; uint256 public constant MAX_NAME_BYTES = 64; uint256 public constant MAX_SYMBOL_BYTES = 16; uint256 public constant MAX_TOTAL_SUPPLY = type(uint128).max; IPairAssetRegistry public immutable pairRegistry; IStankV3PositionManager public immutable positionManager; /// @notice PancakeSwap V3 SwapRouter, used only for the optional atomic launch buy. IStankV3SwapRouter public immutable swapRouter; /// @notice Wrapped native token (WBNB), read from the swap router. A dev buy can be funded with /// native BNB only when the launch is paired against this asset. address public immutable wnative; IStankV3Factory public immutable v3Factory; StankComponentDeployer public immutable componentDeployer; /// @notice Tick spacing of the 1% fee tier, read from the V3 factory at construction. int24 public immutable tickSpacing; address public protocolTreasury; bool public launchesPaused; mapping(address creator => uint256 nextNonce) public launchNonce; mapping(address token => Launch record) private _launchByToken; address[] private _launchedTokens; struct LaunchParams { string name; string symbol; uint256 totalSupply; address pairedAsset; /// @notice Target initial fully-diluted market cap, in paired-asset base units. The pool opens /// at the price that values the whole supply at this amount (never zero). The frontend /// sets this from a USD target; a bounded range gives ~1e6x of upside room. uint256 initialMarketCapPaired; uint16 creatorRewardBps; uint16 holderRewardBps; /// @notice Optional creator-reward recipient. Zero defaults to the launching wallet. address rewardRecipient; /// @notice Optional paired-asset amount to spend on an atomic launch buy. Zero to skip. /// The buy runs inside this same launch tx, so it lands before any sniper can trade. /// The caller must approve the factory to spend this much of `pairedAsset` first. uint256 initialBuyAmount; /// @notice Where the atomic-buy tokens are sent. Zero defaults to the launching wallet. address buyRecipient; /// @notice Optional image + socials metadata URI stamped onto the token. Empty to skip. string metadataURI; bytes32 userSalt; } struct Launch { address token; address pairedAsset; address creator; address rewardRecipient; address pool; address permanentLocker; address rewardVault; address holderRewardsDistributor; uint256 positionId; uint256 totalSupply; uint256 launchTokenLiquidityAmount; uint256 permanentlyLockedDust; int24 tickLower; int24 tickUpper; uint16 creatorRewardBps; uint16 holderRewardBps; uint64 launchedAt; } struct Deployment { StankLaunchToken token; LaunchRewardVault rewardVault; HolderRewardsDistributor holderRewards; PermanentLiquidityLocker locker; address token0; address token1; bool launchTokenIsToken0; int24 tickLower; int24 tickUpper; } struct PositionResult { address pool; uint256 positionId; uint128 liquidity; uint256 launchTokenAmount; uint256 lockedDust; } struct MintResult { uint256 positionId; uint128 liquidity; uint256 amount0; uint256 amount1; } error LaunchesArePaused(); error InvalidConfiguration(); error InvalidMetadata(); error InvalidSupply(); error InvalidMarketCap(); error PairAssetNotActive(address asset); error FeeTierNotEnabled(uint24 fee); error UnsupportedNativePayment(); error InvalidRewardSplit(); error InvalidTickRange(); error PoolAlreadyExists(address pool); error InvalidInitialPoolPrice(address pool, uint160 expected, uint160 actual); error InvalidInitialPoolState(address pool); error InvalidOneSidedMint(); event LaunchCreated( address indexed token, address indexed creator, address indexed pairedAsset, address rewardRecipient, address pool, address permanentLocker, address rewardVault, address holderRewardsDistributor, uint256 positionId ); event LaunchEconomicsConfigured( address indexed token, uint256 totalSupply, uint256 launchTokenLiquidityAmount, uint256 permanentlyLockedDust, int24 tickLower, int24 tickUpper, uint16 creatorRewardBps, uint16 holderRewardBps ); event InitialBuyExecuted( address indexed token, address indexed buyRecipient, uint256 pairedAssetSpent, uint256 tokensReceived ); event ProtocolTreasuryChanged(address indexed previousTreasury, address indexed newTreasury); event LaunchPauseChanged(bool paused); constructor( address initialOwner, IPairAssetRegistry pairRegistry_, IStankV3PositionManager positionManager_, IStankV3SwapRouter swapRouter_, address protocolTreasury_ ) OwnedTwoStep(initialOwner) { if ( address(pairRegistry_) == address(0) || address(positionManager_) == address(0) || address(swapRouter_) == address(0) || protocolTreasury_ == address(0) ) revert ZeroAddress(); if ( address(pairRegistry_).code.length == 0 || address(positionManager_).code.length == 0 || address(swapRouter_).code.length == 0 ) revert InvalidConfiguration(); address v3Factory_ = positionManager_.factory(); if (v3Factory_ == address(0) || v3Factory_.code.length == 0) revert InvalidConfiguration(); // The swap router must be wired to the same V3 factory as the position manager, so the // atomic launch buy always trades against the exact pool this launch just created. if (swapRouter_.factory() != v3Factory_) revert InvalidConfiguration(); address wnative_ = swapRouter_.WETH9(); if (wnative_ == address(0) || wnative_.code.length == 0) revert InvalidConfiguration(); // Stock PancakeSwap V3: no `owner() == address(0)` requirement, no protocol-fee requirement. // Only the 1% fee amount must be enabled; its tick spacing anchors the full-range position. int24 spacing = IStankV3Factory(v3Factory_).feeAmountTickSpacing(POOL_FEE); if (spacing <= 0) revert FeeTierNotEnabled(POOL_FEE); pairRegistry = pairRegistry_; positionManager = positionManager_; swapRouter = swapRouter_; wnative = wnative_; v3Factory = IStankV3Factory(v3Factory_); tickSpacing = spacing; protocolTreasury = protocolTreasury_; componentDeployer = new StankComponentDeployer(address(this)); } /// @notice Launches a token, opens its 1% pool at the target market cap, and locks the NFT forever. /// @dev Payable: attach native BNB to fund the dev buy when the pair is WBNB (see `_initialBuy`). function launch(LaunchParams calldata params) external payable nonReentrant returns (Launch memory created) { if (launchesPaused) revert LaunchesArePaused(); _validateParams(params); // Reject stray BNB that no dev buy would consume (it would otherwise be trapped). if (msg.value != 0 && params.initialBuyAmount == 0) revert InvalidConfiguration(); address recipient = params.rewardRecipient == address(0) ? msg.sender : params.rewardRecipient; uint256 nonce = launchNonce[msg.sender]; bytes32 deploymentSalt = computeLaunchSalt(msg.sender, nonce, params.userSalt); launchNonce[msg.sender] = nonce + 1; // The one-sided range (and therefore the opening price) is derived from the target market cap // and the token's sort order, both of which are only known once the token is deployed. Deployment memory deployment = _deploy(params, recipient, deploymentSalt); PositionResult memory position = _createPosition(deployment, deployment.tickLower, deployment.tickUpper); created.token = address(deployment.token); created.pairedAsset = params.pairedAsset; created.creator = msg.sender; created.rewardRecipient = recipient; created.pool = position.pool; created.permanentLocker = address(deployment.locker); created.rewardVault = address(deployment.rewardVault); created.holderRewardsDistributor = address(deployment.holderRewards); created.positionId = position.positionId; created.totalSupply = params.totalSupply; created.launchTokenLiquidityAmount = position.launchTokenAmount; created.permanentlyLockedDust = position.lockedDust; created.tickLower = deployment.tickLower; created.tickUpper = deployment.tickUpper; created.creatorRewardBps = params.creatorRewardBps; created.holderRewardBps = params.holderRewardBps; created.launchedAt = uint64(block.timestamp); _launchByToken[address(deployment.token)] = created; _launchedTokens.push(address(deployment.token)); _emitLaunchCreated(created); // Optional atomic launch buy. It executes in this same launch transaction, after the pool // exists but before anyone else can see or trade it, so the creator's allocation always // lands ahead of snipers. if (params.initialBuyAmount != 0) { _initialBuy(params, address(deployment.token)); } } function predictTokenAddress( address creator, uint256 nonce, bytes32 userSalt, string calldata name, string calldata symbol, uint256 totalSupply ) public view returns (address predicted) { bytes32 salt = computeLaunchSalt(creator, nonce, userSalt); bytes32 initCodeHash = keccak256( abi.encodePacked( type(StankLaunchToken).creationCode, abi.encode(name, symbol, totalSupply, address(this)) ) ); predicted = address( uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash)))) ); } function predictNextTokenAddress( address creator, bytes32 userSalt, string calldata name, string calldata symbol, uint256 totalSupply ) external view returns (address) { return predictTokenAddress(creator, launchNonce[creator], userSalt, name, symbol, totalSupply); } function computeLaunchSalt(address creator, uint256 nonce, bytes32 userSalt) public view returns (bytes32) { return keccak256(abi.encode("STANK_LAUNCH_V3", block.chainid, creator, nonce, userSalt)); } /// @notice CREATE2 init-code hash for a launch token with these parameters. /// @dev Lets a frontend mine a vanity `userSalt` entirely off-chain: the deployed token address is /// keccak256(0xff, factory, computeLaunchSalt(creator, nonce, userSalt), tokenInitCodeHash(...)). /// Every stank.fun launch mines this so the token address ends with the Stank identity suffix. function tokenInitCodeHash(string calldata name, string calldata symbol, uint256 totalSupply) external view returns (bytes32) { return keccak256( abi.encodePacked( type(StankLaunchToken).creationCode, abi.encode(name, symbol, totalSupply, address(this)) ) ); } /// @notice The one-sided tick range a launch would open at for a target market cap and sort order. /// @dev Frontends can call this to preview the opening price. `launchIsToken0` is whether the token /// address sorts below the paired asset (predict the token address, then compare). function launchRange(bool launchIsToken0, uint256 totalSupply, uint256 initialMarketCapPaired) external view returns (int24 tickLower, int24 tickUpper) { return LaunchPriceMath.oneSidedRange(launchIsToken0, totalSupply, initialMarketCapPaired, tickSpacing); } function launchCount() external view returns (uint256) { return _launchedTokens.length; } function getLaunch(address token) external view returns (Launch memory) { return _launchByToken[token]; } function launchedTokenAt(uint256 index) external view returns (address) { return _launchedTokens[index]; } function setProtocolTreasury(address newTreasury) external onlyOwner { if (newTreasury == address(0)) revert ZeroAddress(); emit ProtocolTreasuryChanged(protocolTreasury, newTreasury); protocolTreasury = newTreasury; } function setLaunchesPaused(bool paused) external onlyOwner { launchesPaused = paused; emit LaunchPauseChanged(paused); } function _validateParams(LaunchParams calldata params) private view { uint256 nameLength = bytes(params.name).length; uint256 symbolLength = bytes(params.symbol).length; if (nameLength == 0 || nameLength > MAX_NAME_BYTES || symbolLength == 0 || symbolLength > MAX_SYMBOL_BYTES) { revert InvalidMetadata(); } if (params.totalSupply == 0 || params.totalSupply > MAX_TOTAL_SUPPLY) revert InvalidSupply(); if (params.initialMarketCapPaired == 0) revert InvalidMarketCap(); if (params.pairedAsset.code.length == 0) revert InvalidConfiguration(); if (!pairRegistry.isPairAssetActive(params.pairedAsset)) revert PairAssetNotActive(params.pairedAsset); if (uint256(params.creatorRewardBps) + params.holderRewardBps != CREATOR_AND_HOLDERS_BPS) { revert InvalidRewardSplit(); } } function _deploy(LaunchParams calldata params, address rewardRecipient, bytes32 salt) private returns (Deployment memory deployment) { deployment.token = new StankLaunchToken{salt: salt}(params.name, params.symbol, params.totalSupply, address(this)); if (address(deployment.token) == params.pairedAsset) revert InvalidConfiguration(); if (bytes(params.metadataURI).length != 0) deployment.token.setMetadata(params.metadataURI); deployment.launchTokenIsToken0 = address(deployment.token) < params.pairedAsset; (deployment.token0, deployment.token1) = deployment.launchTokenIsToken0 ? (address(deployment.token), params.pairedAsset) : (params.pairedAsset, address(deployment.token)); // Open at the price that values the full supply at the target market cap, with a bounded range. (deployment.tickLower, deployment.tickUpper) = LaunchPriceMath.oneSidedRange( deployment.launchTokenIsToken0, params.totalSupply, params.initialMarketCapPaired, tickSpacing ); StankComponentDeployer.DeployParams memory componentParams = StankComponentDeployer.DeployParams({ launchToken: address(deployment.token), pairedAsset: params.pairedAsset, token0: deployment.token0, token1: deployment.token1, protocolTreasury: protocolTreasury, rewardRecipient: rewardRecipient, poolFee: POOL_FEE, tickLower: deployment.tickLower, tickUpper: deployment.tickUpper, creatorRewardBps: params.creatorRewardBps, holderRewardBps: params.holderRewardBps }); (deployment.holderRewards, deployment.rewardVault, deployment.locker) = componentDeployer.deploy(salt, componentParams, positionManager); } function _createPosition(Deployment memory deployment, int24 tickLower, int24 tickUpper) private returns (PositionResult memory result) { int24 initialTick = deployment.launchTokenIsToken0 ? tickLower : tickUpper; if (initialTick <= TickMath.MIN_TICK || initialTick >= TickMath.MAX_TICK) revert InvalidTickRange(); uint160 expectedSqrtPriceX96 = TickMath.getSqrtRatioAtTick(initialTick); address existingPool = v3Factory.getPool(deployment.token0, deployment.token1, POOL_FEE); if (existingPool != address(0)) revert PoolAlreadyExists(existingPool); result.pool = positionManager.createAndInitializePoolIfNecessary( deployment.token0, deployment.token1, POOL_FEE, expectedSqrtPriceX96 ); address canonicalPool = v3Factory.getPool(deployment.token0, deployment.token1, POOL_FEE); if (result.pool == address(0) || result.pool != canonicalPool) revert InvalidConfiguration(); // Stock PancakeSwap V3 may carry a nonzero pool protocol fee; that is tolerated. (uint160 actualSqrtPriceX96, int24 actualTick,,,,, bool unlocked) = IStankV3Pool(result.pool).slot0(); if (actualSqrtPriceX96 != expectedSqrtPriceX96) { revert InvalidInitialPoolPrice(result.pool, expectedSqrtPriceX96, actualSqrtPriceX96); } if (actualTick != initialTick || !unlocked) revert InvalidInitialPoolState(result.pool); MintResult memory minted = _mintOneSided(deployment, tickLower, tickUpper); result.positionId = minted.positionId; result.liquidity = minted.liquidity; uint256 pairedAssetAmount = deployment.launchTokenIsToken0 ? minted.amount1 : minted.amount0; result.launchTokenAmount = deployment.launchTokenIsToken0 ? minted.amount0 : minted.amount1; if (result.liquidity == 0 || result.launchTokenAmount == 0 || pairedAssetAmount != 0) { revert InvalidOneSidedMint(); } result.lockedDust = deployment.token.balanceOf(address(this)); if (result.lockedDust != 0) { IERC20Minimal(address(deployment.token)).safeTransfer(address(deployment.locker), result.lockedDust); } deployment.locker.bindPosition(result.positionId, result.pool, result.liquidity, result.lockedDust); } function _mintOneSided(Deployment memory deployment, int24 tickLower, int24 tickUpper) private returns (MintResult memory minted) { uint256 supply = deployment.token.totalSupply(); IERC20Minimal(address(deployment.token)).forceApprove(address(positionManager), supply); uint256 amount0Desired = deployment.launchTokenIsToken0 ? supply : 0; uint256 amount1Desired = deployment.launchTokenIsToken0 ? 0 : supply; (minted.positionId, minted.liquidity, minted.amount0, minted.amount1) = positionManager.mint( IStankV3PositionManager.MintParams({ token0: deployment.token0, token1: deployment.token1, fee: POOL_FEE, tickLower: tickLower, tickUpper: tickUpper, amount0Desired: amount0Desired, amount1Desired: amount1Desired, amount0Min: 0, amount1Min: 0, recipient: address(deployment.locker), deadline: block.timestamp }) ); IERC20Minimal(address(deployment.token)).forceApprove(address(positionManager), 0); } /// @notice Funds and executes the atomic buy, swapping the paired asset for the freshly launched /// token through the V3 pool. The paired asset is either pulled from the creator, or — when /// BNB is attached and the pair is WBNB — wrapped from the native value. `amountOutMinimum` /// is 0 because the pool was created in this same tx and no one else can have traded against /// it yet, so there is no adversarial price to guard. function _initialBuy(LaunchParams calldata params, address launchToken) private { address buyRecipient = params.buyRecipient == address(0) ? msg.sender : params.buyRecipient; IERC20Minimal paired = IERC20Minimal(params.pairedAsset); if (msg.value != 0) { // Native BNB funds the buy: only valid for a WBNB pair, and it must equal the buy amount. if (params.pairedAsset != wnative) revert UnsupportedNativePayment(); if (msg.value != params.initialBuyAmount) revert InvalidConfiguration(); IWNative(wnative).deposit{value: msg.value}(); } else { paired.safeTransferFrom(msg.sender, address(this), params.initialBuyAmount); } paired.forceApprove(address(swapRouter), params.initialBuyAmount); uint256 tokensReceived = swapRouter.exactInputSingle( IStankV3SwapRouter.ExactInputSingleParams({ tokenIn: params.pairedAsset, tokenOut: launchToken, fee: POOL_FEE, recipient: buyRecipient, deadline: block.timestamp, amountIn: params.initialBuyAmount, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }) ); paired.forceApprove(address(swapRouter), 0); emit InitialBuyExecuted(launchToken, buyRecipient, params.initialBuyAmount, tokensReceived); } function _emitLaunchCreated(Launch memory created) private { emit LaunchCreated( created.token, created.creator, created.pairedAsset, created.rewardRecipient, created.pool, created.permanentLocker, created.rewardVault, created.holderRewardsDistributor, created.positionId ); emit LaunchEconomicsConfigured( created.token, created.totalSupply, created.launchTokenLiquidityAmount, created.permanentlyLockedDust, created.tickLower, created.tickUpper, created.creatorRewardBps, created.holderRewardBps ); } }