// 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(
'"
)
);
}
}