aere-contracts/contracts/season1/AereReferralRegistry.sol
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

254 lines
11 KiB
Solidity

// 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 account (single-key EOA today, not a 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);
}
}