// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; interface IMintToken { function mint(address to, uint256 amount) external; } interface IPriceOracle { function fresh() external view returns (uint256); // USD per HYPE, 8 decimals } interface ILaunchHook { function launch(uint256 tokens) external payable; function deepenFromMint() external payable; } /// @title Ambrosia minter /// @notice 10 stages x 1,000 shares. One share = $10 in HYPE at the oracle price. One path, no refunds after launch. /// Genesis (stage 1) holds every HYPE until launch. After 72 hours, 300 shares or more launch with a /// proportionally smaller pool at the same price; fewer than 300 refund everyone in full. /// From stage 2 there are two modes: /// Paid: 20% to the team at once, 80% buys $AMBR and deepens the pool. No refund. Vests per second over 7 days. /// Free: the HYPE comes back in full after 24 hours, with 10% of the paid rate in $AMBR. Budget 50,000,000. /// Quota per wallet per UTC day, for each mode: 10 in stages 1-3, 7 in stages 4-6, 5 in stages 7-10, /// plus 1 for every wallet you refer, with no cap. Referrals need a voucher from the site's signer, /// which checks networks; the signer can only sign vouchers. No owner. contract AmbrosiaMinter is ReentrancyGuard, EIP712 { uint256 public constant SHARE_USD = 10; uint256 public constant STAGE_SHARES = 1_000; uint256 public constant STAGES = 10; uint256 public constant MAX_SHARES = STAGE_SHARES * STAGES; uint256 public constant GENESIS_MIN = 300; uint256 public constant GENESIS_WINDOW = 72 hours; bytes32 private constant VOUCHER_TYPEHASH = keccak256("RefVoucher(address minter,address referrer,uint256 deadline)"); uint256 public constant LP_TOKENS = 130_000_000 ether; // for a full Genesis; scaled by shares/1,000 uint256 public constant TEAM_BPS = 2_000; uint256 public constant BPS = 10_000; uint256 public constant VEST = 7 days; uint256 public constant FREE_BUDGET = 50_000_000 ether; uint256 public constant FREE_RATE_BPS = 1_000; // 10% of the paid rate of the current stage uint256 public constant FREE_DELAY = 24 hours; uint256 public constant MAX_BATCH = 60; IMintToken public immutable token; IPriceOracle public immutable oracle; ILaunchHook public immutable hook; address public immutable team; address public immutable refSigner; uint64 public immutable openAt; uint64 public immutable genesisDeadline; bool public launched; uint64 public launchTime; uint256 public totalShares; uint256 public genesisRaised; uint256 public teamHype; struct User { uint32 day; uint32 used; uint32 curVest; uint32 freeDay; uint32 freeUsed; uint32 curFree; uint32 refCount; bool seen; address referrer; } struct Voucher { uint256 deadline; uint8 v; bytes32 r; bytes32 s; } struct FreePos { uint64 time; uint32 shares; bool claimed; uint128 hype; uint128 tokens; } struct VestPos { uint64 start; uint128 amount; uint128 claimed; } mapping(address => User) public users; mapping(address => uint256) public sharesOf; mapping(address => uint256) public genesisPaid; mapping(address => uint256) public vestingRemaining; mapping(address => VestPos[]) internal vest; mapping(address => FreePos[]) internal freePos; uint256 public freeMinted; uint256 public freeOwed; address[] public genesisBuyers; event Minted(address indexed who, uint256 shares, uint256 tokens, uint256 paid, uint256 firstShare); event Launched(uint256 at, uint256 shares, uint256 lpHype, uint256 lpTokens); event GenesisRefund(address indexed who, uint256 hype); event RefundFailed(address indexed who, uint256 hype); event Vested(address indexed who, uint256 tokens); event FreeMinted(address indexed who, uint256 indexed pos, uint256 shares, uint256 hype, uint256 tokens); event FreeClaimed(address indexed who, uint256 hype, uint256 tokens); event Referred(address indexed referrer, address indexed who); error NotOpen(); error ZeroShares(); error SoldOut(uint256 left); error GenesisClosed(); error QuotaExceeded(uint256 left); error PriceAboveMax(uint256 perShare); error Underpaid(uint256 need); error CannotLaunch(); error AlreadyLaunched(); error NotRefundable(); error SendFailed(); error FreeClosed(); error BadVoucher(); error VoucherExpired(); constructor(IMintToken token_, IPriceOracle oracle_, ILaunchHook hook_, address team_, address refSigner_, uint64 openAt_) EIP712("Ambrosia", "1") { refSigner = refSigner_; token = token_; oracle = oracle_; hook = hook_; team = team_; openAt = openAt_; genesisDeadline = openAt_ + uint64(GENESIS_WINDOW); } // ------------------------------------------------------------------ views /// @notice $AMBR per share by stage (stage 0 = Genesis). Sum x 1,000 = 664,388,000. Each stage starts about /// 5% under the pool price that minting alone leaves behind; Genesis starts about 9% under launch. function stageRate(uint256 s) public pure returns (uint256) { if (s == 0) return 178_440 ether; if (s == 1) return 171_797 ether; if (s == 2) return 85_887 ether; if (s == 3) return 57_256 ether; if (s == 4) return 42_941 ether; if (s == 5) return 34_352 ether; if (s == 6) return 28_627 ether; if (s == 7) return 24_536 ether; if (s == 8) return 21_469 ether; return 19_083 ether; } function currentStage() public view returns (uint256) { uint256 s = totalShares / STAGE_SHARES; return s >= STAGES ? STAGES - 1 : s; } /// @notice HYPE wei for one $10 share at the oracle price. Reverts if the oracle is stale. function sharePriceHype() public view returns (uint256) { return SHARE_USD * 1e18 * 1e8 / oracle.fresh(); } function quoteTokens(uint256 shares) public view returns (uint256 tokens) { uint256 idx = totalShares; uint256 left = shares; while (left != 0) { uint256 s = idx / STAGE_SHARES; if (s >= STAGES) break; uint256 room = (s + 1) * STAGE_SHARES - idx; uint256 n = left < room ? left : room; tokens += n * stageRate(s); idx += n; left -= n; } } /// @notice Daily shares per mode before referrals: 10 in stages 1-3, 7 in stages 4-6, 5 in stages 7-10. function dailyBase() public view returns (uint256) { uint256 s = currentStage(); return s < 3 ? 10 : s < 6 ? 7 : 5; } function dailyCap(address a) public view returns (uint256) { return dailyBase() + users[a].refCount; } function quotaLeft(address a) public view returns (uint256) { User storage u = users[a]; uint256 used = u.day == block.timestamp / 1 days ? u.used : 0; uint256 cap = dailyCap(a); return cap > used ? cap - used : 0; } function freeQuotaLeft(address a) public view returns (uint256) { User storage u = users[a]; uint256 used = u.freeDay == block.timestamp / 1 days ? u.freeUsed : 0; uint256 cap = dailyCap(a); return cap > used ? cap - used : 0; } function quoteFree(uint256 shares) public view returns (uint256) { return shares * stageRate(currentStage()) * FREE_RATE_BPS / BPS; } function genesisOpen() public view returns (bool) { return !launched && block.timestamp >= openAt && block.timestamp < genesisDeadline && totalShares < STAGE_SHARES; } function canLaunch() public view returns (bool) { if (launched) return false; if (totalShares >= STAGE_SHARES) return true; return block.timestamp >= genesisDeadline && totalShares >= GENESIS_MIN; } function refundable() public view returns (bool) { return !launched && block.timestamp >= genesisDeadline && totalShares < GENESIS_MIN; } function genesisBuyerCount() external view returns (uint256) { return genesisBuyers.length; } // ------------------------------------------------------------------- mint /// @param maxPerShare Highest HYPE per share the buyer accepts. Any extra HYPE sent comes back. function mint(uint256 shares, uint256 maxPerShare, address referrer, Voucher calldata voucher) external payable nonReentrant { if (block.timestamp < openAt) revert NotOpen(); if (shares == 0) revert ZeroShares(); uint256 idx = totalShares; if (!launched) { if (block.timestamp >= genesisDeadline) revert GenesisClosed(); if (idx + shares > STAGE_SHARES) revert SoldOut(STAGE_SHARES - idx); } else if (idx + shares > MAX_SHARES) { revert SoldOut(MAX_SHARES - idx); } User storage u = _seen(referrer, voucher); uint32 today = uint32(block.timestamp / 1 days); uint256 used = u.day == today ? u.used : 0; uint256 cap = dailyBase() + u.refCount; if (used + shares > cap) revert QuotaExceeded(cap > used ? cap - used : 0); u.day = today; u.used = uint32(used + shares); uint256 per = sharePriceHype(); if (per > maxPerShare) revert PriceAboveMax(per); uint256 cost = per * shares; if (msg.value < cost) revert Underpaid(cost); uint256 tokens = quoteTokens(shares); totalShares = idx + shares; sharesOf[msg.sender] += shares; vestingRemaining[msg.sender] += tokens; _addVest(msg.sender, tokens); if (launched) { uint256 toTeam = cost * TEAM_BPS / BPS; teamHype += toTeam; hook.deepenFromMint{value: cost - toTeam}(); } else { if (genesisPaid[msg.sender] == 0) genesisBuyers.push(msg.sender); genesisPaid[msg.sender] += cost; genesisRaised += cost; } if (msg.value > cost) _send(msg.sender, msg.value - cost); emit Minted(msg.sender, shares, tokens, cost, idx + 1); } /// @notice Free mode, from stage 2: lock HYPE for 24 hours, get it all back plus 10% of the paid rate in $AMBR. /// Does not count toward the 10,000 paid shares and pays nothing to the team. function mintFree(uint256 shares, uint256 maxPerShare, address referrer, Voucher calldata voucher) external payable nonReentrant { if (!launched) revert NotOpen(); if (shares == 0) revert ZeroShares(); uint256 tokens = quoteFree(shares); if (freeMinted + tokens > FREE_BUDGET) revert FreeClosed(); User storage u = _seen(referrer, voucher); uint32 today = uint32(block.timestamp / 1 days); uint256 used = u.freeDay == today ? u.freeUsed : 0; uint256 cap = dailyBase() + u.refCount; if (used + shares > cap) revert QuotaExceeded(cap > used ? cap - used : 0); u.freeDay = today; u.freeUsed = uint32(used + shares); uint256 per = sharePriceHype(); if (per > maxPerShare) revert PriceAboveMax(per); uint256 cost = per * shares; if (msg.value < cost) revert Underpaid(cost); freeMinted += tokens; freeOwed += cost; uint256 id = freePos[msg.sender].length; freePos[msg.sender].push( FreePos({time: uint64(block.timestamp), shares: uint32(shares), claimed: false, hype: uint128(cost), tokens: uint128(tokens)}) ); if (msg.value > cost) _send(msg.sender, msg.value - cost); emit FreeMinted(msg.sender, id, shares, cost, tokens); } /// @notice Returns the HYPE and mints the $AMBR of every free position older than 24 hours. function claimFree() external nonReentrant { User storage u = users[msg.sender]; FreePos[] storage list = freePos[msg.sender]; uint256 i = u.curFree; uint256 end = i + MAX_BATCH; if (end > list.length) end = list.length; uint256 hype; uint256 tokens; for (; i < end; ++i) { FreePos storage p = list[i]; if (block.timestamp < p.time + FREE_DELAY) break; p.claimed = true; hype += p.hype; tokens += p.tokens; } u.curFree = uint32(i); if (hype == 0) return; freeOwed -= hype; token.mint(msg.sender, tokens); _send(msg.sender, hype); emit FreeClaimed(msg.sender, hype, tokens); } function claimableFree(address a) external view returns (uint256 hype, uint256 tokens) { FreePos[] storage list = freePos[a]; uint256 end = users[a].curFree + MAX_BATCH; if (end > list.length) end = list.length; for (uint256 i = users[a].curFree; i < end; ++i) { if (block.timestamp < list[i].time + FREE_DELAY) break; hype += list[i].hype; tokens += list[i].tokens; } } function freePositions(address a) external view returns (FreePos[] memory) { return freePos[a]; } /// @notice Anyone may call once Genesis is full, or after 72 hours with at least 300 shares. function launch() external nonReentrant { if (!canLaunch()) revert CannotLaunch(); launched = true; launchTime = uint64(block.timestamp); uint256 shares = totalShares; uint256 raised = genesisRaised; uint256 toTeam = raised * TEAM_BPS / BPS; teamHype += toTeam; uint256 lpTokens = LP_TOKENS * shares / STAGE_SHARES; token.mint(address(hook), lpTokens); hook.launch{value: raised - toTeam}(lpTokens); emit Launched(block.timestamp, shares, raised - toTeam, lpTokens); } // ----------------------------------------------------------------- refund function refundGenesis() external nonReentrant { if (!refundable() || genesisPaid[msg.sender] == 0) revert NotRefundable(); if (!_refund(msg.sender)) revert SendFailed(); } /// @notice The keeper pushes refunds to every Genesis buyer. Anyone may call. function refundFor(address[] calldata who) external nonReentrant { if (!refundable()) revert NotRefundable(); for (uint256 i; i < who.length; ++i) { if (genesisPaid[who[i]] != 0) _refund(who[i]); } } function _refund(address a) private returns (bool) { uint256 amt = genesisPaid[a]; genesisPaid[a] = 0; (bool ok,) = a.call{value: amt, gas: 50_000}(""); if (!ok) { genesisPaid[a] = amt; emit RefundFailed(a, amt); return false; } sharesOf[a] = 0; vestingRemaining[a] = 0; delete vest[a]; emit GenesisRefund(a, amt); return true; } // ------------------------------------------------------------------ claim function claimVested() external nonReentrant { if (!launched) revert NotOpen(); User storage u = users[msg.sender]; VestPos[] storage list = vest[msg.sender]; uint256 i = u.curVest; uint256 end = i + MAX_BATCH; if (end > list.length) end = list.length; uint256 sum; bool prefix = true; uint256 cur = i; for (; i < end; ++i) { VestPos storage p = list[i]; uint256 amt = _vested(p) - p.claimed; if (amt != 0) { p.claimed += uint128(amt); sum += amt; } if (prefix) { if (p.claimed == p.amount) cur = i + 1; else prefix = false; } } u.curVest = uint32(cur); if (sum == 0) return; vestingRemaining[msg.sender] -= sum; token.mint(msg.sender, sum); emit Vested(msg.sender, sum); } function claimableVested(address a) external view returns (uint256 sum) { if (!launched) return 0; VestPos[] storage list = vest[a]; uint256 end = users[a].curVest + MAX_BATCH; if (end > list.length) end = list.length; for (uint256 i = users[a].curVest; i < end; ++i) { sum += _vested(list[i]) - list[i].claimed; } } function vestPositions(address a) external view returns (VestPos[] memory) { return vest[a]; } function withdrawTeam() external nonReentrant { uint256 amt = teamHype; teamHype = 0; _send(team, amt); } // -------------------------------------------------------------- internals function _vested(VestPos storage p) private view returns (uint256) { uint256 start = p.start < launchTime ? launchTime : p.start; if (block.timestamp <= start) return 0; uint256 elapsed = block.timestamp - start; if (elapsed >= VEST) return p.amount; return uint256(p.amount) * elapsed / VEST; } function _addVest(address a, uint256 tokens) private { VestPos[] storage list = vest[a]; uint256 n = list.length; uint256 today = block.timestamp / 1 days; if (n != 0 && list[n - 1].start / 1 days == today) { list[n - 1].amount += uint128(tokens); } else { list.push(VestPos({start: uint64(block.timestamp), amount: uint128(tokens), claimed: 0})); } } /// @dev A referrer binds once, on the wallet's first mint, and must already hold paid shares. /// A wrong voucher reverts so the wallet never loses that one chance. function _seen(address referrer, Voucher calldata voucher) private returns (User storage u) { u = users[msg.sender]; if (u.seen) return u; u.seen = true; if (referrer == address(0)) return u; if (block.timestamp > voucher.deadline) revert VoucherExpired(); bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(VOUCHER_TYPEHASH, msg.sender, referrer, voucher.deadline))); if (ECDSA.recover(digest, voucher.v, voucher.r, voucher.s) != refSigner) revert BadVoucher(); if (referrer != msg.sender && sharesOf[referrer] != 0) { users[referrer].refCount += 1; u.referrer = referrer; emit Referred(referrer, msg.sender); } } function _send(address to, uint256 amt) private { if (amt == 0) return; (bool ok,) = to.call{value: amt}(""); if (!ok) revert SendFailed(); } }