// SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.24; import {Owned} from "./Owned.sol"; /** * Per-domain review prices, set by the DAO and read on chain. * * Owner decision, 2026-09-17: the price of a review is a DAO parameter rather * than a platform constant. That makes it governed rather than announced, and * puts it on the same footing as the release approvals the DAO already controls. * It is also the second argument for the measured reviewer reading chain state * directly — the price has to be read from somewhere, and a reviewer that * already reads the chain needs no second path to do it. * * ## A quote is pinned, not looked up twice * * The hazard with a governed price is not the price; it is the window. Work is * accepted at one price and settled later, and a governance change in between * would reprice work already underway. So a caller `quote()`s a domain, receives * a price and an expiry, and settles against that quote. A change after the * quote does not reach it. * * ## Unset is not free * * A domain with no price reverts rather than returning zero. Zero is a real * price — it means free — and a registry that returns it for "nobody has decided * yet" would give away every review in a domain the DAO has not priced. Refusing * to quote is the honest answer, and it is what the plan asked for. * * ## Changes are timelocked * * A price change takes effect after a delay the owner cannot shorten, so an * integrator can see a change coming. Without it, governance could reprice * between a developer reading a price and acting on it, which is the same * window in a different place. */ contract DomainPriceRegistry is Owned { /// How long a quote stays good once issued. uint64 public immutable quoteValiditySeconds; /// How long a price change waits before it can take effect. uint64 public immutable changeDelaySeconds; struct Price { uint128 usdCents; bool set; } struct PendingPrice { uint128 usdCents; uint64 effectiveAt; bool pending; } /// keccak256(domain id) => current price. mapping(bytes32 => Price) private prices; /// keccak256(domain id) => queued change. mapping(bytes32 => PendingPrice) private pending; error PriceNotSet(bytes32 domain); error ChangeNotReady(bytes32 domain, uint64 effectiveAt); error NoPendingChange(bytes32 domain); event PriceChangeQueued(bytes32 indexed domain, uint128 usdCents, uint64 effectiveAt); event PriceChanged(bytes32 indexed domain, uint128 previousUsdCents, uint128 usdCents); constructor(address initialOwner, uint64 quoteValidity, uint64 changeDelay) Owned(initialOwner) { require(quoteValidity > 0 && changeDelay > 0); quoteValiditySeconds = quoteValidity; changeDelaySeconds = changeDelay; } /// Queues a change. It cannot take effect before the delay elapses. function queuePriceChange(bytes32 domain, uint128 usdCents) external onlyOwner { uint64 effectiveAt = uint64(block.timestamp) + changeDelaySeconds; pending[domain] = PendingPrice({usdCents: usdCents, effectiveAt: effectiveAt, pending: true}); emit PriceChangeQueued(domain, usdCents, effectiveAt); } /** * Applies a queued change once its delay has elapsed. * * Deliberately callable by anyone: the delay is the control, not the caller. * Requiring the owner to return would let a queued change sit unapplied and * make the effective price depend on who was paying attention. */ function applyPriceChange(bytes32 domain) external { PendingPrice memory queued = pending[domain]; if (!queued.pending) revert NoPendingChange(domain); if (block.timestamp < queued.effectiveAt) { revert ChangeNotReady(domain, queued.effectiveAt); } uint128 previous = prices[domain].usdCents; prices[domain] = Price({usdCents: queued.usdCents, set: true}); delete pending[domain]; emit PriceChanged(domain, previous, queued.usdCents); } /// The current price, or a revert. Never zero-for-unset. function priceOf(bytes32 domain) public view returns (uint128) { Price memory current = prices[domain]; if (!current.set) revert PriceNotSet(domain); return current.usdCents; } function isPriced(bytes32 domain) external view returns (bool) { return prices[domain].set; } /** * Issues a quote a caller settles against. * * `view`, so it costs nothing and cannot be front-run into a different * price: the caller reads price and expiry together and carries both. A * governance change after this call reaches the registry, not the quote. */ function quote(bytes32 domain) external view returns (uint128 usdCents, uint64 expiresAt) { usdCents = priceOf(domain); expiresAt = uint64(block.timestamp) + quoteValiditySeconds; } function pendingChange(bytes32 domain) external view returns (bool isPending, uint128 usdCents, uint64 effectiveAt) { PendingPrice memory queued = pending[domain]; return (queued.pending, queued.usdCents, queued.effectiveAt); } }