SignGame
Leo’s own game
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);
}
}