Contracts

The contracts behind the Leo game: what they do, what nobody can do with them, and the code itself.

In short

What nobody can do

  • Withdraw a pot, a prize, referral rewards or anyone’s locked tokens: not the team, not us.
  • Mint $LEO, pause a game, upgrade the code or change the rules.
  • Keep your tokens past 24 hours: after that only you can unlock them.
  • Stop a winner being paid: anyone can send a prize, and the next lock sends it too.

Addresses

Robinhood Chain mainnet
Ticker
$LEO
Supply
1,000,000,000
Pair
ETH
Trading fee
2%

The three contracts

Solidity 0.8.30 · Robinhood Chain

SignGame

Leo’s own game

Deploys at launch

Each sign’s game. It is $LEO’s fee recipient on Pons, holds the pot and the locked tokens, runs the 10-minute clock and pays the winner.

What it does

  • Receives every trading fee $LEO pays its creator on Pons: about 1.7% of every trade.
  • Splits each payment: 40% into this sign’s pot, 10% to the Grand game, 50% to the sign’s team, from which referral rewards are paid.
  • Takes locks worth at least 5% of the pot: Buy & lock (it buys $LEO with your ETH and locks it) or tokens you already hold.
  • Puts the clock back to 10:00 on every lock; when it runs out, sends half the pot to the last locker.
  • Keeps the other half for the next round, and gives every lock back to its owner after 24 hours.

What it can't do

  • Nobody can withdraw the pot, a prize or anyone’s locked tokens. There is no function that does it.
  • It can’t mint $LEO, pause, or be upgraded: the code below is the code that runs.
  • The rules (split, 5% entry, 10:00 clock, 24-hour lock, half to the winner) are fixed at deployment.
  • A failing Grand game or referral list can’t stop a lock: their calls are capped and caught.
  • The team has three switches only: set the token once, connect a Grand game once, and move future fees to a new contract after 7 days’ public notice.
_collect() splits every fee payment: pot, Grand, referrers, team.
function _collect() private returns (uint256 amount) {
    LaunchedToken memory launch = factory.getLaunchedToken(token);
    // Ask Pons to pay out pending fees. These calls can fail without harm: on the curve when nothing is owed,
    // in the pool whenever fees wait in the token (then only Pons's own operator may sweep, which it does on a
    // schedule). Either way the credit lands in the fee escrow, which is read below.
    if (launch.phase == GraduationPhase.NotGraduated) {
        try IPonsCurve(curve).sweepFees(0) {} catch {}
    } else if (launch.phase == GraduationPhase.PoolCreated) {
        try IPonsHook(hook).sweepPoolFees(PoolId.unwrap(_poolKey(launch).toId()), 0, 0) {} catch {}
    }

    if (feeEscrow.balanceOf(address(this)) == 0) return 0;
    amount = feeEscrow.claim();

    uint256 toPot = (amount * POT_BPS) / BPS;
    uint256 toGrand = (amount * GRAND_BPS) / BPS;
    uint256 toTeam = amount - toPot - toGrand;
    pot += toPot;
    grandSavings += toGrand;

    // Referral rewards come out of the team's share, as far as this payment covers them.
    uint256 toReferrals = referralDebt < toTeam ? referralDebt : toTeam;
    if (toReferrals != 0) {
        try IReferrals(referrals).fund{value: toReferrals}() {
            referralDebt -= toReferrals;
            toTeam -= toReferrals;
            emit ReferralsFunded(toReferrals);
        } catch {}
    }
    emit FeesCollected(amount, toPot, toGrand, toTeam);

    _forwardGrand();
    (bool sent,) = team.call{value: toTeam}("");
    if (!sent) {
        // Never let the team wallet block the game; it can claim this like a prize.
        prizeOf[team] += toTeam;
        owed += toTeam;
    }
}
_settle() ends a round: half the pot to the last locker, half stays.
/// @dev Ends the round and credits the winner; returns who won.
function _settle() private returns (address winner) {
    uint256 id = round;
    uint256 p = pot;
    uint256 prize = (p * WINNER_BPS) / BPS;
    winner = lastLocker;

    rounds[id] = Round({winner: winner, winnerPrize: prize});

    prizeOf[winner] += prize;
    owed += prize;
    pot = p - prize;
    deadline = 0;
    round = id + 1;

    emit RoundSettled(id, winner, prize, pot);
}
unlock() gives tokens back after 24 hours, only to the wallet that locked them.
/// @notice Take back the tokens of your listed locks once their time is up.
function unlock(uint256[] calldata lockIds) external nonReentrant returns (uint256 amount) {
    for (uint256 i = 0; i < lockIds.length; ++i) {
        uint256 id = lockIds[i];
        Lock storage l = locks[id];
        if (l.owner != msg.sender) revert NotYourLock(id);
        if (l.withdrawn) revert AlreadyWithdrawn(id);
        if (block.timestamp < l.unlockAt) revert StillLocked(id, l.unlockAt);
        l.withdrawn = true;
        amount += l.amount;
    }
    totalLocked -= amount;
    IERC20(token).safeTransfer(msg.sender, amount);
    emit Unlocked(msg.sender, amount);
}
proposeFeeRecipient() the team’s one lasting power, which starts a 7-day public notice.
/// @notice Start the notice period for moving this token's future Pons fees elsewhere.
/// Pots, prizes and locked tokens stay here; only fees earned after the move go to `next`.
function proposeFeeRecipient(address next) external onlyTeam withToken {
    if (next == address(0)) revert ZeroAddress();
    pendingFeeRecipient = next;
    feeRecipientReadyAt = block.timestamp + FEE_ROLE_DELAY;
    emit FeeRecipientProposed(next, feeRecipientReadyAt);
}
The whole of SignGame.sol · 665 lines
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

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 {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "v4-core/interfaces/callback/IUnlockCallback.sol";
import {IHooks} from "v4-core/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol";
import {SwapParams} from "v4-core/types/PoolOperation.sol";
import {BalanceDelta} from "v4-core/types/BalanceDelta.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {FullMath} from "v4-core/libraries/FullMath.sol";
import {StateLibrary} from "v4-core/libraries/StateLibrary.sol";
import {
    GraduationPhase,
    LaunchedToken,
    IPonsFactory,
    IPonsCurve,
    IPonsHook,
    IPonsFeeEscrow
} from "./interfaces/IPons.sol";

/// The Grand game's side of a lock: told about every lock on a joined sign, it decides whether it counts.
interface IGrandGame {
    function recordLock(address locker, uint256 value) external;
}

/// The shared referral list's side of a Buy & lock (Referrals.sol).
interface IReferrals {
    function recordBuy(address buyer, address referrer, uint256 reward) external returns (uint256 credited);
    function fund() external payable;
}

/// @title SignGame
/// @notice One zodiac sign's "last to lock wins" game on top of its own ETH-paired Pons token.
///
/// The game is the token's Pons creator fee recipient. Every fee payment it collects from Pons is split 40% into
/// this sign's pot, 10% to the Grand game (held here until one is connected) and 50% to the sign's team wallet: the
/// team keeps half, and a fifth of the game's half goes on to the Grand as it arrives.
///
/// To enter, a holder locks the sign's token here for `lockLength`, worth at least `minLockNow()` in ETH (5% of the
/// pot at launch settings). Two ways: send ETH and the game buys the token and locks it (`buyAndLock`), or lock
/// tokens already held (`lockTokens`), valued at what they would sell for right now after fees. Every lock puts
/// the clock back to its full length, and locking more than the minimum is fine. When the clock runs out, the last
/// to lock wins half the pot, sent straight to them, and the other half stays as the next round's pot. Locked tokens
/// go back to their owner after `lockLength`.
///
/// When a Grand game is connected, every lock is also reported to it; a failing Grand never blocks a lock here.
///
/// Referrals: every Buy & lock is reported to the shared referral list with the link the buyer came from. If the
/// buyer has referrers there, 20% of the team's share of that buy's fees is credited to them (70% to the direct
/// referrer, 30% to theirs) and paid over from the team's share as the fees come in. Nothing else of the fees is
/// touched, and a failing referral list never blocks a lock.
///
/// The game can be deployed before its token exists: launch the token on Pons with this game as its creator fee
/// recipient, then the sign's wallet calls `setToken` once. Until then the game takes no locks.
contract SignGame is ReentrancyGuard, IUnlockCallback {
    using SafeERC20 for IERC20;
    using PoolIdLibrary for PoolKey;
    using StateLibrary for IPoolManager;

    uint256 private constant BPS = 10_000;
    uint256 private constant Q96 = 2 ** 96;

    /// Shares of every fee payment from Pons. The team gets what is left (50%).
    uint256 public constant POT_BPS = 4_000;
    uint256 public constant GRAND_BPS = 1_000;
    /// The referrers' cut of the team's share of a referred Buy & lock's fees.
    uint256 public constant REFERRAL_BPS = 2_000;

    /// The winner's share of the pot when a clock runs out. The rest (50%) stays for the next round.
    uint256 public constant WINNER_BPS = 5_000;

    /// Notice the team must give before the Pons fee role can move to another address.
    uint256 public constant FEE_ROLE_DELAY = 7 days;

    /// Gas the Grand game gets to record a lock; anything it does beyond that cannot hold a lock up.
    uint256 public constant GRAND_GAS = 300_000;
    /// @notice Gas given to a winner's wallet when its prize is sent. A wallet that refuses the ETH, or tries to burn
    /// the caller's gas, cannot hold up the game; it keeps the prize and claims it itself.
    uint256 public constant PAYOUT_GAS = 60_000;
    /// Gas the referral list gets to record a Buy & lock; like the Grand, it can never hold a lock up.
    uint256 public constant REFERRAL_GAS = 200_000;

    struct Settings {
        uint32 clockLength; // seconds the clock runs after each lock
        uint32 lockLength; // seconds each lock's tokens stay locked
        uint128 minLock; // smallest lock, in wei of value
        uint16 minLockPotBps; // a lock must also be worth at least this share of the pot
        uint128 startFloor; // the clock only starts once the pot holds at least this much
    }

    struct Round {
        address winner;
        uint256 winnerPrize;
    }

    struct Lock {
        address owner;
        uint64 unlockAt;
        bool withdrawn;
        uint256 amount;
    }

    IPonsFactory public immutable factory;
    /// This sign's Pons token and its bonding curve: set at deployment, or once later with `setToken`.
    address public token;
    address public curve;
    address public immutable hook;
    IPoolManager public immutable poolManager;
    IPonsFeeEscrow public immutable feeEscrow;
    /// This sign's own wallet: receives the team share and holds the two team switches.
    address public immutable team;
    /// The referral list shared by every sign, or zero for none.
    address public immutable referrals;

    uint256 public immutable clockLength;
    uint256 public immutable lockLength;
    uint256 public immutable minLock;
    uint256 public immutable minLockPotBps;
    uint256 public immutable startFloor;

    /// ETH waiting to be won in the current round.
    uint256 public pot;
    /// ETH set aside for the Grand game until one is connected.
    uint256 public grandSavings;
    /// Prizes and round shares won but not yet claimed.
    uint256 public owed;
    mapping(address => uint256) public prizeOf;

    uint256 public round = 1;
    address public lastLocker;
    uint256 public lastLockAt;
    /// When the current clock runs out. Zero while the clock is not running.
    uint256 public deadline;
    mapping(uint256 => Round) public rounds;

    Lock[] public locks;
    uint256 public totalLocked;

    address public grand;
    /// Referral rewards already credited to referrers and still to be paid over from the team's share of fees.
    uint256 public referralDebt;
    address public pendingFeeRecipient;
    uint256 public feeRecipientReadyAt;

    event Locked(
        uint256 indexed round,
        address indexed locker,
        uint256 value,
        uint256 tokens,
        uint256 lockId,
        uint256 deadline,
        bool bought
    );
    event FeesCollected(uint256 amount, uint256 toPot, uint256 toGrand, uint256 toTeam);
    event RoundSettled(uint256 indexed round, address indexed winner, uint256 winnerPrize, uint256 potLeft);
    event Claimed(address indexed locker, uint256 amount);
    event PaidOut(address indexed winner, uint256 amount);
    event Unlocked(address indexed locker, uint256 amount);
    event Donated(address indexed from, uint256 amount);
    event GrandConnected(address grand);
    event TokenSet(address token, address curve);
    event GrandFunded(uint256 amount);
    event ReferralsFunded(uint256 amount);
    event FeeRecipientProposed(address next, uint256 readyAt);
    event FeeRecipientCancelled();
    event FeeRecipientMoved(address next);

    error NotTeam();
    error NotEthPair();
    error UnknownToken();
    error NoTokenYet();
    error TokenAlreadySet();
    error NotOurToken();
    error BadSettings();
    error LockTooSmall(uint256 value, uint256 minimum);
    error NotTrading();
    error ClockRunning();
    error NothingToClaim();
    error NotYourLock(uint256 lockId);
    error StillLocked(uint256 lockId, uint256 unlockAt);
    error AlreadyWithdrawn(uint256 lockId);
    error GrandAlreadyConnected();
    error ZeroAddress();
    error NoPendingFeeRecipient();
    error FeeRecipientNotReady(uint256 readyAt);
    error NotPoolManager();
    error TooLittleReceived(uint256 got, uint256 minimum);
    error SendFailed();

    modifier withToken() {
        if (token == address(0)) revert NoTokenYet();
        _;
    }

    modifier onlyTeam() {
        if (msg.sender != team) revert NotTeam();
        _;
    }

    /// @param grand_ The Grand game to report locks to and fund, or zero to connect one later.
    /// @param referrals_ The shared referral list, or zero for none (it can't be added later).
    constructor(
        IPonsFactory factory_,
        address token_,
        address team_,
        address grand_,
        address referrals_,
        Settings memory settings
    ) {
        if (team_ == address(0)) revert ZeroAddress();
        if (settings.clockLength == 0 || settings.lockLength == 0 || settings.minLockPotBps > BPS) revert BadSettings();
        factory = factory_;
        if (token_ != address(0)) {
            LaunchedToken memory launch = factory_.getLaunchedToken(token_);
            if (!launch.exists) revert UnknownToken();
            if (launch.pairToken != address(0)) revert NotEthPair();
            token = token_;
            curve = launch.curve;
            emit TokenSet(token_, launch.curve);
        }
        hook = factory_.memeHook();
        poolManager = IPoolManager(factory_.poolManager());
        feeEscrow = IPonsFeeEscrow(factory_.feeEscrow());
        team = team_;
        grand = grand_;
        referrals = referrals_;

        clockLength = settings.clockLength;
        lockLength = settings.lockLength;
        minLock = settings.minLock;
        minLockPotBps = settings.minLockPotBps;
        startFloor = settings.startFloor;
    }

    // ---------------------------------------------------------------------
    // Locking
    // ---------------------------------------------------------------------

    /// @notice Buy this sign's token with the ETH sent and lock it, in one step.
    /// @param minTokensOut Slippage guard on the buy.
    /// @param referrer The referral link the buyer came from, or zero. It only counts on the buyer's first Buy & lock.
    function buyAndLock(uint256 minTokensOut, address referrer)
        external
        payable
        nonReentrant
        withToken
        returns (uint256 lockId)
    {
        uint256 minimum = _openRound();
        (uint256 spent, uint256 tokensOut, uint256 fee) = _buy(msg.value, minTokensOut);
        if (spent < minimum) revert LockTooSmall(spent, minimum);

        lockId = _enter(msg.sender, spent, tokensOut, true);
        _reward(msg.sender, referrer, fee);
        _collect();

        // A buy that crosses the graduation line can fill only part of the ETH.
        if (spent < msg.value) _send(msg.sender, msg.value - spent);
    }

    /// @notice Lock tokens already held. They count at what they would sell for right now, after fees.
    function lockTokens(uint256 amount) external nonReentrant withToken returns (uint256 lockId) {
        uint256 minimum = _openRound();
        uint256 value = valueOf(amount);
        if (value < minimum) revert LockTooSmall(value, minimum);

        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        lockId = _enter(msg.sender, value, amount, false);
        _collect();
    }

    /// @notice End the round once its clock has run out and send the winner their prize. Anyone may call it; the
    /// next lock also does.
    function settle() external nonReentrant {
        if (deadline == 0 || block.timestamp < deadline) revert ClockRunning();
        _collect();
        _pay(_settle());
    }

    /// @notice Send `winner` every prize they have won and not yet received. Anyone may call it, so a win never waits
    /// on the winner; if the clock has run out it settles that round first. Round shares stay with claim().
    function payOut(address winner) external nonReentrant returns (uint256 amount) {
        if (deadline != 0 && block.timestamp >= deadline) {
            _collect();
            _settle();
        }
        amount = _pay(winner);
        if (amount == 0) revert NothingToClaim();
    }

    /// @notice Pull this sign's fees out of Pons and split them. Anyone may call it.
    function collect() external nonReentrant withToken returns (uint256 amount) {
        return _collect();
    }

    /// @notice Add ETH straight to the pot. Plain ETH sent to the contract does the same.
    function donate() external payable {
        pot += msg.value;
        emit Donated(msg.sender, msg.value);
    }

    // ---------------------------------------------------------------------
    // Claiming
    // ---------------------------------------------------------------------

    /// @notice Withdraw prizes that could not be sent to you (a wallet that refused ETH), or the team's fee share
    /// when its wallet refused it. Prizes are normally sent straight to the winner.
    function claim() external nonReentrant returns (uint256 amount) {
        amount = prizeOf[msg.sender];
        prizeOf[msg.sender] = 0;
        if (amount == 0) revert NothingToClaim();
        owed -= amount;
        _send(msg.sender, amount);
        emit Claimed(msg.sender, amount);
    }

    /// @notice Take back the tokens of your listed locks once their time is up.
    function unlock(uint256[] calldata lockIds) external nonReentrant returns (uint256 amount) {
        for (uint256 i = 0; i < lockIds.length; ++i) {
            uint256 id = lockIds[i];
            Lock storage l = locks[id];
            if (l.owner != msg.sender) revert NotYourLock(id);
            if (l.withdrawn) revert AlreadyWithdrawn(id);
            if (block.timestamp < l.unlockAt) revert StillLocked(id, l.unlockAt);
            l.withdrawn = true;
            amount += l.amount;
        }
        totalLocked -= amount;
        IERC20(token).safeTransfer(msg.sender, amount);
        emit Unlocked(msg.sender, amount);
    }

    // ---------------------------------------------------------------------
    // Team switches
    // ---------------------------------------------------------------------

    /// @notice Connect a Grand game if none was set at deployment. Works once and can never be changed.
    /// @notice Point a game deployed ahead of its launch at the token, once. The token must be a Pons ETH-pair token
    /// launched by this sign's wallet with this game as its creator fee recipient, so its fees already come here.
    function setToken(address token_) external onlyTeam nonReentrant {
        if (token != address(0)) revert TokenAlreadySet();
        LaunchedToken memory launch = factory.getLaunchedToken(token_);
        if (!launch.exists) revert UnknownToken();
        if (launch.pairToken != address(0)) revert NotEthPair();
        if (launch.deployer != team || launch.creatorFeeRecipient != address(this)) revert NotOurToken();
        token = token_;
        curve = launch.curve;
        emit TokenSet(token_, launch.curve);
    }

    function connectGrand(address grand_) external onlyTeam nonReentrant {
        if (grand != address(0)) revert GrandAlreadyConnected();
        if (grand_ == address(0)) revert ZeroAddress();
        grand = grand_;
        emit GrandConnected(grand_);
        _forwardGrand();
    }

    /// @notice Start the notice period for moving this token's future Pons fees elsewhere.
    /// Pots, prizes and locked tokens stay here; only fees earned after the move go to `next`.
    function proposeFeeRecipient(address next) external onlyTeam withToken {
        if (next == address(0)) revert ZeroAddress();
        pendingFeeRecipient = next;
        feeRecipientReadyAt = block.timestamp + FEE_ROLE_DELAY;
        emit FeeRecipientProposed(next, feeRecipientReadyAt);
    }

    function cancelFeeRecipient() external onlyTeam {
        if (pendingFeeRecipient == address(0)) revert NoPendingFeeRecipient();
        pendingFeeRecipient = address(0);
        feeRecipientReadyAt = 0;
        emit FeeRecipientCancelled();
    }

    /// @notice Carry out a proposed move once its notice has passed. Anyone may call it.
    function executeFeeRecipient() external nonReentrant {
        address next = pendingFeeRecipient;
        if (next == address(0)) revert NoPendingFeeRecipient();
        if (block.timestamp < feeRecipientReadyAt) revert FeeRecipientNotReady(feeRecipientReadyAt);
        _collect();
        pendingFeeRecipient = address(0);
        feeRecipientReadyAt = 0;
        factory.transferCreatorFeeRecipient(token, next);
        emit FeeRecipientMoved(next);
    }

    // ---------------------------------------------------------------------
    // Views
    // ---------------------------------------------------------------------

    /// @notice The smallest lock that enters right now, in ETH of value.
    function minLockNow() public view returns (uint256) {
        uint256 potMin = (pot * minLockPotBps) / BPS;
        return potMin > minLock ? potMin : minLock;
    }

    /// @notice What `tokens` of this sign are worth in ETH right now: what selling them would bring after trading fees
    /// and price impact, on the bonding curve while it trades and in the Uniswap pool after graduation.
    function valueOf(uint256 tokens) public view withToken returns (uint256) {
        if (tokens == 0) return 0;
        LaunchedToken memory launch = factory.getLaunchedToken(token);
        if (launch.phase == GraduationPhase.NotGraduated) {
            (uint256 quoteReserve, uint256 tokenReserve) = IPonsCurve(curve).getReserves();
            uint256 gross = (tokens * quoteReserve) / (tokenReserve + tokens);
            uint256 feeBps = IPonsCurve(curve).feeBps() + IPonsCurve(curve).creatorTaxBps();
            return gross - (gross * feeBps) / BPS;
        }
        if (launch.phase == GraduationPhase.PoolCreated) {
            // The same sale as on the curve, against the pool's active liquidity as constant-product reserves, so a
            // big lock is valued with its price impact rather than at the spot price. ETH is currency0.
            PoolId id = _poolKey(launch).toId();
            (uint160 sqrtPriceX96,,,) = poolManager.getSlot0(id);
            uint128 liquidity = poolManager.getLiquidity(id);
            uint256 ethReserve = FullMath.mulDiv(liquidity, Q96, sqrtPriceX96);
            uint256 tokenReserve = FullMath.mulDiv(liquidity, sqrtPriceX96, Q96);
            uint256 gross = (tokens * ethReserve) / (tokenReserve + tokens);
            uint256 feeBps = IPonsHook(hook).hookFeeBps() + launch.creatorTaxBps;
            return gross - (gross * feeBps) / BPS;
        }
        revert NotTrading();
    }

    /// @notice Seconds left on the clock: zero when it has run out or has not started.
    function timeLeft() external view returns (uint256) {
        return deadline > block.timestamp ? deadline - block.timestamp : 0;
    }

    function lockCount() external view returns (uint256) {
        return locks.length;
    }

    /// @notice Fees Pons has already credited to this game but not yet paid over.
    function escrowBalance() external view returns (uint256) {
        return feeEscrow.balanceOf(address(this));
    }

    // ---------------------------------------------------------------------
    // Internals
    // ---------------------------------------------------------------------

    /// @dev Before a lock: pay out a round whose clock has run out (with every fee owed in it), then return the entry
    /// minimum for the pot as the holder saw it. Fees still waiting at Pons are pulled in after the lock is recorded,
    /// so they cannot raise the minimum (here or in the Grand game) under a lock already on its way.
    /// @dev Ends a finished round first, if there is one, and returns the minimum this lock must meet. That is the
    /// minimum the visitor saw before sending: ending the round pulls in waiting fees and halves the pot, which can
    /// leave the next pot bigger than the one shown, so the lower of the two applies.
    function _openRound() private returns (uint256 minimum) {
        minimum = minLockNow();
        if (deadline != 0 && block.timestamp >= deadline) {
            _collect();
            _pay(_settle());
            uint256 next = minLockNow();
            if (next < minimum) minimum = next;
        }
    }

    function _enter(address locker, uint256 value, uint256 tokens, bool bought) private returns (uint256 lockId) {
        lockId = locks.length;
        locks.push(Lock({owner: locker, unlockAt: uint64(block.timestamp + lockLength), withdrawn: false, amount: tokens}));
        totalLocked += tokens;

        uint256 id = round;
        lastLocker = locker;
        lastLockAt = block.timestamp;
        if (deadline != 0 || pot >= startFloor) deadline = block.timestamp + clockLength;

        emit Locked(id, locker, value, tokens, lockId, deadline, bought);

        // The Grand game decides for itself whether this lock counts there. Nothing it does can undo this lock:
        // its gas is capped, its reverts are caught, and an address without code is never called.
        if (grand.code.length > 0) {
            try IGrandGame(grand).recordLock{gas: GRAND_GAS}(locker, value) {} catch {}
        }
    }

    /// @dev Reports a Buy & lock to the referral list, which credits the buyer's referrers 20% of the team's share
    /// of `fee` (what this buy pays the game); the game owes that from the team's share until it is paid over.
    function _reward(address buyer, address referrer, uint256 fee) private {
        if (referrals.code.length == 0) return;
        uint256 reward = (((fee * (BPS - POT_BPS - GRAND_BPS)) / BPS) * REFERRAL_BPS) / BPS;
        try IReferrals(referrals).recordBuy{gas: REFERRAL_GAS}(buyer, referrer, reward) returns (
            uint256 credited
        ) {
            // Never more than was asked for, so the list can't run up the team's bill.
            referralDebt += credited < reward ? credited : reward;
        } catch {}
    }

    function _collect() private returns (uint256 amount) {
        LaunchedToken memory launch = factory.getLaunchedToken(token);
        // Ask Pons to pay out pending fees. These calls can fail without harm: on the curve when nothing is owed,
        // in the pool whenever fees wait in the token (then only Pons's own operator may sweep, which it does on a
        // schedule). Either way the credit lands in the fee escrow, which is read below.
        if (launch.phase == GraduationPhase.NotGraduated) {
            try IPonsCurve(curve).sweepFees(0) {} catch {}
        } else if (launch.phase == GraduationPhase.PoolCreated) {
            try IPonsHook(hook).sweepPoolFees(PoolId.unwrap(_poolKey(launch).toId()), 0, 0) {} catch {}
        }

        if (feeEscrow.balanceOf(address(this)) == 0) return 0;
        amount = feeEscrow.claim();

        uint256 toPot = (amount * POT_BPS) / BPS;
        uint256 toGrand = (amount * GRAND_BPS) / BPS;
        uint256 toTeam = amount - toPot - toGrand;
        pot += toPot;
        grandSavings += toGrand;

        // Referral rewards come out of the team's share, as far as this payment covers them.
        uint256 toReferrals = referralDebt < toTeam ? referralDebt : toTeam;
        if (toReferrals != 0) {
            try IReferrals(referrals).fund{value: toReferrals}() {
                referralDebt -= toReferrals;
                toTeam -= toReferrals;
                emit ReferralsFunded(toReferrals);
            } catch {}
        }
        emit FeesCollected(amount, toPot, toGrand, toTeam);

        _forwardGrand();
        (bool sent,) = team.call{value: toTeam}("");
        if (!sent) {
            // Never let the team wallet block the game; it can claim this like a prize.
            prizeOf[team] += toTeam;
            owed += toTeam;
        }
    }

    /// @dev Ends the round and credits the winner; returns who won.
    function _settle() private returns (address winner) {
        uint256 id = round;
        uint256 p = pot;
        uint256 prize = (p * WINNER_BPS) / BPS;
        winner = lastLocker;

        rounds[id] = Round({winner: winner, winnerPrize: prize});

        prizeOf[winner] += prize;
        owed += prize;
        pot = p - prize;
        deadline = 0;
        round = id + 1;

        emit RoundSettled(id, winner, prize, pot);
    }

    function _forwardGrand() private {
        uint256 amount = grandSavings;
        if (grand == address(0) || amount == 0) return;
        grandSavings = 0;
        (bool sent,) = grand.call{value: amount}("");
        if (sent) {
            emit GrandFunded(amount);
        } else {
            grandSavings = amount;
        }
    }

    /// @dev Buys with up to `amount` ETH. Returns the ETH spent, the tokens bought, and the part of the trading fees
    /// on this buy that comes to this game: the creator tax plus the base fee less Pons's own share.
    function _buy(uint256 amount, uint256 minTokensOut)
        private
        returns (uint256 spent, uint256 tokensOut, uint256 fee)
    {
        LaunchedToken memory launch = factory.getLaunchedToken(token);
        uint256 tokensBefore = IERC20(token).balanceOf(address(this));

        if (launch.phase == GraduationPhase.NotGraduated) {
            // The curve refunds any ETH it could not fill to this contract.
            uint256 ethBefore = address(this).balance - amount;
            IPonsCurve(curve).buy{value: amount}(amount, minTokensOut, address(this));
            spent = amount - (address(this).balance - ethBefore);
            IPonsCurve c = IPonsCurve(curve);
            fee = _feeOf(spent, c.feeBps(), c.protocolFeeShareBps(), c.creatorTaxBps());
        } else if (launch.phase == GraduationPhase.PoolCreated) {
            spent = abi.decode(poolManager.unlock(abi.encode(_poolKey(launch), amount, minTokensOut)), (uint256));
            IPonsHook h = IPonsHook(hook);
            fee = _feeOf(spent, h.hookFeeBps(), h.protocolFeeShareBps(), launch.creatorTaxBps);
        } else {
            // Between the curve closing and the pool opening nothing can be bought.
            revert NotTrading();
        }

        tokensOut = IERC20(token).balanceOf(address(this)) - tokensBefore;
    }

    /// @dev Exact-input ETH -> token swap in the graduated pool. Only reachable through `_buy`.
    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        (PoolKey memory key, uint256 amountIn, uint256 minTokensOut) = abi.decode(data, (PoolKey, uint256, uint256));

        BalanceDelta delta = poolManager.swap(
            key,
            SwapParams({
                zeroForOne: true,
                amountSpecified: -int256(amountIn),
                sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
            }),
            ""
        );
        uint256 paid = uint256(uint128(-delta.amount0()));
        uint256 got = uint256(uint128(delta.amount1()));
        if (got < minTokensOut) revert TooLittleReceived(got, minTokensOut);

        poolManager.settle{value: paid}();
        poolManager.take(key.currency1, address(this), got);
        return abi.encode(paid);
    }

    /// @dev What a trade of `amount` pays this game: the creator tax, plus the base fee less Pons's share of it.
    function _feeOf(uint256 amount, uint256 feeBps, uint256 protocolShareBps, uint256 taxBps)
        private
        pure
        returns (uint256)
    {
        uint256 baseFee = (amount * feeBps) / BPS;
        return baseFee - (baseFee * protocolShareBps) / BPS + (amount * taxBps) / BPS;
    }

    /// @dev Native ETH sorts below every token address, so it is always currency0.
    function _poolKey(LaunchedToken memory launch) private view returns (PoolKey memory) {
        return PoolKey({
            currency0: Currency.wrap(address(0)),
            currency1: Currency.wrap(token),
            fee: launch.poolFee,
            tickSpacing: launch.tickSpacing,
            hooks: IHooks(hook)
        });
    }

    /// @dev Sends `to` their credited prizes with capped gas. On a refusal the credit stays for them to claim.
    function _pay(address to) private returns (uint256 amount) {
        amount = prizeOf[to];
        if (amount == 0) return 0;
        prizeOf[to] = 0;
        owed -= amount;
        (bool sent,) = to.call{value: amount, gas: PAYOUT_GAS}("");
        if (!sent) {
            prizeOf[to] = amount;
            owed += amount;
            return 0;
        }
        emit PaidOut(to, amount);
    }

    function _send(address to, uint256 amount) private {
        (bool sent,) = to.call{value: amount}("");
        if (!sent) revert SendFailed();
    }

    /// @dev Pons fee payouts, curve refunds and pool settlement arrive here and are counted where
    /// they are triggered. Anything else sent to the contract is treated as a donation to the pot.
    receive() external payable {
        if (msg.sender == address(feeEscrow) || msg.sender == curve || msg.sender == address(poolManager)) return;
        pot += msg.value;
        emit Donated(msg.sender, msg.value);
    }
}

GrandGame

One game across every sign

0x332f…513f

The game over all twelve signs. It is filled by 10% of every sign’s fees and has its own 10-minute clock.

What it does

  • Receives 10% of every sign’s fees as they are collected.
  • Counts a lock on any sign as an entry when it is worth at least 5% of the Grand pot.
  • Puts its clock back to 10:00 on every entry; when it runs out, sends half the pot to the last entry.

What it can't do

  • Nobody can withdraw its pot. It holds no tokens: the locks stay in each sign’s game.
  • Its owner (the main wallet) can only add newly launched signs, at most twelve, and never remove one.
  • Its rules are fixed at deployment, and it can’t block a lock on any sign.
recordLock() decides whether a lock on a sign also enters the Grand game.
/// @notice Called by a joined sign game for every lock there. Locks worth less than `minEntryNow()` only count
/// in their sign's own game.
function recordLock(address locker, uint256 value) external {
    if (!isSign[msg.sender]) revert NotSign();
    if (deadline != 0 && block.timestamp >= deadline) _settle();
    if (value < minEntryNow()) return;

    uint256 id = round;
    lastLocker = locker;
    lastSign = msg.sender;
    lastLockAt = block.timestamp;
    if (deadline != 0 || pot >= startFloor) deadline = block.timestamp + clockLength;
    emit Entered(id, locker, msg.sender, value, deadline);
}
_settle() half the Grand pot to the last entry, half stays.
/// @dev Ends the round and credits the winner; returns who won.
function _settle() private returns (address winner) {
    winner = lastLocker;
    uint256 id = round;
    uint256 p = pot;
    uint256 prize = (p * WINNER_BPS) / BPS;

    rounds[id] = Round({winner: lastLocker, winnerSign: lastSign, winnerPrize: prize});

    prizeOf[lastLocker] += prize;
    owed += prize;
    pot = p - prize;
    deadline = 0;
    round = id + 1;

    emit RoundSettled(id, lastLocker, lastSign, prize, pot);
}
The whole of GrandGame.sol · 208 lines
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// The one thing the Grand game asks of a sign game before letting it join.
interface ISignGameGrand {
    function grand() external view returns (address);
}

/// @title GrandGame
/// @notice The "last to lock wins" game across every launched zodiac sign. It runs from the first sign and grows as
/// signs join: the owner adds each sign's game when that sign launches (append-only, at most twelve, never removed).
///
/// Its pot is what joined signs send it: 10% of every fee payment each sign collects from Pons, a fifth of the game's
/// half. It holds no tokens itself; locks stay in the sign games. Every lock on a joined sign is reported here, and
/// it counts when it is worth at least `minEntryNow()` (5% of this pot at launch settings): it becomes the last entry
/// and puts this clock back to full. When the clock runs out, the last entry wins half the pot and the other half
/// stays for the next round.
contract GrandGame is ReentrancyGuard {
    uint256 private constant BPS = 10_000;
    /// The winner's share of the pot when the clock runs out. The rest (50%) stays for the next round.
    uint256 public constant WINNER_BPS = 5_000;
    uint256 public constant MAX_SIGNS = 12;
    /// @notice Gas given to a winner's wallet when its prize is sent; a wallet that refuses keeps it to claim.
    uint256 public constant PAYOUT_GAS = 60_000;

    struct Settings {
        uint32 clockLength; // seconds the clock runs after each entry
        uint128 minEntry; // smallest lock that counts here, in wei of value
        uint16 minEntryPotBps; // an entry must also be worth at least this share of the pot
        uint128 startFloor; // the clock only starts once the pot holds at least this much
    }

    struct Round {
        address winner;
        address winnerSign;
        uint256 winnerPrize;
    }

    /// May add signs, and do nothing else.
    address public immutable owner;
    uint256 public immutable clockLength;
    uint256 public immutable minEntry;
    uint256 public immutable minEntryPotBps;
    uint256 public immutable startFloor;

    address[] public signs;
    mapping(address => bool) public isSign;

    uint256 public pot;
    uint256 public owed;
    mapping(address => uint256) public prizeOf;

    uint256 public round = 1;
    address public lastLocker;
    address public lastSign;
    uint256 public lastLockAt;
    uint256 public deadline;
    mapping(uint256 => Round) public rounds;

    event SignAdded(address indexed sign, uint256 count);
    event Funded(address indexed from, uint256 amount);
    event Entered(uint256 indexed round, address indexed locker, address indexed sign, uint256 value, uint256 deadline);
    event RoundSettled(
        uint256 indexed round,
        address indexed winner,
        address sign,
        uint256 winnerPrize,
        uint256 potLeft
    );
    event Claimed(address indexed locker, uint256 amount);
    event PaidOut(address indexed winner, uint256 amount);

    error NotOwner();
    error NotSign();
    error AlreadySign();
    error TooManySigns();
    error WrongGrand();
    error BadSettings();
    error ClockRunning();
    error NothingToClaim();
    error SendFailed();

    constructor(address owner_, Settings memory settings) {
        if (owner_ == address(0) || settings.clockLength == 0 || settings.minEntryPotBps > BPS) revert BadSettings();
        owner = owner_;
        clockLength = settings.clockLength;
        minEntry = settings.minEntry;
        minEntryPotBps = settings.minEntryPotBps;
        startFloor = settings.startFloor;
    }

    /// @notice Let a newly launched sign's game join. It must already point at this Grand game.
    function addSign(address game) external {
        if (msg.sender != owner) revert NotOwner();
        if (isSign[game]) revert AlreadySign();
        if (signs.length >= MAX_SIGNS) revert TooManySigns();
        if (ISignGameGrand(game).grand() != address(this)) revert WrongGrand();
        isSign[game] = true;
        signs.push(game);
        emit SignAdded(game, signs.length);
    }

    /// @notice Called by a joined sign game for every lock there. Locks worth less than `minEntryNow()` only count
    /// in their sign's own game.
    function recordLock(address locker, uint256 value) external {
        if (!isSign[msg.sender]) revert NotSign();
        if (deadline != 0 && block.timestamp >= deadline) _settle();
        if (value < minEntryNow()) return;

        uint256 id = round;
        lastLocker = locker;
        lastSign = msg.sender;
        lastLockAt = block.timestamp;
        if (deadline != 0 || pot >= startFloor) deadline = block.timestamp + clockLength;
        emit Entered(id, locker, msg.sender, value, deadline);
    }

    /// @notice End the round once its clock has run out and send the winner their prize. Anyone may call it. The
    /// next entry also ends it, but only credits the prize, since a sign's lock leaves this game little gas.
    function settle() external nonReentrant {
        if (deadline == 0 || block.timestamp < deadline) revert ClockRunning();
        _pay(_settle());
    }

    /// @notice Send `winner` every prize they have won and not yet received. Anyone may call it, so a win never waits
    /// on the winner; if the clock has run out it settles that round first. Round shares stay with claim().
    function payOut(address winner) external nonReentrant returns (uint256 amount) {
        if (deadline != 0 && block.timestamp >= deadline) _settle();
        amount = _pay(winner);
        if (amount == 0) revert NothingToClaim();
    }

    /// @notice Withdraw your winner prizes: ones credited when a sign's lock ended the round, or ones your wallet
    /// refused. Anyone can also send them to you with `payOut`.
    function claim() external nonReentrant returns (uint256 amount) {
        amount = prizeOf[msg.sender];
        prizeOf[msg.sender] = 0;
        if (amount == 0) revert NothingToClaim();
        owed -= amount;
        (bool sent,) = msg.sender.call{value: amount}("");
        if (!sent) revert SendFailed();
        emit Claimed(msg.sender, amount);
    }

    // ---------------------------------------------------------------------
    // Views
    // ---------------------------------------------------------------------

    /// @notice The smallest lock, in ETH of value, that counts here right now.
    function minEntryNow() public view returns (uint256) {
        uint256 potMin = (pot * minEntryPotBps) / BPS;
        return potMin > minEntry ? potMin : minEntry;
    }

    function signCount() external view returns (uint256) {
        return signs.length;
    }

    function timeLeft() external view returns (uint256) {
        return deadline > block.timestamp ? deadline - block.timestamp : 0;
    }

    // ---------------------------------------------------------------------
    // Internals
    // ---------------------------------------------------------------------

    /// @dev Ends the round and credits the winner; returns who won.
    function _settle() private returns (address winner) {
        winner = lastLocker;
        uint256 id = round;
        uint256 p = pot;
        uint256 prize = (p * WINNER_BPS) / BPS;

        rounds[id] = Round({winner: lastLocker, winnerSign: lastSign, winnerPrize: prize});

        prizeOf[lastLocker] += prize;
        owed += prize;
        pot = p - prize;
        deadline = 0;
        round = id + 1;

        emit RoundSettled(id, lastLocker, lastSign, prize, pot);
    }

    /// @dev Sends `to` their credited prizes with capped gas. On a refusal the credit stays for them to claim.
    function _pay(address to) private returns (uint256 amount) {
        amount = prizeOf[to];
        if (amount == 0) return 0;
        prizeOf[to] = 0;
        owed -= amount;
        (bool sent,) = to.call{value: amount, gas: PAYOUT_GAS}("");
        if (!sent) {
            prizeOf[to] = amount;
            owed += amount;
            return 0;
        }
        emit PaidOut(to, amount);
    }

    /// @dev Joined signs send their Grand share here. Anything else sent is a donation; both grow the pot.
    receive() external payable {
        pot += msg.value;
        emit Funded(msg.sender, msg.value);
    }
}

Referrals

One referral list for every sign

0x624b…15F3

Who brought whom, and what each referrer has earned. It is paid only from the team’s share of the fees.

What it does

  • Fixes a wallet’s referrer on its first Buy & lock, on any sign: the link it came through, if any.
  • Credits 20% of the team’s share of each later Buy & lock’s fees: 70% to the direct referrer, 30% to theirs.
  • Pays referrers when they claim, as the fees arrive.

What it can't do

  • A referrer can’t be changed once set, and nobody can refer themselves.
  • It can’t touch the pot or the Grand: rewards come from the team’s share alone.
  • Its owner can only add sign games; nobody can withdraw referrers’ rewards but the referrers.
recordBuy() sets the referrer on a first Buy & lock and credits 70/30.
/// @notice Called by a sign game for every Buy & lock. On the buyer's first one, `referrer` (the link they came
/// from, or zero) becomes their referrer for good. Then `reward` is credited 70/30 to their two levels of
/// referrers. Returns how much was credited, which the game owes this contract from the team's fees.
function recordBuy(address buyer, address referrer, uint256 reward) external returns (uint256 credited) {
    if (!isGame[msg.sender]) revert NotGame();
    if (!joined[buyer]) {
        joined[buyer] = true;
        // No referring yourself, directly or through the wallet you referred.
        if (referrer != address(0) && referrer != buyer && referrerOf[referrer] != buyer) {
            referrerOf[buyer] = referrer;
            referredCount[referrer] += 1;
            emit Referred(buyer, referrer);
        }
    }

    address level1 = referrerOf[buyer];
    if (level1 == address(0) || reward == 0) return 0;
    uint256 first = (reward * LEVEL1_BPS) / BPS;
    _credit(level1, buyer, 1, first);
    credited = first;

    address level2 = referrerOf[level1];
    if (level2 != address(0)) {
        _credit(level2, buyer, 2, reward - first);
        credited = reward;
    }
}
claim() pays a referrer what has arrived for them.
/// @notice Withdraw your referral rewards: everything credited to you that has arrived here so far.
function claim() external nonReentrant returns (uint256 amount) {
    uint256 due = earned[msg.sender];
    uint256 here = address(this).balance;
    amount = due < here ? due : here;
    if (amount == 0) revert NothingToClaim();
    earned[msg.sender] = due - amount;
    totalOwed -= amount;
    (bool sent,) = msg.sender.call{value: amount}("");
    if (!sent) revert SendFailed();
    emit Claimed(msg.sender, amount);
}
The whole of Referrals.sol · 151 lines
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// The one thing Referrals asks of a sign game before letting it record buys.
interface ISignGameReferrals {
    function referrals() external view returns (address);
}

/// @title Referrals
/// @notice One referral list for every zodiac sign: who brought whom, and what each referrer has earned.
///
/// A wallet's referrer is fixed by its first Buy & lock on any sign: the link it came from, if any. That never
/// changes afterwards, and a wallet can't refer itself (or the wallet that referred it). Every later Buy & lock by
/// that wallet, on any sign, earns its referrers a reward that its sign game pays out of the team's share of that
/// buy's fees: 70% to the direct referrer (level 1) and 30% to theirs (level 2). Without a level 2, that 30% stays
/// with the team. A referrer doesn't need to have locked anything.
///
/// Rewards are credited the moment the buy happens and paid in as the buy's fees reach its game (on the bonding
/// curve that is the same transaction; after graduation it waits for Pons to sweep the pool's fees). A referrer
/// claims whenever they like; a claim pays what has arrived and leaves the rest for later.
contract Referrals is ReentrancyGuard {
    uint256 private constant BPS = 10_000;
    /// The direct referrer's share of each reward. The referrer's own referrer gets the rest (30%).
    uint256 public constant LEVEL1_BPS = 7_000;
    uint256 public constant MAX_GAMES = 12;

    /// May add sign games, and do nothing else.
    address public immutable owner;

    address[] public games;
    mapping(address => bool) public isGame;

    /// Who referred each wallet: fixed by its first Buy & lock, zero if it came without a link.
    mapping(address => address) public referrerOf;
    /// Whether a wallet has made its first Buy & lock, after which its referrer can no longer be set.
    mapping(address => bool) public joined;
    /// Wallets each referrer brought in directly.
    mapping(address => uint256) public referredCount;

    /// Credited to a referrer and not yet claimed.
    mapping(address => uint256) public earned;
    /// Everything a referrer has ever been credited, claimed or not.
    mapping(address => uint256) public totalEarned;
    uint256 public totalOwed;

    event GameAdded(address indexed game, uint256 count);
    event Referred(address indexed wallet, address indexed referrer);
    event Rewarded(
        address indexed referrer, address indexed buyer, address indexed game, uint8 level, uint256 amount
    );
    event Funded(address indexed from, uint256 amount);
    event Claimed(address indexed referrer, uint256 amount);

    error NotOwner();
    error NotGame();
    error AlreadyGame();
    error TooManyGames();
    error WrongReferrals();
    error NothingToClaim();
    error SendFailed();

    constructor(address owner_) {
        owner = owner_;
    }

    /// @notice Let a sign game record its Buy & locks here. It must already point at this contract.
    function addGame(address game) external {
        if (msg.sender != owner) revert NotOwner();
        if (isGame[game]) revert AlreadyGame();
        if (games.length >= MAX_GAMES) revert TooManyGames();
        if (ISignGameReferrals(game).referrals() != address(this)) revert WrongReferrals();
        isGame[game] = true;
        games.push(game);
        emit GameAdded(game, games.length);
    }

    /// @notice Called by a sign game for every Buy & lock. On the buyer's first one, `referrer` (the link they came
    /// from, or zero) becomes their referrer for good. Then `reward` is credited 70/30 to their two levels of
    /// referrers. Returns how much was credited, which the game owes this contract from the team's fees.
    function recordBuy(address buyer, address referrer, uint256 reward) external returns (uint256 credited) {
        if (!isGame[msg.sender]) revert NotGame();
        if (!joined[buyer]) {
            joined[buyer] = true;
            // No referring yourself, directly or through the wallet you referred.
            if (referrer != address(0) && referrer != buyer && referrerOf[referrer] != buyer) {
                referrerOf[buyer] = referrer;
                referredCount[referrer] += 1;
                emit Referred(buyer, referrer);
            }
        }

        address level1 = referrerOf[buyer];
        if (level1 == address(0) || reward == 0) return 0;
        uint256 first = (reward * LEVEL1_BPS) / BPS;
        _credit(level1, buyer, 1, first);
        credited = first;

        address level2 = referrerOf[level1];
        if (level2 != address(0)) {
            _credit(level2, buyer, 2, reward - first);
            credited = reward;
        }
    }

    /// @notice Sign games pay in what they owe here as their fees arrive. Anyone else sending ETH adds to it too.
    function fund() external payable {
        emit Funded(msg.sender, msg.value);
    }

    /// @notice Withdraw your referral rewards: everything credited to you that has arrived here so far.
    function claim() external nonReentrant returns (uint256 amount) {
        uint256 due = earned[msg.sender];
        uint256 here = address(this).balance;
        amount = due < here ? due : here;
        if (amount == 0) revert NothingToClaim();
        earned[msg.sender] = due - amount;
        totalOwed -= amount;
        (bool sent,) = msg.sender.call{value: amount}("");
        if (!sent) revert SendFailed();
        emit Claimed(msg.sender, amount);
    }

    // ---------------------------------------------------------------------
    // Views
    // ---------------------------------------------------------------------

    /// @notice What `referrer` could withdraw right now.
    function claimable(address referrer) external view returns (uint256) {
        uint256 due = earned[referrer];
        uint256 here = address(this).balance;
        return due < here ? due : here;
    }

    function gameCount() external view returns (uint256) {
        return games.length;
    }

    function _credit(address referrer, address buyer, uint8 level, uint256 amount) private {
        earned[referrer] += amount;
        totalEarned[referrer] += amount;
        totalOwed += amount;
        emit Rewarded(referrer, buyer, msg.sender, level, amount);
    }

    receive() external payable {
        emit Funded(msg.sender, msg.value);
    }
}

How the design pushes upward

Buying pressure

  1. Every entry is a buy. Buy & lock spends ETH on $LEO at the market price, so each new lead taken that way is fresh demand for the token.
  2. The game only ever buys. The contract can buy $LEO and lock it, and has no way to sell it. Prizes are paid from the ETH pot, never by selling tokens.
  3. Winning the lead back takes another lock. Once someone locks after you, the only way back to the front is to lock again; with Buy & lock, that is another buy.
  4. The entry grows with the pot. A lock must be worth at least 5% of the pot: as trading fills it, each new lead takes a bigger lock.
  5. The rush comes at the end. The closer the clock gets to zero, the more a single lock is worth: half the pot for the last one in. Every late lock puts the clock back to 10:00 and the race starts again.

Less on the market

  1. Locked tokens are off the market. Every lock holds its tokens for 24 hours, so fewer are free to sell while the game runs.
  2. Winners stay locked. A win is paid in ETH; the winner’s tokens stay locked until their 24 hours are up.
  3. The supply is fixed. Each token has 1,000,000,000, set at launch, and nothing in these contracts can mint more.

Bigger prizes, more players

  1. Every trade feeds the prizes, buys and sells alike. Of the 2% each trade pays, about 0.68% goes into the sign’s pot and 0.17% into the Grand pot. More trading in either direction means bigger prizes.
  2. The pot never starts from zero. Half of every pot carries over, so there is always a prize to play for.
  3. Twelve tokens, one Grand game. A lock on any sign worth 5% of the Grand pot also leads the Grand game, so attention on one sign spills over to all of them.
  4. Twelve clocks at once. Every sign runs its own clock, so at any moment one of them may be close to zero.
  5. Referrals pay people to bring buyers. A referrer earns on every Buy & lock by the wallets they brought, on every sign, for good.
  6. It keeps going after graduation. When a token fills its bonding curve it moves to a Uniswap pool, and the game works the same there.

This is how the rules are built, not a promise of profit. Prices can fall as well as rise, pots depend on trading going on, and locked tokens can’t be sold for 24 hours. Only lock what you can afford to lose.

For developers and AI assistants