// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; interface IArbSys { function arbBlockNumber() external view returns (uint256); function arbBlockHash(uint256 n) external view returns (bytes32); } interface IRoundsToken { function mint(address to, uint256 amount) external; } interface IRoundsMinter { function launchTime() external view returns (uint64); } interface IFundHook { function fund(uint256 toBurn) external payable; } interface IFundStaking { function notifyHype() external payable; } /// @title The Field — 25 plots, one blooms every 60 seconds, forever /// @notice Rounds close on the UTC minute. Plant HYPE on any plots (0.01 HYPE minimum per plot, 5 HYPE per /// wallet per round). The bloom is read from the hash of the first Elysium block after the round closes, /// which nobody knows when they plant. Anyone may close and settle; if nobody settles within 256 blocks /// the round is void and everyone takes back all of their HYPE. /// 10% of new HYPE: 2.5% buy-and-burn, 2.5% deepens the pool, 3% stakers, 2% team. 90% plus any rollover /// goes to the blooming plot. Each settled round also mints $AMBR (1,000, halving every 50,000 rounds): /// 80% to the bloom, 20% into the Nectar Jar, which the bloom empties 1 time in 625. contract AmbrosiaRounds is ReentrancyGuard { IArbSys public constant ARBSYS = IArbSys(address(0x64)); uint256 public constant ROUND = 60; uint256 public constant PLOTS = 25; uint256 public constant MIN_PER_PLOT = 0.01 ether; uint256 public constant MAX_PER_WALLET = 5 ether; uint256 public constant FEE_BPS = 1_000; uint256 public constant EMISSION = 1_000 ether; uint256 public constant HALVING = 50_000; uint256 public constant JAR_ODDS = 625; uint256 public constant WINDOW = 255; IRoundsToken public immutable token; IRoundsMinter public immutable minter; IFundHook public immutable hook; IFundStaking public immutable staking; address public immutable team; address private immutable deployer; address public vault; enum Status { Open, Settled, Void } struct Round { uint128 deposits; uint64 endBlock; uint8 winner; Status status; bool jackpot; uint128 payout; // HYPE shared by the blooming plot uint128 tokens; // $AMBR shared by the blooming plot } mapping(uint256 => Round) public rounds; mapping(uint256 => uint128[25]) internal plotTotals; mapping(uint256 => mapping(address => uint128[25])) internal planted; mapping(uint256 => mapping(address => uint128)) public plantedTotal; mapping(uint256 => mapping(address => bool)) public claimed; uint256[] public active; // rounds with HYPE, in order uint256 public head; // next index in `active` to settle uint256 public settledCount; uint256 public carry; // rolled-over HYPE waiting for a bloom with planters uint256 public jar; // $AMBR in the Nectar Jar uint256 public teamHype; event Planted(uint256 indexed round, address indexed who, uint32 mask, uint256 each, address payer); event Closed(uint256 indexed round, uint256 endBlock); event Bloomed(uint256 indexed round, uint8 plot, uint256 payout, uint256 tokens, bool jackpot, bool anyone); event Voided(uint256 indexed round); event Claimed(uint256 indexed round, address indexed who, uint256 hype, uint256 tokens); error NotStarted(); error BadMask(); error BelowMinimum(); error WrongValue(); error OverWalletCap(uint256 left); error NotVault(); error AlreadyWired(); error NothingToSettle(); error NotClosable(); error TooEarly(); error NothingToClaim(); error SendFailed(); constructor(IRoundsToken token_, IRoundsMinter minter_, IFundHook hook_, IFundStaking staking_, address team_) { token = token_; minter = minter_; hook = hook_; staking = staking_; team = team_; deployer = msg.sender; } function wire(address vault_) external { if (msg.sender != deployer || vault != address(0)) revert AlreadyWired(); vault = vault_; } // ------------------------------------------------------------------ views /// @notice First round opens on the first full UTC minute after launch. function startTime() public view returns (uint256) { uint256 l = minter.launchTime(); return l == 0 ? 0 : (l / ROUND + 1) * ROUND; } function currentRound() public view returns (uint256) { uint256 s = startTime(); if (s == 0 || block.timestamp < s) revert NotStarted(); return (block.timestamp - s) / ROUND; } function roundEnd(uint256 r) public view returns (uint256) { return startTime() + (r + 1) * ROUND; } function emissionAt(uint256 k) public pure returns (uint256) { uint256 h = k / HALVING; return h >= 64 ? 0 : EMISSION >> h; } function plots(uint256 r) external view returns (uint128[25] memory) { return plotTotals[r]; } function plantedBy(uint256 r, address a) external view returns (uint128[25] memory) { return planted[r][a]; } function activeCount() external view returns (uint256) { return active.length; } function pending() external view returns (uint256) { return active.length - head; } function claimable(uint256 r, address a) public view returns (uint256 hype, uint256 tokens) { if (claimed[r][a]) return (0, 0); Round storage R = rounds[r]; if (R.status == Status.Void) return (plantedTotal[r][a], 0); if (R.status != Status.Settled) return (0, 0); uint256 mine = planted[r][a][R.winner]; uint256 all = plotTotals[r][R.winner]; if (mine == 0 || all == 0) return (0, 0); return (uint256(R.payout) * mine / all, uint256(R.tokens) * mine / all); } // ------------------------------------------------------------------ plant /// @param mask Bit i set = plant on plot i (0..24). @param each HYPE per chosen plot. function plant(uint32 mask, uint256 each) external payable nonReentrant { _plant(msg.sender, mask, each); } /// @notice Used by the gardener vault: the HYPE comes from the vault, the plot belongs to `who`. function plantFor(address who, uint32 mask, uint256 each) external payable nonReentrant { if (msg.sender != vault) revert NotVault(); _plant(who, mask, each); } function _plant(address who, uint32 mask, uint256 each) private { if (mask == 0 || mask >= (1 << PLOTS)) revert BadMask(); if (each < MIN_PER_PLOT) revert BelowMinimum(); uint256 n = _popcount(mask); if (msg.value != n * each) revert WrongValue(); uint256 r = currentRound(); uint256 mineTotal = plantedTotal[r][who] + msg.value; if (mineTotal > MAX_PER_WALLET) revert OverWalletCap(MAX_PER_WALLET - plantedTotal[r][who]); Round storage R = rounds[r]; if (R.deposits == 0) { // a new round with HYPE: close the previous one if its minute has passed uint256 len = active.length; if (len != 0) _tryClose(active[len - 1]); active.push(r); } R.deposits += uint128(msg.value); plantedTotal[r][who] = uint128(mineTotal); uint128[25] storage mine = planted[r][who]; uint128[25] storage tot = plotTotals[r]; for (uint256 i; i < PLOTS; ++i) { if (mask & (1 << i) != 0) { mine[i] += uint128(each); tot[i] += uint128(each); } } emit Planted(r, who, mask, each, msg.sender); } // ----------------------------------------------------------------- settle /// @notice Fixes the block whose hash decides the bloom. Anyone, once the round's minute has passed. function close(uint256 r) external { if (!_tryClose(r)) revert NotClosable(); } function _tryClose(uint256 r) private returns (bool) { Round storage R = rounds[r]; if (R.deposits == 0 || R.endBlock != 0 || block.timestamp < roundEnd(r)) return false; R.endBlock = uint64(ARBSYS.arbBlockNumber()); emit Closed(r, R.endBlock); return true; } /// @notice Settles up to `max` rounds in order. Closes the next one if needed (settle it in a later block). function settle(uint256 max) external nonReentrant { uint256 done; bool closedOne; while (done < max && head < active.length) { uint256 r = active[head]; Round storage R = rounds[r]; if (R.endBlock == 0) { closedOne = _tryClose(r); break; } uint256 cur = ARBSYS.arbBlockNumber(); if (cur <= R.endBlock) break; if (cur - R.endBlock > WINDOW) { R.status = Status.Void; emit Voided(r); } else { _bloom(r, R, ARBSYS.arbBlockHash(R.endBlock)); } ++head; ++done; } if (done == 0 && !closedOne) revert NothingToSettle(); } function _bloom(uint256 r, Round storage R, bytes32 h) private { uint8 w = uint8(uint256(keccak256(abi.encode(h, r))) % PLOTS); bool jackpot = uint256(keccak256(abi.encode(h, r, "nectar"))) % JAR_ODDS == 0; uint256 dep = R.deposits; uint256 fee = dep * FEE_BPS / 10_000; uint256 pot = dep - fee + carry; uint256 e = emissionAt(settledCount); ++settledCount; uint256 toBloom = e * 8 / 10; uint256 toJar = e - toBloom; bool anyone = plotTotals[r][w] != 0; R.winner = w; R.status = Status.Settled; if (anyone) { carry = 0; R.payout = uint128(pot); if (jackpot) { toBloom += toJar + jar; jar = 0; } else { jar += toJar; } R.tokens = uint128(toBloom); R.jackpot = jackpot; } else { carry = pot; jar += e; } uint256 quarter = fee / 4; uint256 toStake = fee * 3 / 10; uint256 toTeam = fee - 2 * quarter - toStake; teamHype += toTeam; if (2 * quarter > 0) hook.fund{value: 2 * quarter}(quarter); if (toStake > 0) staking.notifyHype{value: toStake}(); emit Bloomed(r, w, anyone ? pot : 0, anyone ? toBloom : 0, anyone && jackpot, anyone); } // ------------------------------------------------------------------ claim function claim(uint256[] calldata rs) external nonReentrant { uint256 hype; uint256 tokens; for (uint256 i; i < rs.length; ++i) { (uint256 h, uint256 t) = claimable(rs[i], msg.sender); if (h == 0 && t == 0) continue; claimed[rs[i]][msg.sender] = true; hype += h; tokens += t; emit Claimed(rs[i], msg.sender, h, t); } if (hype == 0 && tokens == 0) revert NothingToClaim(); if (tokens > 0) token.mint(msg.sender, tokens); _send(msg.sender, hype); } function withdrawTeam() external nonReentrant { uint256 a = teamHype; teamHype = 0; _send(team, a); } function _send(address to, uint256 amt) private { if (amt == 0) return; (bool ok,) = to.call{value: amt}(""); if (!ok) revert SendFailed(); } function _popcount(uint32 x) private pure returns (uint256 c) { while (x != 0) { x &= x - 1; ++c; } } }