// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "./interfaces/ISeason1.sol"; /** * @title AereReferralRegistry — AERE Season 1 "Altitude" referrals * @notice Referral codes bound to accounts. A referrer is credited AP ONLY * once their invitee reaches ACTIVATION, defined as ALL of: * * 1. a passkey smart account was created for the invitee * (checked via a Foundation-settable `passkeyFlag` oracle over the * live passkey-account source), AND * 2. the invitee completed First Flight check-in * (AereQuestCheckIn.hasCheckedIn), AND * 3. the invitee has been credited for >= MIN_DISTINCT_QUESTS * distinct on-chain quests (AereQuestAttestor.distinctQuestCount). * * This "activation, not signup" rule is the core anti-farm defense: * a referrer earns nothing for a wallet that just binds a code, only * for one that actually onboards and uses the chain. * * REWARD SHAPE (all Foundation-tunable): * - DIMINISHING RETURNS: per-referral reward halves every `halvingEvery` * activated referrals, floored at `minReward`. * - CAPPED ONGOING SHARE: cumulative referral AP per referrer is capped at * cap(tier); rewards clamp to the remaining headroom. * - PER-ACCOUNT CAP RAISED BY TIER: tier = activatedReferrals / tierSize, * and cap(tier) = tierCapBase + tier * tierCapStep, so a proven referrer * unlocks more headroom over time instead of an unlimited faucet. * * This contract is authorized as an attestor on the ledger by the Foundation * and credits referrers directly via LEDGER.credit(...). It holds no funds. * Owner = Foundation multisig. * * DISCLAIMER: AP is not a token, has no guaranteed value, and confers no * right to returns. Rewards depend on the network growing and are not * promised. There is no listing. */ contract AereReferralRegistry is Ownable, ReentrancyGuard { bytes32 public constant REFERRAL_QUEST_ID = keccak256("season1-referral"); IAerePointsLedger public immutable LEDGER; IAereQuestCheckIn public immutable CHECKIN; IAereQuestAttestor public immutable ATTESTOR; /// @notice Settable "has a passkey account" flag oracle for activation. IAccountFlag public passkeyFlag; /* -------------------------------- config -------------------------------- */ uint256 public minDistinctQuests = 2; uint256 public baseReward = 50; // AP for a referral in the first bracket uint256 public halvingEvery = 10; // halve per-referral reward every N referrals uint256 public minReward = 5; // floor uint256 public tierSize = 10; // referrals per tier step uint256 public tierCapBase = 250; // cap at tier 0 uint256 public tierCapStep = 250; // extra cap per tier /* --------------------------------- state -------------------------------- */ mapping(bytes32 => address) public codeOwner; // code => referrer mapping(address => bytes32) public accountCode; // referrer => code mapping(address => address) public referredBy; // invitee => referrer mapping(address => bool) public referralCredited; // invitee => paid out mapping(address => uint256) public activatedReferrals; // referrer => count mapping(address => uint256) public referralPointsEarned; // referrer => AP /* --------------------------------- events ------------------------------- */ event PasskeyFlagSet(address indexed oracle); event ReferralParamsSet( uint256 minDistinctQuests, uint256 baseReward, uint256 halvingEvery, uint256 minReward, uint256 tierSize, uint256 tierCapBase, uint256 tierCapStep ); event CodeRegistered(address indexed referrer, bytes32 indexed code); event ReferrerBound(address indexed invitee, address indexed referrer, bytes32 indexed code); event ReferralCredited( address indexed referrer, address indexed invitee, uint256 reward, uint256 activatedReferralCount ); /* --------------------------------- errors ------------------------------- */ error ZeroCode(); error CodeTaken(); error AlreadyHasCode(); error UnknownCode(); error SelfReferral(); error AlreadyBound(); error NoReferrer(); error AlreadyCredited(); error NotActivated(); error BadParams(); constructor(address ledger, address checkIn, address attestor) { require(ledger != address(0) && checkIn != address(0) && attestor != address(0), "zero-ref"); LEDGER = IAerePointsLedger(ledger); CHECKIN = IAereQuestCheckIn(checkIn); ATTESTOR = IAereQuestAttestor(attestor); } /* --------------------------------- admin -------------------------------- */ function setPasskeyFlag(address oracle) external onlyOwner { passkeyFlag = IAccountFlag(oracle); emit PasskeyFlagSet(oracle); } function setReferralParams( uint256 _minDistinctQuests, uint256 _baseReward, uint256 _halvingEvery, uint256 _minReward, uint256 _tierSize, uint256 _tierCapBase, uint256 _tierCapStep ) external onlyOwner { if (_halvingEvery == 0 || _tierSize == 0) revert BadParams(); if (_minReward > _baseReward) revert BadParams(); minDistinctQuests = _minDistinctQuests; baseReward = _baseReward; halvingEvery = _halvingEvery; minReward = _minReward; tierSize = _tierSize; tierCapBase = _tierCapBase; tierCapStep = _tierCapStep; emit ReferralParamsSet( _minDistinctQuests, _baseReward, _halvingEvery, _minReward, _tierSize, _tierCapBase, _tierCapStep ); } /* ------------------------------- codes ---------------------------------- */ /// @notice Claim a unique referral code for the caller. One code per account. /// @dev CODES ARE NON-SEMANTIC AND FIRST-COME. The contract attaches no meaning /// to the bytes32: any caller may claim any currently-unused value, and the /// claim is therefore front-runnable. A "branded" string (for example a /// company or handle) is NOT reserved for anyone and can be squatted by the /// first sender to register it. This is acceptable for this version because /// a code carries no monetary value: AP is non-transferable, has no listing, /// and a squatted code only routes activation credit to whoever holds it, it /// cannot take funds or another account's AP. Branded-code reservation is /// intentionally OUT OF SCOPE for this version. If branded codes ever matter, /// a future version should add an owner-gated reservation list or derive the /// code deterministically from msg.sender instead of accepting arbitrary bytes. function registerCode(bytes32 code) external { if (code == bytes32(0)) revert ZeroCode(); if (codeOwner[code] != address(0)) revert CodeTaken(); if (accountCode[msg.sender] != bytes32(0)) revert AlreadyHasCode(); codeOwner[code] = msg.sender; accountCode[msg.sender] = code; emit CodeRegistered(msg.sender, code); } /// @notice Bind the caller (invitee) to the referrer who owns `code`. /// One-shot and immutable; cannot self-refer. function bindReferrer(bytes32 code) external { if (referredBy[msg.sender] != address(0)) revert AlreadyBound(); address referrer = codeOwner[code]; if (referrer == address(0)) revert UnknownCode(); if (referrer == msg.sender) revert SelfReferral(); referredBy[msg.sender] = referrer; emit ReferrerBound(msg.sender, referrer, code); } /* ----------------------------- activation ------------------------------- */ /// @notice True iff `invitee` satisfies all three activation gates. function isActivated(address invitee) public view returns (bool) { if (address(passkeyFlag) == address(0)) return false; if (!passkeyFlag.isFlagged(invitee)) return false; if (!CHECKIN.hasCheckedIn(invitee)) return false; if (ATTESTOR.distinctQuestCount(invitee) < minDistinctQuests) return false; return true; } /// @notice Credit the referrer of `invitee` once the invitee has activated. /// Permissionless: anyone may trigger (referrer or a relayer). function claimReferral(address invitee) external nonReentrant { address referrer = referredBy[invitee]; if (referrer == address(0)) revert NoReferrer(); if (referralCredited[invitee]) revert AlreadyCredited(); if (!isActivated(invitee)) revert NotActivated(); uint256 n = activatedReferrals[referrer]; uint256 reward = _diminishingReward(n); uint256 cap = _capForCount(n); uint256 already = referralPointsEarned[referrer]; uint256 remaining = cap > already ? cap - already : 0; uint256 actual = reward < remaining ? reward : remaining; // Effects first (reentrancy-safe; also guarded). referralCredited[invitee] = true; activatedReferrals[referrer] = n + 1; if (actual > 0) { referralPointsEarned[referrer] = already + actual; } // Interaction: credit the ledger only when there is headroom. if (actual > 0) { LEDGER.credit(referrer, actual, REFERRAL_QUEST_ID); } emit ReferralCredited(referrer, invitee, actual, n + 1); } /* ------------------------------- reward math ---------------------------- */ function _diminishingReward(uint256 n) internal view returns (uint256) { uint256 shifts = n / halvingEvery; if (shifts > 255) shifts = 255; uint256 r = baseReward >> shifts; return r > minReward ? r : minReward; } function _capForCount(uint256 n) internal view returns (uint256) { uint256 tier = n / tierSize; return tierCapBase + tier * tierCapStep; } /* --------------------------------- views -------------------------------- */ function nextReferralReward(address referrer) external view returns (uint256) { uint256 n = activatedReferrals[referrer]; uint256 reward = _diminishingReward(n); uint256 cap = _capForCount(n); uint256 already = referralPointsEarned[referrer]; uint256 remaining = cap > already ? cap - already : 0; return reward < remaining ? reward : remaining; } function referralStats(address referrer) external view returns (uint256 activated, uint256 pointsEarned, uint256 currentCap) { activated = activatedReferrals[referrer]; pointsEarned = referralPointsEarned[referrer]; currentCap = _capForCount(activated); } }