// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; /// ============================================================ /// LastCallHook — the LASTCALL game clock and pot as a Uniswap v4 hook. /// /// LASTCALL's order flow lives on pump.fun (Solana). This hook is the game's /// Ethereum half: the doubling round clock, the public last-buyer record, and /// an ETH pot — all enforced by the pool the hook is attached to. /// /// 1. Initializing the ETH/wLAST pool with this hook STARTS round one: /// five minutes on the clock. Every settle doubles it. Forever. /// 2. The last buyer is recorded two ways: /// - natively, from real v4 swaps in the pool (hookData names the /// buyer so routers don't take credit), and /// - by the oracle, which mirrors every qualifying pump.fun buy of /// the token into recordBuy() — the buyer's Solana wallet is /// written on-chain here as a bytes32. /// 3. The pot accrues from a 1% fee on pool swaps plus fund(), which the /// oracle feeds with a share of each round's pump.fun creator fees. /// 4. After the deadline ANYONE can crank settle(): the pot sweeps to the /// payout router for delivery to the winner on Solana, the winner's /// wallet is emitted in the settle event, and the clock doubles. /// /// A round with no qualifying buyer rolls its pot into the next round. /// /// Build: solc 0.8.26 + v4-core (see tools/compile-hook.js). The deployed /// address encodes AFTER_INITIALIZE | AFTER_SWAP | AFTER_SWAP_RETURNS_DELTA /// in its low bits — mined via CREATE2 (tools/mine-hook.js). /// ============================================================ import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {IHooks} from "v4-core/interfaces/IHooks.sol"; import {Hooks} from "v4-core/libraries/Hooks.sol"; import {PoolKey} from "v4-core/types/PoolKey.sol"; import {Currency} from "v4-core/types/Currency.sol"; import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; import {BeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol"; import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol"; contract LastCallHook is IHooks { // ---------- config ---------- uint256 public constant BASE_DURATION = 5 minutes; // round 1; doubles each round uint256 public constant POT_FEE_BPS = 100; // 1% of swap output feeds the pot uint256 public immutable minBuyQuote; // dust buys don't take the spot IPoolManager public immutable poolManager; address public oracle; // mirrors pump.fun buys + feeds the pot; rotatable address public payoutRouter; // receives the swept pot for delivery on Solana Currency public quote; // what buyers spend (native ETH) Currency public token; // what buyers receive (wLAST) // ---------- game state ---------- uint64 public round; // 1-based; 0 = pool not initialized yet uint256 public roundDuration; uint256 public deadline; // unix; settle() is callable after this bytes32 public lastBuyerId; // Solana wallet (or zero-padded ETH address) uint256 public lastBuyAt; uint256 public potQuote; // ETH pot (fund() + sell-side swap fees) uint256 public potToken; // wLAST pot (buy-side swap fees) uint256 public totalPaidQuote; uint256 public totalPaidToken; uint64 public roundsPaid; // ---------- events ---------- event RoundStarted(uint64 indexed round, uint256 duration, uint256 deadline); event LastCallTaken(uint64 indexed round, bytes32 indexed buyerId, uint256 amount, bool mirrored); event PotFunded(uint64 indexed round, uint256 amount); event RoundSettled(uint64 indexed round, bytes32 indexed winnerId, uint256 paidQuote, uint256 paidToken); error NotPoolManager(); error NotOracle(); error NotStarted(); error RoundStillLive(); error RoundIsOver(); error HookNotUsed(); modifier onlyPoolManager() { if (msg.sender != address(poolManager)) revert NotPoolManager(); _; } modifier onlyOracle() { if (msg.sender != oracle) revert NotOracle(); _; } constructor(IPoolManager _poolManager, uint256 _minBuyQuote, address _oracle) { poolManager = _poolManager; minBuyQuote = _minBuyQuote; oracle = _oracle; payoutRouter = _oracle; Hooks.validateHookPermissions(this, getHookPermissions()); } function getHookPermissions() public pure returns (Hooks.Permissions memory) { return Hooks.Permissions({ beforeInitialize: false, afterInitialize: true, beforeAddLiquidity: false, afterAddLiquidity: false, beforeRemoveLiquidity: false, afterRemoveLiquidity: false, beforeSwap: false, afterSwap: true, beforeDonate: false, afterDonate: false, beforeSwapReturnDelta: false, afterSwapReturnDelta: true, afterAddLiquidityReturnDelta: false, afterRemoveLiquidityReturnDelta: false }); } // ---------- lifecycle: initializing the pool starts round one ---------- function afterInitialize(address, PoolKey calldata key, uint160, int24) external onlyPoolManager returns (bytes4) { quote = key.currency0; // native ETH sorts first token = key.currency1; round = 1; roundDuration = BASE_DURATION; deadline = block.timestamp + BASE_DURATION; emit RoundStarted(1, BASE_DURATION, deadline); return IHooks.afterInitialize.selector; } // ---------- native path: real v4 swaps in the pool ---------- function afterSwap( address, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata hookData ) external onlyPoolManager returns (bytes4, int128) { bool isBuy = params.zeroForOne; // ETH in, wLAST out // pot fee comes out of the swap's unspecified side (the output on // exact-input swaps). Exact-output swaps pass through fee-free. int128 feeDelta = 0; if (params.amountSpecified < 0) { (Currency outCur, int128 outAmt) = isBuy ? (key.currency1, delta.amount1()) : (key.currency0, delta.amount0()); if (outAmt > 0) { uint256 fee = (uint256(uint128(outAmt)) * POT_FEE_BPS) / 10_000; if (fee > 0) { poolManager.take(outCur, address(this), fee); if (isBuy) potToken += fee; else potQuote += fee; feeDelta = int128(uint128(fee)); } } } if (isBuy && block.timestamp < deadline) { uint256 quoteIn = uint256(uint128(-delta.amount0())); if (quoteIn >= minBuyQuote) { // routers execute the swap — hookData names the real buyer bytes32 buyer = hookData.length >= 32 ? abi.decode(hookData, (bytes32)) : bytes32(uint256(uint160(tx.origin))); lastBuyerId = buyer; lastBuyAt = block.timestamp; emit LastCallTaken(round, buyer, quoteIn, false); } } return (IHooks.afterSwap.selector, feeDelta); } // ---------- mirror path: pump.fun order flow, written here ---------- /// Oracle mirrors a qualifying pump.fun buy. buyerId is the buyer's /// Solana wallet; lamports is the buy size on Solana. function recordBuy(bytes32 buyerId, uint256 lamports) external onlyOracle { if (round == 0) revert NotStarted(); if (block.timestamp >= deadline) revert RoundIsOver(); lastBuyerId = buyerId; lastBuyAt = block.timestamp; emit LastCallTaken(round, buyerId, lamports, true); } /// Feed the pot — the oracle routes a share of each round's pump.fun /// creator fees here as ETH. Anyone else is welcome to sweeten it too. function fund() external payable { potQuote += msg.value; emit PotFunded(round, msg.value); } // ---------- settlement: anyone can crank it after the deadline ---------- function settle() external { if (round == 0) revert NotStarted(); if (block.timestamp < deadline) revert RoundStillLive(); uint64 ended = round; bytes32 winner = lastBuyerId; uint256 q = potQuote; uint256 t = potToken; if (winner != bytes32(0)) { // sweep to the payout router, which delivers to the winner's // Solana wallet alongside the round's creator-fee pot potQuote = 0; potToken = 0; totalPaidQuote += q; totalPaidToken += t; roundsPaid += 1; if (q > 0) quote.transfer(payoutRouter, q); if (t > 0) token.transfer(payoutRouter, t); emit RoundSettled(ended, winner, q, t); } else { // nobody bought: the pot rolls into the next round untouched emit RoundSettled(ended, bytes32(0), 0, 0); } // the clock doubles, forever lastBuyerId = bytes32(0); round = ended + 1; roundDuration *= 2; deadline += roundDuration; emit RoundStarted(round, roundDuration, deadline); } // ---------- ops ---------- function setOracle(address next) external onlyOracle { oracle = next; } function setPayoutRouter(address next) external onlyOracle { payoutRouter = next; } // ---------- views for frontends ---------- function gameSnapshot() external view returns ( uint64 round_, uint256 duration_, uint256 deadline_, bytes32 lastBuyerId_, uint256 lastBuyAt_, uint256 potQuote_, uint256 potToken_, bool settleable_ ) { return ( round, roundDuration, deadline, lastBuyerId, lastBuyAt, potQuote, potToken, round > 0 && block.timestamp >= deadline ); } receive() external payable {} // native ETH settles here via take() // ---------- unused IHooks entry points ---------- function beforeInitialize(address, PoolKey calldata, uint160) external pure returns (bytes4) { revert HookNotUsed(); } function beforeAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) external pure returns (bytes4) { revert HookNotUsed(); } function afterAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, BalanceDelta, bytes calldata) external pure returns (bytes4, BalanceDelta) { revert HookNotUsed(); } function beforeRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) external pure returns (bytes4) { revert HookNotUsed(); } function afterRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, BalanceDelta, bytes calldata) external pure returns (bytes4, BalanceDelta) { revert HookNotUsed(); } function beforeSwap(address, PoolKey calldata, SwapParams calldata, bytes calldata) external pure returns (bytes4, BeforeSwapDelta, uint24) { revert HookNotUsed(); } function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { revert HookNotUsed(); } function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { revert HookNotUsed(); } }