// 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); } }