// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IJackpotFunding, IPonsFactory, IPonsEscrow, IPonsCurve} from "./Interfaces.sol"; interface IPonsLaunchIdentity { function launchDeployer() external view returns(address); } interface IPonsTokenIdentity { function deployer() external view returns(address); function launchFactory() external view returns(address); function curve() external view returns(address); } /// @notice Pins a future Pons token before launch; admission is a view, never an activation transaction. contract PrelaunchFeeVault is Ownable2Step, ReentrancyGuard { using SafeERC20 for IERC20; bool public isShutdown; address payable public immutable recoveryRecipient; uint256 public totalRecovered; IPonsFactory public immutable factory; IPonsEscrow public immutable escrow; address payable public immutable creator; address payable public keeper; address public token; address public immutable expectedDeployer; bytes32 public immutable expectedTokenCodeHash; bytes32 public immutable factoryCodeHash; address public immutable launchDeployer; bytes32 public immutable launchDeployerCodeHash; bytes32 public immutable escrowCodeHash; IJackpotFunding public game; uint256 public poolFunds; uint256 public creatorFunds; uint256 public totalFees; uint256 public totalCreatorPaid; uint256 public totalOperatingAllocated; uint256 public retainedReserve; error InvalidConfiguration(); error Unauthorized(); error InsufficientBudget(); error PaymentFailed(); event FeesSplit(uint256 received, uint256 pool, uint256 creatorGross); event TokenReserved(address indexed token); event FeeSweepDeferred(address indexed curve); event GameBound(address indexed game); event KeeperChanged(address indexed keeper, uint256 retainedReserve); event OperationsAllocated(uint256 gameAmount, uint256 keeperAmount); event CreatorPaid(uint256 amount); event ShutdownRecovery(address indexed recipient, uint256 eth); event SurplusTokensRecovered(address indexed asset, uint256 amount); modifier whenOpen() { if (isShutdown) revert InvalidConfiguration(); _; } constructor(address owner_, address creator_, address keeper_, address factory_, uint256 reserve_, address token_, address deployer_, bytes32 tokenCodeHash_) Ownable(owner_) { recoveryRecipient = payable(owner_); if (deployer_ == address(0) || (token_ != address(0) && token_.code.length != 0)) revert InvalidConfiguration(); token = token_; expectedDeployer = deployer_; expectedTokenCodeHash = tokenCodeHash_; factoryCodeHash = factory_.codehash; address helper = IPonsLaunchIdentity(factory_).launchDeployer(); if (helper.code.length == 0) revert InvalidConfiguration(); launchDeployer = helper; launchDeployerCodeHash = helper.codehash; if (creator_ == address(0) || keeper_ == address(0) || factory_.code.length == 0) revert InvalidConfiguration(); factory = IPonsFactory(factory_); address escrow_ = factory.feeEscrow(); if (escrow_.code.length == 0) revert InvalidConfiguration(); escrowCodeHash = escrow_.codehash; escrow = IPonsEscrow(escrow_); creator = payable(creator_); keeper = payable(keeper_); retainedReserve = reserve_; } // Account every fee receipt exactly once; operating seeds use the named function below. receive() external payable { uint256 poolShare = Math.mulDiv(msg.value, 9, 10); poolFunds += poolShare; creatorFunds += msg.value - poolShare; totalFees += msg.value; emit FeesSplit(msg.value, poolShare, msg.value - poolShare); } function seedOperations() external payable whenOpen { creatorFunds += msg.value; } /// @notice Permanently stop funding/remittance and recover the entire local ETH balance. /// This does not depend on Pons or the oracle being operational. Late fees can be recovered again. function shutdownAndRecover() external onlyOwner nonReentrant { isShutdown = true; uint256 amount = address(this).balance; poolFunds = 0; creatorFunds = 0; totalRecovered += amount; emit ShutdownRecovery(recoveryRecipient, amount); if (amount != 0) { (bool ok,) = recoveryRecipient.call{value: amount}(""); if (!ok) revert PaymentFailed(); } } function recoverSurplusTokens(address asset) external onlyOwner nonReentrant { if (!isShutdown || asset.code.length == 0) revert InvalidConfiguration(); IERC20 coin = IERC20(asset); uint256 amount = coin.balanceOf(address(this)); if (amount != 0) coin.safeTransfer(recoveryRecipient, amount); emit SurplusTokensRecovered(asset, amount); } /// @notice Optional one-time reservation after deploying this vault, BEFORE the coin launches. /// This allows Pons to compute its CA using the already-known fee-recipient address. function reserveToken(address token_) external onlyOwner whenOpen { if (token != address(0) || token_ == address(0) || token_.code.length != 0 || address(game) != address(0)) revert InvalidConfiguration(); token = token_; emit TokenReserved(token_); } /// @dev Every check reads current chain state. No successful call caches an activation flag. function assertLaunch() public view returns (address launchCurve) { if (token.code.length == 0 || (expectedTokenCodeHash != bytes32(0) && token.codehash != expectedTokenCodeHash) || address(factory).codehash != factoryCodeHash || address(escrow).codehash != escrowCodeHash || factory.feeEscrow() != address(escrow)) revert InvalidConfiguration(); if (IPonsLaunchIdentity(address(factory)).launchDeployer() != launchDeployer || launchDeployer.codehash != launchDeployerCodeHash) revert InvalidConfiguration(); IPonsFactory.Launch memory launch = factory.getLaunchedToken(token); if (!launch.exists || launch.token != token || launch.deployer != expectedDeployer || launch.curve.code.length == 0 || launch.creatorFeeRecipient != address(this) || launch.pairToken != address(0) || launch.buybackEnabled) revert InvalidConfiguration(); if (IPonsTokenIdentity(token).launchFactory() != address(factory) || IPonsTokenIdentity(token).deployer() != expectedDeployer || IPonsTokenIdentity(token).curve() != launch.curve) revert InvalidConfiguration(); if (IERC20Metadata(token).decimals() != 18) revert InvalidConfiguration(); IPonsFactory.Policy memory policy = factory.getLaunchFeePolicy(token); uint256 baseFee = IPonsCurve(launch.curve).feeBps(); if (launch.creatorTaxBps != 500 || policy.protocolFeeShareBps > 10_000 || baseFee > 10_000 || baseFee != policy.hookFeeBps) revert InvalidConfiguration(); return launch.curve; } function launchReady() public view returns (bool) { if (isShutdown) return false; try this.assertLaunch() returns (address) { return true; } catch { return false; } } function curve() public view returns (address) { try this.assertLaunch() returns (address found) { return found; } catch { return address(0); } } function bindGame(address game_) external onlyOwner whenOpen { if (address(game) != address(0) || token == address(0) || game_.code.length == 0) revert InvalidConfiguration(); IJackpotFunding candidate = IJackpotFunding(game_); if (candidate.feeVault() != address(this) || candidate.token() != token) revert InvalidConfiguration(); game = candidate; emit GameBound(game_); } function configureOperations(address payable keeper_, uint256 reserve_) external onlyOwner whenOpen { if (keeper_ == address(0)) revert InvalidConfiguration(); keeper = keeper_; retainedReserve = reserve_; emit KeeperChanged(keeper_, reserve_); } function collectFees() external nonReentrant returns (uint256) { return _collectFees(); } function _collectFees() private returns (uint256 received) { uint256 beforeBalance = address(this).balance; uint256 claimed; if (escrow.balanceOf(address(this)) > 0) claimed = escrow.claim(); received = address(this).balance - beforeBalance; if (received != claimed) revert InvalidConfiguration(); } function sweepCurveFees() external nonReentrant { address found = assertLaunch(); if (IPonsCurve(found).graduated() || IPonsCurve(found).buybackQuoteBalance() != 0) revert InvalidConfiguration(); IPonsCurve(found).sweepFees(0); } function fundGame() external nonReentrant { _fundGame(); } function _fundGame() private { if (isShutdown) revert InvalidConfiguration(); if (address(game) == address(0)) revert InvalidConfiguration(); uint256 amount = poolFunds; poolFunds = 0; if (amount != 0) game.fundPot{value: amount}(); } /// @notice Ordinary fee processing; no token binding or activation is performed. /// A sweep temporarily failing must not block use of an already funded prize. function syncFees() external nonReentrant whenOpen { address found = assertLaunch(); if (!IPonsCurve(found).graduated() && IPonsCurve(found).buybackQuoteBalance() == 0) { try IPonsCurve(found).sweepFees(0) {} catch { emit FeeSweepDeferred(found); } } _collectFees(); _fundGame(); } // The keeper can spend only creator-side money, never pool funds or game liabilities. function allocateOperations(uint256 gameAmount, uint256 keeperAmount) external nonReentrant whenOpen { if (msg.sender != keeper && msg.sender != owner()) revert Unauthorized(); uint256 amount = gameAmount + keeperAmount; if (amount > creatorFunds || (gameAmount != 0 && address(game) == address(0))) revert InsufficientBudget(); creatorFunds -= amount; totalOperatingAllocated += amount; if (gameAmount != 0) game.fundOperations{value: gameAmount}(); if (keeperAmount != 0) { (bool ok,) = keeper.call{value: keeperAmount}(""); if (!ok) revert PaymentFailed(); } emit OperationsAllocated(gameAmount, keeperAmount); } function remitCreator() external nonReentrant whenOpen { if (msg.sender != keeper && msg.sender != creator && msg.sender != owner()) revert Unauthorized(); uint256 amount = creatorFunds > retainedReserve ? creatorFunds - retainedReserve : 0; if (amount == 0) return; creatorFunds -= amount; totalCreatorPaid += amount; (bool ok,) = creator.call{value: amount}(""); if (!ok) revert PaymentFailed(); emit CreatorPaid(amount); } }