// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; interface IDeedRounds { function rounds(uint256 r) external view returns (uint128 deposits, uint64 endBlock, uint8 winner, uint8 status, bool jackpot, uint128 payout, uint128 tokens); function roundEnd(uint256 r) external view returns (uint256); } interface IDeedMinter { function launchTime() external view returns (uint64); } interface IDeedStaking { function notifyHype() external payable; } interface IDeedHook { function fund(uint256 toBurn) external payable; } /// @title Ambrosia Plot Deeds /// @notice 25 deeds, one for each plot of the Field. Deed #n owns plot n (1-25). /// /// Rent: every settled round, the deed of the blooming plot earns 0.5% of that round's new HYPE (a quarter /// of the team's 2% round fee). The team sends that HYPE here; rent is paid out of what has been sent, and /// `rentUnfunded()` shows on-chain what the team still owes. /// /// Always for sale (Harberger): every owner sets a price, and anyone can buy the deed at that price at any /// time. The owner pays 5% of their own price per week, from a deposit they keep here, to $AMBR stakers. /// Price it high and it costs more to hold; price it low and someone takes it. When the deposit runs out, /// the deed goes back on sale in a falling-price auction. /// /// First sale: falling-price auctions that start only after the Field is live, at a time the owner sets. /// Proceeds: half deepens the pool and buys and burns $AMBR, half to the team. /// The owner can only schedule that first sale (before it starts), point the deeds at newer Ambrosia /// contracts (before it starts), and hand over ownership. It cannot move anyone's deed or deposit. contract AmbrosiaDeeds is ERC721 { using Strings for uint256; uint256 public constant PLOTS = 25; uint256 public constant RENT_BPS = 50; // 0.5% of a round's new HYPE uint256 public constant TAX_BPS_PER_WEEK = 500; // 5% of the owner's price, per week uint256 public constant MIN_DEPOSIT_DAYS = 3; // a buyer leaves at least 3 days of tax uint256 public constant REAUCTION = 1 days; // a foreclosed deed falls from 2x its last price to the floor in a day uint256 public constant MAX_PRICE = 1_000_000 ether; address public owner; IDeedRounds public rounds; IDeedMinter public minter; IDeedStaking public staking; IDeedHook public hook; address public team; uint64 public saleAt; // first sale starts (0 = not scheduled) uint64 public saleDuration; uint128 public saleStart; // first-sale start price, HYPE uint128 public saleFloor; // floor of every auction, HYPE struct Deed { uint128 price; // owner's price; 0 while unsold uint128 deposit; // tax deposit left at `paidThrough` uint64 paidThrough; // tax settled up to here uint64 since; // current owner since address prev; // previous owner, for rent of rounds that ended in [prevSince, prevUntil) uint64 prevSince; uint64 prevUntil; uint64 auctionAt; // falling-price auction start (for unsold or foreclosed deeds) uint128 auctionStart; } mapping(uint256 => Deed) public deeds; mapping(address => uint256) public rentOf; // credited, not yet claimed mapping(uint256 => bool) public harvested; // round => rent credited uint256 public rentCredited; uint256 public rentFunded; uint256 public rentPaid; uint256 public taxPaid; uint256 public deposits; // total deposits held for owners mapping(address => uint256) public payable_; // sale proceeds and refunds waiting to be withdrawn uint256 public payableTotal; uint256 private _lock = 1; event SaleScheduled(uint64 at, uint64 duration, uint128 start, uint128 floor); event Sources(address rounds, address minter, address staking, address hook, address team); event Bought(uint256 indexed id, address indexed buyer, address indexed seller, uint256 price, uint256 newPrice); event Repriced(uint256 indexed id, uint256 price); event Deposit(uint256 indexed id, int256 change, uint256 deposit); event Foreclosed(uint256 indexed id, address indexed who, uint256 lastPrice); event Rent(uint256 indexed round, uint256 indexed id, address indexed to, uint256 amount); event RentFunded(address indexed from, uint256 amount); event RentClaimed(address indexed who, uint256 amount); event OwnerChanged(address owner); event Withdrawn(address indexed who, uint256 amount); error NotOwner(); error NotOnSale(); error AlreadyStarted(); error FieldNotLive(); error BadPrice(); error TooLittle(uint256 need); error NotDeedOwner(); error Reentrancy(); error TransferFailed(); error NoTransfers(); error BadId(); modifier nonReentrant() { if (_lock != 1) revert Reentrancy(); _lock = 2; _; _lock = 1; } constructor(address owner_, IDeedRounds rounds_, IDeedMinter minter_, IDeedStaking staking_, IDeedHook hook_, address team_) ERC721("Ambrosia Plot Deed", "DEED") { owner = owner_; _sources(rounds_, minter_, staking_, hook_, team_); } // ------------------------------------------------------------------ owner function scheduleSale(uint64 at, uint64 duration, uint128 start, uint128 floor) external { if (msg.sender != owner) revert NotOwner(); if (saleAt != 0 && block.timestamp >= saleAt) revert AlreadyStarted(); if (floor == 0 || start < floor || start > MAX_PRICE || duration < 1 hours || at < block.timestamp) revert BadPrice(); (saleAt, saleDuration, saleStart, saleFloor) = (at, duration, start, floor); emit SaleScheduled(at, duration, start, floor); } function setSources(IDeedRounds r, IDeedMinter m, IDeedStaking s, IDeedHook h, address t) external { if (msg.sender != owner) revert NotOwner(); if (saleAt != 0 && block.timestamp >= saleAt) revert AlreadyStarted(); _sources(r, m, s, h, t); } function setOwner(address o) external { if (msg.sender != owner) revert NotOwner(); owner = o; emit OwnerChanged(o); } function _sources(IDeedRounds r, IDeedMinter m, IDeedStaking s, IDeedHook h, address t) private { (rounds, minter, staking, hook, team) = (r, m, s, h, t); emit Sources(address(r), address(m), address(s), address(h), t); } // ------------------------------------------------------------------ views function fieldLive() public view returns (bool) { return minter.launchTime() != 0; } function holder(uint256 id) public view returns (address) { return _ownerOf(id); } /// @notice Tax owed since `paidThrough`, and whether the deposit covers it. function taxDue(uint256 id) public view returns (uint256 due, bool covered) { Deed storage d = deeds[id]; if (_ownerOf(id) == address(0) || _ownerOf(id) == address(this)) return (0, true); due = uint256(d.price) * TAX_BPS_PER_WEEK * (block.timestamp - d.paidThrough) / 10_000 / 1 weeks; covered = due <= d.deposit; if (!covered) due = d.deposit; } /// @notice Seconds of tax the deposit still covers. function runway(uint256 id) public view returns (uint256) { Deed storage d = deeds[id]; if (d.price == 0) return type(uint256).max; (uint256 due, bool ok) = taxDue(id); if (!ok) return 0; return (uint256(d.deposit) - due) * 10_000 * 1 weeks / (uint256(d.price) * TAX_BPS_PER_WEEK); } /// @notice What it costs to buy deed `id` right now, before the new owner's deposit. function priceOf(uint256 id) public view returns (uint256) { if (id == 0 || id > PLOTS) revert BadId(); address h = _ownerOf(id); if (h != address(0) && h != address(this)) { (, bool ok) = taxDue(id); if (ok) return deeds[id].price; return _reauctionStart(deeds[id].price); // it forecloses on the next touch and starts falling from here } return _auctionPrice(id); } function onSale(uint256 id) public view returns (bool) { if (!fieldLive() || saleAt == 0 || block.timestamp < saleAt) return false; return id >= 1 && id <= PLOTS; } function _auctionPrice(uint256 id) private view returns (uint256) { Deed storage d = deeds[id]; (uint256 at, uint256 start, uint256 span) = d.auctionAt == 0 ? (uint256(saleAt), uint256(saleStart), uint256(saleDuration)) : (uint256(d.auctionAt), uint256(d.auctionStart), REAUCTION); if (block.timestamp <= at) return start; uint256 t = block.timestamp - at; if (t >= span) return saleFloor; return start - (start - saleFloor) * t / span; } function _reauctionStart(uint256 last) private view returns (uint256 s) { s = last * 2; if (s < saleFloor) s = saleFloor; if (s > MAX_PRICE) s = MAX_PRICE; } function minDeposit(uint256 price) public pure returns (uint256) { return price * TAX_BPS_PER_WEEK * MIN_DEPOSIT_DAYS * 1 days / 10_000 / 1 weeks; } function rentUnfunded() external view returns (uint256) { return rentCredited > rentFunded ? rentCredited - rentFunded : 0; } // ------------------------------------------------------------------ tax /// @notice Settle a deed's tax; forecloses it when the deposit has run out. Anyone may call. function poke(uint256 id) public { address h = _ownerOf(id); if (h == address(0) || h == address(this)) return; Deed storage d = deeds[id]; (uint256 due, bool ok) = taxDue(id); if (due > 0) { d.deposit -= uint128(due); deposits -= due; taxPaid += due; staking.notifyHype{value: due}(); } d.paidThrough = uint64(block.timestamp); if (!ok) { uint256 last = d.price; _release(id, d, h); d.auctionStart = uint128(_reauctionStart(last)); emit Foreclosed(id, h, last); } } // ------------------------------------------------------------------ buy /// @notice Buy deed `id` at its current price and set your own price. Send price + deposit; whatever is /// above the price becomes your tax deposit (at least 3 days' worth of tax on your new price). function buy(uint256 id, uint256 maxPrice, uint256 newPrice) external payable nonReentrant { if (!onSale(id)) revert NotOnSale(); if (newPrice == 0 || newPrice > MAX_PRICE) revert BadPrice(); poke(id); uint256 p = priceOf(id); if (p > maxPrice) revert TooLittle(p); uint256 need = p + minDeposit(newPrice); if (msg.value < need) revert TooLittle(need); Deed storage d = deeds[id]; address seller = _ownerOf(id); bool resale = seller != address(0) && seller != address(this); uint256 refund; if (resale) refund = d.deposit; if (resale) { deposits -= refund; (d.prev, d.prevSince, d.prevUntil) = (seller, d.since, uint64(block.timestamp)); } uint256 dep = msg.value - p; d.price = uint128(newPrice); d.deposit = uint128(dep); d.paidThrough = uint64(block.timestamp); d.since = uint64(block.timestamp); deposits += dep; if (seller == address(0)) _mint(msg.sender, id); else _move(id, seller, msg.sender); emit Bought(id, msg.sender, seller, p, newPrice); emit Deposit(id, int256(dep), dep); if (resale) { // credited, not pushed: a seller that cannot receive HYPE must not be able to block the sale payable_[seller] += p + refund; payableTotal += p + refund; } else if (p > 0) { uint256 half = p / 2; hook.fund{value: half}(half / 2); // a quarter buys and burns, a quarter deepens the pool _send(team, p - half); } } function setPrice(uint256 id, uint256 newPrice) external { if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); if (newPrice == 0 || newPrice > MAX_PRICE) revert BadPrice(); poke(id); if (_ownerOf(id) != msg.sender) return; // foreclosed by the poke deeds[id].price = uint128(newPrice); emit Repriced(id, newPrice); } function addDeposit(uint256 id) external payable { if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); poke(id); if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); deeds[id].deposit += uint128(msg.value); deposits += msg.value; emit Deposit(id, int256(msg.value), deeds[id].deposit); } function withdrawDeposit(uint256 id, uint256 amount) external nonReentrant { if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); poke(id); if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); Deed storage d = deeds[id]; if (amount > d.deposit) amount = d.deposit; d.deposit -= uint128(amount); deposits -= amount; emit Deposit(id, -int256(amount), d.deposit); _send(msg.sender, amount); } /// @notice Give the deed up: your deposit comes back and the deed goes back on sale. function abandon(uint256 id) external nonReentrant { if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); poke(id); if (_ownerOf(id) != msg.sender) revert NotDeedOwner(); Deed storage d = deeds[id]; uint256 back = d.deposit; uint256 last = d.price; deposits -= back; _release(id, d, msg.sender); d.auctionStart = uint128(last < saleFloor ? saleFloor : last); emit Foreclosed(id, msg.sender, last); _send(msg.sender, back); } // ------------------------------------------------------------------ rent /// @notice Credit the rent of settled rounds to the deed of their blooming plot. Anyone may call (our keeper /// does). A round is credited once. The owner at the moment the round ended gets it. function harvest(uint256[] calldata rs) external { for (uint256 i; i < rs.length; ++i) { uint256 r = rs[i]; if (harvested[r]) continue; (uint128 dep,, uint8 w, uint8 status,,,) = rounds.rounds(r); if (status != 1) continue; // only settled rounds; open ones are retried later, void ones pay nothing harvested[r] = true; if (dep == 0) continue; uint256 id = uint256(w) + 1; uint256 end = rounds.roundEnd(r); Deed storage d = deeds[id]; address h = _ownerOf(id); address to; if (h != address(0) && h != address(this) && d.since <= end) to = h; else if (d.prev != address(0) && d.prevSince <= end && end < d.prevUntil) to = d.prev; if (to == address(0)) continue; // unsold, or changed hands twice since: nobody's uint256 amt = uint256(dep) * RENT_BPS / 10_000; rentOf[to] += amt; rentCredited += amt; emit Rent(r, id, to, amt); } } /// @notice The team (or anyone) sends rent money here. function fundRent() external payable { rentFunded += msg.value; emit RentFunded(msg.sender, msg.value); } /// @notice Rent money on hand that is not owed back as a deposit. function rentOnHand() public view returns (uint256) { uint256 b = address(this).balance; uint256 held = deposits + payableTotal; return b > held ? b - held : 0; } /// @notice Collect sale proceeds and refunds of deposits from deeds you sold. function withdraw() external nonReentrant returns (uint256 amt) { amt = payable_[msg.sender]; if (amt == 0) return 0; payable_[msg.sender] = 0; payableTotal -= amt; emit Withdrawn(msg.sender, amt); _send(msg.sender, amt); } /// @notice Collect your rent, as far as the team has funded it. The rest stays credited to you. function claimRent() external nonReentrant returns (uint256 amt) { amt = rentOf[msg.sender]; uint256 avail = rentOnHand(); if (amt > avail) amt = avail; if (amt == 0) return 0; rentOf[msg.sender] -= amt; rentPaid += amt; emit RentClaimed(msg.sender, amt); _send(msg.sender, amt); } // ------------------------------------------------------------------ internals /// Deeds only move through buy, foreclosure and abandon: a plain transfer would leave the tax behind. function _update(address to, uint256 id, address auth) internal override returns (address) { if (_lock == 3) return super._update(to, id, auth); if (_ownerOf(id) == address(0) && auth == address(0)) return super._update(to, id, auth); // _mint revert NoTransfers(); } /// The deed goes back to the contract and on sale; the leaving owner keeps rent of rounds that ended while they held it. function _release(uint256 id, Deed storage d, address h) private { (d.prev, d.prevSince, d.prevUntil) = (h, d.since, uint64(block.timestamp)); d.since = uint64(block.timestamp); d.price = 0; d.deposit = 0; d.auctionAt = uint64(block.timestamp); _move(id, h, address(this)); } function _move(uint256 id, address from, address to) private { uint256 l = _lock; _lock = 3; _transfer(from, to, id); _lock = l; } function _send(address to, uint256 v) private { if (v == 0) return; (bool ok,) = to.call{value: v}(""); if (!ok) revert TransferFailed(); } // ------------------------------------------------------------------ art function tokenURI(uint256 id) public view override returns (string memory) { _requireOwned(id); Deed storage d = deeds[id]; bytes memory json = abi.encodePacked( '{"name":"Ambrosia Plot Deed #', id.toString(), '","description":"Deed to plot ', id.toString(), ' of the Ambrosia Field. Earns 0.5% of each round that blooms on its plot. Always for sale at the owner\'s price; the owner pays 5% of that price per week to stakers.","attributes":[{"trait_type":"Plot","value":', id.toString(), '},{"trait_type":"Row","value":', ((id - 1) / 5 + 1).toString(), '},{"trait_type":"Column","value":', ((id - 1) % 5 + 1).toString(), '}],"image":"data:image/svg+xml;base64,', Base64.encode(bytes(svg(id, d.price))), '"}' ); return string(abi.encodePacked("data:application/json;base64,", Base64.encode(json))); } function svg(uint256 id, uint256 price) public pure returns (string memory) { bytes memory grid; for (uint256 i; i < PLOTS; ++i) { bool me = i + 1 == id; uint256 x = 120 + (i % 5) * 34; uint256 y = 112 + (i / 5) * 34; grid = abi.encodePacked( grid, '' : '" width="30" height="30" rx="6" fill="#2b1810" stroke="#8a6a4a" stroke-opacity=".5"/>' ); } return string( abi.encodePacked( '', grid, 'AMBROSIADEEDPlot ', id.toString(), '', price == 0 ? "ON SALE" : "OWNED", "" ) ); } }