diff --git a/.github/abi-contracts.txt b/.github/abi-contracts.txt index 3c184f03d..dc1201c15 100644 --- a/.github/abi-contracts.txt +++ b/.github/abi-contracts.txt @@ -28,6 +28,8 @@ DotnsPopResolver DotnsRoleManager RootGatewayDispatcher DotnsNameWhitelist +DotnsCostModelRegistry +DotnsFlatPricing IStoreFactory ILabelStore @@ -46,3 +48,5 @@ IDotnsPopResolver IDotnsController IDotnsRoleManager IDotnsNameWhitelist +IDotnsCostModelRegistry +IDotnsPricing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e7227293..6d9051183 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,13 +56,19 @@ Before opening a pull request: - Interfaces should describe public/external functions with NatSpec. - Keep implementations aligned with the interface surface area. Avoid unused methods. -3. Tests +3. Comments + - Comments explain behaviour. Decorative separator comments are not allowed: a comment whose + content is a run of rule characters, such as a line of dashes, equals, hashes, or asterisks + under or around a heading. Group code with functions and NatSpec, not with drawn rules. The + pre-commit check rejects them. + +4. Tests - Add unit tests for behaviour changes. - Use fuzz tests where they add meaningful coverage. - Prefer readable, behaviour-oriented test names and assertions. - Keep tests deterministic unless explicitly fuzzing. -4. Commit hygiene +5. Commit hygiene - Keep commits logically grouped. - Squash when appropriate to keep history clean (maintainers may squash on merge). diff --git a/README.md b/README.md index c68a9e0a8..6f8f3e7dc 100644 --- a/README.md +++ b/README.md @@ -32,52 +32,65 @@ If a run fails partway, re-run it from the Actions tab; the draft is updated rat ## Economics -dotNS uses a single tunable constant, written **D** throughout the protocol. D is the starting price used by PopRules and equals ten DOT at launch; governance can adjust it under the same gate as the upgrade authority. D is the only money quantity the protocol charges; everything else is a composition of D with zero. +Every name admitted to public sale costs the same refundable deposit: 10 DOT at launch. The amount is not fixed in the registration path. It comes from a cost model resolved through the protocol registry under the `costModel` key, and governance can replace that model without touching the registrar or its storage. The launch model, `DotnsFlatPricing`, returns one deposit for every length. A length-sensitive scarcity curve, `DotnsScarcityPricing`, ships alongside it as a candidate for a later version but is not the registered default. -D plays two distinct roles. As a **deposit** it is the refundable lock a NoStatus user posts to register a NoStatus-tier label; the deposit is bound to the name, not to the depositor, so it travels with the NFT on every transfer and only unlocks when the current holder releases the name back to escrow. Transferring a funded name forfeits the deposit to the new holder, who inherits the locked D and the right to release later. As a **friction** charge it is the non-refundable amount a sender pays on a cross-tier downward or reach-floor transfer. The two flows are economically distinct: the deposit gates a count of names (one D per NoStatus name in existence), the friction gates the rate of tier laundering. +### Base length and the digit rule -### Registration matrix +Pricing and eligibility read a name's base length: the character count once a trailing number is set aside. A name carries no trailing digits or exactly two; one digit, or three or more, is rejected before pricing. The trailing digits come off before the length is measured, so `andrew` and `andrew01` both classify as a six-character name. Base length decides which band a name falls in and who may register it; under the flat model it does not change the amount. -The public controller computes the registration charge as the greater of the owner-side price and the payer-to-owner downward friction; it does not add the two together. The single charge becomes a refundable deposit on a direct NoStatus registration and becomes non-refundable reserve funding on a cross-payer registration. +### What a name costs -| Owner tier | Reserved (stem ≤5) | PopFull-tier (stem 6-8, no digits) | PopLite-tier (stem 6-8, two digits) | NoStatus-tier (stem ≥9) | -|---|---|---|---|---| -| **NoStatus user** | rejected | rejected | rejected | direct: pays D into deposit | -| **PopLite user** | rejected | rejected | gateway-only; free | free | -| **PopFull user** | rejected | free | gateway-only; free | free | -| **Whitelisted address** | free | free | free | free | - -Cross-payer registrations pay the greater of the owner-side price and the transfer-floor amount into the reserve. Reserved labels remain forbidden on the cross-payer path because the owner-side gate still rejects them. Whitelist registrations go through the same commit-reveal pipeline as the public path. +Only names of nine characters or more are on public sale by default. The short-name switch, off at launch, keeps base lengths below nine off the public paid path. Every name on sale costs the flat deposit: -### Transfer matrix +| Base length | Price | On public sale by default | +|---|---|---| +| 6 to 8 | 10 DOT | No, held behind the short-name switch | +| 9 or more | 10 DOT | Yes | +| 5 or fewer | not sold | issued at zero base cost through the reserved path | -The registrar consults PopRules for the transfer floor. A transfer pays D whenever the recipient's tier is strictly below the sender's, or whenever the recipient cannot reach the label's required tier. A stale PopFull-tier name landing with a PopLite holder, for example, can still owe friction even when the holder-to-holder move otherwise looks same-tier. Same-tier and upward transfers between holders of the label's own class are free of friction. +### Who can register a name -The deposit, when present, is bound to the name and rides with it on every transfer. The escrow position is rebound to the new holder rather than refunded; only releasing the name back to escrow ever unlocks the locked D. Transferring a funded name is therefore a real forfeiture: the sender hands the locked deposit over to the recipient along with the NFT. +Three bands share the one deposit. -| Sender → Recipient | Friction (to insurance) | Deposit movement | +| Base length | Who may register on the public paid path | Price | |---|---|---| -| NoStatus → NoStatus (same tier) | 0 | Travels with the name; position rebinds to recipient | -| NoStatus → PopLite or PopFull | 0 | Travels with the name; position rebinds to recipient | -| PopLite → NoStatus | D | Any inherited deposit travels with the name | -| PopLite → PopLite (same) | 0 | Any inherited deposit stays bound to the name | -| PopLite → PopFull (upward) | 0 | Any inherited deposit stays bound to the name | -| PopFull → NoStatus | D | Any inherited deposit travels with the name | -| PopFull → PopLite (downward) | D | Any inherited deposit stays bound to the name | -| PopFull → PopFull (same) | 0 | Any inherited deposit stays bound to the name | +| 9 or more | anyone, as NoStatus | 10 DOT | +| 6 to 8 | a verified person, and only while the short-name switch is on | 10 DOT | +| 5 or fewer | nobody on the public path | not sold; issued at zero base cost through the reserved path | + +Personhood unlocks only the six-to-eight band. A no-digit name there needs full-person verification; a two-digit name needs lite-person verification. It gates who may buy, not the price: the deposit is the same one everyone pays. -The friction is constant and additive across downward hops. Every step that crosses a tier boundary downward charges D independently, so routing a name through intermediary tiers never costs less than the equivalent direct transfer. Laundering pays at least as much as the route it tries to avoid. Because the deposit follows the NFT, a NoStatus user cannot recover their D by handing the name to a fresh address and registering again; the only path back to D is releasing the current name into escrow. This binds Sybil cost to one D per live NoStatus name in existence, independent of how often names change hands. +Names shorter than nine characters are closed on the public paid path by default. A paid registration below nine reverts until governance opens the short-name market with a single switch. The switch gates the public paid path alone: the personhood gateway issues names of any length without it, and the reserved path does not consult it. It defaults off, so at launch only names of nine characters or more are for sale. -### Refund and cooldown model +Names of five characters or fewer are never sold on the public path, which rejects a reserved-tier label outright. Such a name enters circulation only through the reserved path, which the contract owner or a whitelisted operator calls to mint an available label at zero base cost with no deposit and no personhood check. There is no treasury: no value moves when a reserved name is issued. + +### Worked examples + +The path decides whether the amount is a refundable deposit, a non-refundable fee, or nothing at all. Every amount below is the flat launch deposit of 10 DOT. + +| Name | Base length | Path | Amount | Held as | +|---|---|---|---|---| +| `gavinwood` | 9 | public, own key | 10 DOT | refundable deposit | +| `gavinwood` | 9 | public, someone else pays | 10 DOT | protocol fee | +| `andrewsays` | 10 | public, own key | 10 DOT | refundable deposit | +| `andrew` | 6 | public, own key (switch on, full person) | 10 DOT | refundable deposit | +| `alicebob42` | 8 | public, own key (switch on, lite person) | 10 DOT | refundable deposit | +| any six-to-eight name | 6 to 8 | personhood gateway grant | none | no deposit, no fee | +| `andrew`, moved to a wallet that cannot clear its band | 6 | transfer | 10 DOT | protocol fee | -The escrow maintains two separate pull-payment ledgers. The split is deliberate: one ledger is for immediate overpayment withdrawals, and the other is for refunds that must wait behind a cooldown. +### Deposits and protocol fees -- **Overpayment ledger.** No cooldown. Used only as the fallback when a direct registration overpayment cannot be returned to the sender inline. -- **Refund ledger.** Every refund has its own cooldown clock. Used for transfer-fee overpayments. Transfers never credit the refund ledger because the position rides with the name. +Registering a name under your own key locks a refundable deposit equal to the name's price. The deposit is bound to the name rather than to you, so it travels with the token on every transfer and unlocks only when the current holder releases the name back to escrow. -Only registrations try to return surplus immediately. Every other refund path waits behind a clock. The deposit unlocked by releasing a funded name is credited to the overpayment ledger rather than the refund ledger: the delay comes from the position's own `withdrawAvailableAt` stamp, so once `withdraw` lands the credit is immediately pullable. The cooldown is bounded to minutes and governance can tune it within that band. +A name someone else pays for, and a transfer, pay a non-refundable fee instead. When a third party pays for another wallet's registration, the charge is the same owner-side price, with no separate friction added; it routes to a single protocol fee pot, and the owner's escrow slot is seeded with a zero amount so the release lifecycle stays reachable. A gateway grant carries neither a deposit nor a fee. The protocol fee pot only ever grows: it backs no refunds, and nothing burns, sweeps, or withdraws from it. Refunds draw solely on the separate per-asset reserve that deposits fund. A holder's own deposit is their money held in trust and is never moved into fees. -Clients can enumerate pending refunds through the escrow's public refund views. Pagination is capped so refund discovery remains bounded. +### Transfers re-price at the name's own length + +A transfer charges the name's own price, but only in two cases: the recipient cannot clear the name's band, or the move is a personhood downgrade, where the recipient's tier is lower than the sender's. Passing a six-character name to a wallet that could never have registered it costs the name's own price, so there is no cheap way to hand a band-gated name to a party who could not have earned it. A move between two wallets that both clear the band, and a move to the same address, cost nothing. The fee, when one is owed, settles into the protocol fee pot. The deposit, when present, rides with the name: the escrow position rebinds to the new holder rather than refunding, and only releasing the name back to escrow unlocks the locked deposit. + +### Versioned pricing + +The cost model is chosen by governance and swapped, not upgraded. Registering a new model adds it under a fresh version and points the current version at it; earlier versions stay priceable, so a registration already committed against an earlier version settles at the amount it committed to. A commitment binds the version current when it is made, and the reveal reverts if it is presented at a different version, so a model change between commit and reveal cannot move the amount. Governance can also point the current version back at an earlier registered model. ### Release lifecycle @@ -85,21 +98,29 @@ Releasing a name starts two independent clocks, and the distinction between them | Clock | Length | What it gates | |---|---|---| -| `withdrawAvailableAt` | release + `cooldown` (15 minutes at launch, ≤ 1 hour) | When the holder may credit the deposit to themselves via `withdraw` | -| `redeemableUntil` | release + `redeemWindow` (1 day at launch, governance may set 1 to 30 days) | When the holder's exclusive claim on the name ends and `reclaim` opens to anyone | +| `withdrawAvailableAt` | release + `cooldown` (15 minutes at launch, at most 1 hour) | When the holder may credit the deposit to themselves through `withdraw` | +| `redeemableUntil` | release + `redeemWindow` (1 day at launch, governance may set 1 to 30 days) | When the holder's exclusive claim ends and `reclaim` opens to anyone | + +Both are stamped at release time, so a governance change never moves the clocks on a name already released. Inside the redeem window the name belongs to its previous holder, and `DotnsRegistrar.available` reports false so no client advertises it as free. The holder has two mutually exclusive options: + +- `redeem` returns the token and moves no value. The position keeps its recipient, asset and amount, so the deposit stays locked and the name lands back in its exact pre-release state, releasable again later on a fresh pair of clocks. This is the undo for an accidental release. +- `withdraw` credits the deposit and forfeits the right to redeem. A holder paid for the name cannot also take it back, otherwise they would hold a name no deposit backs. + +Once `redeemableUntil` passes, `reclaim` is permissionless through the ordinary commit-reveal path, whether or not the previous holder ever withdrew. If the position still holds value, reclaim settles it: the amount is credited to the previous holder's pull-payment balance and stays claimable with no deadline. The value follows the departing holder; the name does not wait for them. Cross-paid registrations seed zero-amount positions, so those names have nothing to withdraw and nothing to settle. + +### Cost-model versioning -Both are snapshotted at release time, so a governance change never changes the clocks on a name already released. `redeemWindow` is tuned through `updateRedeemWindow` under the same gate as the upgrade authority. +D and F are fixed for the life of a pricing model. Changing either means deploying a fresh model with the new values and registering it, at which point it becomes the current version; there is no live setter that edits the numbers in place. Governance can also point the current version back at an earlier registered model to roll a change back. An in-flight registration prices at the version it committed to, so a model change between commit and reveal leaves its cost unchanged. -Inside the redeem window the name belongs to its previous holder. They alone may act on it, and `DotnsRegistrar.available` reports **false** so no client advertises the name as free and no registrant burns a commit-reveal cycle on a registration that cannot succeed. The two are mutually exclusive: +### What governance controls -- **`redeem`** returns the NFT and moves no value. The position keeps its recipient, asset and amount, so the deposit stays locked and the name lands back in its exact pre-release state, releasable again later on a fresh pair of clocks. This is the undo for an accidental release. -- **`withdraw`** credits the deposit and forfeits the right to redeem. A holder who has been paid for the name cannot also take it back; otherwise they would hold a NoStatus name that no deposit backs, and the Sybil bound of one D per live NoStatus name would break. +Governance sets D and F to whatever values it chooses. The contracts hold them coherent and nothing more: D above zero, F above zero and no greater than D, and D within a ceiling that keeps the six-character multiplication from overflowing. There is no cap on how high or low D goes and no limit on how fast it moves, so any rate limit or advance notice comes from the governance process rather than from these contracts. Governance also opens or closes the short-name market with the switch, tunes the release cooldown within its one-hour bound, sets the redeem window between 1 and 30 days, and sets the gateway's reservation duration. None of these controls lets governance seize, reassign, or destroy a name anyone already holds. -Once `redeemableUntil` is reached, `reclaim` is permissionless through the ordinary commit-reveal path, **whether or not the previous holder ever withdrew**. If the position still holds value, reclaim settles it: the amount is credited to the previous holder's pull-payment balance and stays claimable through `claimWithdrawal` with no deadline. The value follows the departing holder; the name does not wait for them. +### Refund ledgers -That is why the window is bounded rather than open-ended: reclaim cannot depend on the previous holder acting. Free PopFull and PopLite registrations seed zero-amount positions, so for those names there is nothing to withdraw and no incentive to act. +The escrow keeps two separate pull-payment ledgers. One has no cooldown and serves as the fallback when a registration overpayment cannot be pushed back to the sender inline. The other gives every credit its own cooldown clock and holds transfer-fee overpayments. A deposit unlocked by releasing a funded name lands on the no-cooldown ledger; its delay comes instead from the position's own `withdrawAvailableAt` stamp, so once `withdraw` lands the credit is immediately pullable. Clients enumerate pending refunds through the escrow's public views, which page under a fixed cap so discovery stays bounded. -Clients wanting the exact moment a released name becomes registrable should read `redeemableUntil` from `getReleasePosition` rather than polling `available`. +Clients that need the exact moment a released name becomes registrable should read `redeemableUntil` from `getReleasePosition` rather than polling `available`. ## Contracts @@ -107,7 +128,7 @@ Two controllers sit on top of a single registrar and a single protocol registry. ### DotnsRegistrarController -Commit-reveal controller for the public registration path. A caller first submits a commitment hash, waits out the minimum commitment age, then reveals the registration parameters alongside the payment. The controller validates the commitment, routes price and eligibility through PopRules, and orchestrates every side effect of a successful registration: the mint on the registrar, the forward wire-up on the registry, the reverse record on the reverse resolver, the immutable Store write, and any refund owed on overpayment. Acceptable input is a single DNS label of at least the minimum-length policy; shorter labels revert with `LabelTooShort`. Labels classified as governance-reserved revert with `GovernanceReserved`; a base stem held by another user reverts with `NameReserved`. On the cross-payer path the owner's recorded PoP tier must meet the label's required tier — verified-payer-for-unverified-owner sponsorship is rejected with `OwnerStatusInsufficient`, so the direct-path personhood guarantee carries over to sponsored registrations. +Commit-reveal controller for the public registration path. A caller first submits a commitment hash, waits out the minimum commitment age, then reveals the registration parameters alongside the payment. The controller validates the commitment, routes price and eligibility through PopRules, and orchestrates every side effect of a successful registration: the mint on the registrar, the forward wire-up on the registry, the reverse record on the reverse resolver, the immutable Store write, and any refund owed on overpayment. Acceptable input is a single DNS label of at least the minimum-length policy; shorter labels revert with `LabelTooShort`. Labels classified as governance-reserved revert with `GovernanceReserved`; a base stem held by another user reverts with `NameReserved`. On the cross-payer path the owner's recorded PoP tier must meet the label's required tier, so verified-payer-for-unverified-owner sponsorship is rejected with `OwnerStatusInsufficient` and the direct-path personhood guarantee carries over to sponsored registrations. ### DotnsPopController @@ -149,24 +170,24 @@ The registry exposes isAuthorised(node, account) as the canonical check for whet ### PopRules -PoP-aware name classification and pricing. Classification reads the label's **stem length** (the character count after stripping the trailing digit suffix) and the trailing digit count itself, then maps to one of four tiers: NoStatus (stem of 9+ characters, open to anyone for a flat deposit, with zero or exactly two trailing digits permitted), PopLite (stem of 6-8 characters with exactly two trailing digits, gateway-issued to lite-verified users), PopFull (stem of 6-8 characters with no trailing digits, requires full-person verification), and Reserved (stem of 5 characters or fewer, governed by the protocol). Labels carrying one trailing digit or more than two trailing digits are rejected at the classifier. The classification determines the price and the eligibility gate the commit-reveal controller enforces. +PoP-aware name classification and pricing. Classification reads the label's **stem length** (the character count after stripping the trailing digit suffix) and the trailing digit count itself, then maps to one of four tiers: NoStatus (stem of 9+ characters, open to anyone at the cost-model price, with zero or exactly two trailing digits permitted), PopLite (stem of 6-8 characters with exactly two trailing digits, gateway-issued to lite-verified users), PopFull (stem of 6-8 characters with no trailing digits, requires full-person verification), and Reserved (stem of 5 characters or fewer, governed by the protocol). Labels carrying one trailing digit or more than two trailing digits are rejected at the classifier. The classification determines the price and the eligibility gate the commit-reveal controller enforces. #### Classification examples and failure modes -The classifier bands on the stem, not the total label length. The stem is the label after removing any trailing digits. Trailing digit count must be zero or exactly two; a one-digit suffix and suffixes longer than two digits are invalid before tier eligibility is considered. - -| Label | Stem | Trailing digits | Classification | Eligible public path | Notes | -| --- | --- | ---: | --- | --- | --- | -| alice12 | alice | 2 | Reserved | Whitelist only | The stem is five characters, so the two-digit suffix does not make it PopLite. | -| andrew01 | andrew | 2 | PopLite | Pop gateway only | Valid lite shape: six-character stem plus system-supplied two-digit suffix. | -| alicebob42 | alicebob | 2 | PopLite | Pop gateway only | Eight-character stem plus two digits; total length is ten. | -| andrew | andrew | 0 | PopFull | PopFull user | Canonical full-person base name. | -| andrew1 | andrew | 1 | Rejected | None | One trailing digit has no protocol meaning. | -| andrewsays | andrewsays | 0 | NoStatus | Anyone | NoStatus self-registration pays the flat refundable deposit. | -| andrewsays01 | andrewsays | 2 | NoStatus | Anyone | Long stem remains NoStatus even with a two-digit suffix. | -| andrew123 | andrew | 3 | Rejected | None | More than two trailing digits is invalid. | -| andrew.01 | n/a | n/a | Rejected by public label validator | None | Dots are not valid in the public flat label. The Pop gateway accepts stem.suffix and normalises it to stemsuffix. | -| Andrew01 | n/a | n/a | Rejected by canonical label validator | None | Labels must be lowercase ASCII DNS labels. | +The classifier bands on the stem, not the total label length. The stem is the label after removing any trailing digits. The trailing digit count must be zero or exactly two; a one-digit suffix and suffixes longer than two digits are invalid before tier eligibility is considered. The price column gives the flat deposit for a registrable example; a rejected or reserved name has no price. + +| Label | Stem | Trailing digits | Classification | Eligible public path | Price | Notes | +| --- | --- | ---: | --- | --- | ---: | --- | +| alice12 | alice | 2 | Reserved | Whitelist only | Not sold; issued at 0 | The stem is five characters, so the two-digit suffix does not make it PopLite. | +| andrew01 | andrew | 2 | PopLite | Pop gateway only | 10 DOT | Valid lite shape: six-character stem plus system-supplied two-digit suffix. Priced on the public paid path only while the short-name switch is on; the gateway grant is free. | +| alicebob42 | alicebob | 2 | PopLite | Pop gateway only | 10 DOT | Eight-character stem plus two digits; total length is ten. Gateway grant is free. | +| andrew | andrew | 0 | PopFull | PopFull user | 10 DOT | Canonical full-person base name; priced only while the short-name switch is on. | +| andrew1 | andrew | 1 | Rejected | None | n/a | One trailing digit has no protocol meaning. | +| andrewsays | andrewsays | 0 | NoStatus | Anyone | 10 DOT | Base length 10; the amount is the flat refundable deposit. | +| andrewsays01 | andrewsays | 2 | NoStatus | Anyone | 10 DOT | Long stem remains NoStatus even with a two-digit suffix, and prices at the same flat deposit. | +| andrew123 | andrew | 3 | Rejected | None | n/a | More than two trailing digits is invalid. | +| andrew.01 | n/a | n/a | Rejected by public label validator | None | n/a | Dots are not valid in the public flat label. The Pop gateway accepts stem.suffix and normalises it to stemsuffix. | +| Andrew01 | n/a | n/a | Rejected by canonical label validator | None | n/a | Labels must be lowercase ASCII DNS labels. | Tier assignment is read on every pricing call, not stored: PopRules queries the alias-accounts personhood precompile at DotnsConstants.PERSONHOOD with the dotns context (bytes32("dotns")), and translates the returned status byte into a PopStatus (0=NoStatus, 1=PopLite, 2=PopFull). Unknown tier bytes collapse to NoStatus, so a future precompile addition fails closed rather than silently being treated as a higher tier. There is no on-chain self-attestation; users obtain personhood off-chain through the People-chain ring proof and the alias-accounts pallet propagates the result via XCM. diff --git a/contracts/escrow/DotnsNameEscrow.sol b/contracts/escrow/DotnsNameEscrow.sol index 86705fc58..0beb7d980 100644 --- a/contracts/escrow/DotnsNameEscrow.sol +++ b/contracts/escrow/DotnsNameEscrow.sol @@ -80,11 +80,10 @@ contract DotnsNameEscrow is /// @notice Reverse lookup into `_releasedTokens` (one-based) for O(1) remove-by-swap. mapping(uint256 tokenId => uint256 indexPlusOne) private _releasedIndexPlusOne; - /// @notice Cumulative balance of cross-tier fees held against unreleased shortfalls. - /// @dev Credited by cross-tier registration deposits, reach-floor friction, and transfer-fee - /// deltas; debited only when `withdraw` needs to top up a refund that exceeds the - /// asset's reserved balance. - uint256 public insuranceFund; + /// @notice Cumulative balance of non-refundable protocol fees; only accumulates. + /// @dev Credited by cross-paid registration fees and transfer fees. Never debited: protocol + /// fees do not back refunds, which draw solely on the per-asset reserve. + uint256 public protocolFees; /// @notice Pull-payment ledger storing each recipient's claimable refund balance. /// @dev Per-recipient isolation ensures a failing or reentrant receiver cannot block other @@ -255,8 +254,8 @@ contract DotnsNameEscrow is ReleasePosition storage position = _positions[params.tokenId]; // Use `recipient` as the "is this slot funded?" sentinel so zero-amount - // positions (seeded by free PopFull / PopLite registrations) still count - // as funded and cannot be re-seeded with a different recipient. + // positions (seeded by cross-paid registrations, which pay a fee rather than a deposit) + // still count as present and cannot be re-seeded with a different recipient. require(position.recipient == address(0), PositionAlreadyFunded(params.tokenId)); require(!position.released, AlreadyReleased(params.tokenId)); @@ -278,7 +277,7 @@ contract DotnsNameEscrow is } /// @inheritdoc IDotnsNameEscrow - function depositInsurance(InsuranceDepositParams calldata params) + function depositProtocolFee(ProtocolFeeDepositParams calldata params) external payable override @@ -286,7 +285,7 @@ contract DotnsNameEscrow is { require(msg.value > 0, InvalidAmount()); - insuranceFund += msg.value; + protocolFees += msg.value; emit CrossTierFeePaid( params.tokenId, @@ -315,7 +314,7 @@ contract DotnsNameEscrow is address priorRecipient = position.recipient; - uint256 fee = params.reachFloor; + uint256 fee = params.transferFee; require(msg.value >= fee, InsufficientValue()); // Deposits follow the NFT, not the depositor. When the position is funded the locked @@ -326,20 +325,19 @@ contract DotnsNameEscrow is position.recipient = params.to; } - if (fee > 0) { - insuranceFund += fee; - } - charged = fee; - emit CrossTierFeePaid( - params.tokenId, - params.payer, - params.to, - fee, - /* isRegistration */ - false - ); + if (fee > 0) { + protocolFees += fee; + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.to, + fee, + /* isRegistration */ + false + ); + } uint256 overpayment = msg.value - fee; if (overpayment > 0) { @@ -355,8 +353,8 @@ contract DotnsNameEscrow is ReleasePosition storage position = _positions[tokenId]; // Recipient is the canonical "is this position present?" sentinel; zero-amount positions - // seeded for free PopFull / PopLite registrations are still releasable so every minted - // name has a reachable lifecycle. + // seeded for cross-paid registrations are still releasable so every minted name has a + // reachable lifecycle. require(position.recipient != address(0), DepositNotConfigured(tokenId)); require(!position.released, AlreadyReleased(tokenId)); @@ -415,8 +413,6 @@ contract DotnsNameEscrow is WithdrawalTooEarly(tokenId, position.withdrawAvailableAt, block.timestamp) ); - // `position.recipient == msg.sender` was just enforced above, so reuse the local in place - // of an extra warm SLOAD. _settleDeposit(position, tokenId, msg.sender); } @@ -424,11 +420,9 @@ contract DotnsNameEscrow is /// @dev Shared by @custom:function withdraw, where the recipient pulls the deposit themselves, /// and by @custom:function reclaim, where a third party takes the name and the deposit is /// settled on the departing holder's behalf. Both credit the same ledger and neither - /// transfers value, so the accounting is identical and lives here once. Draws from the - /// per-asset `tokenReserved` pool first and tops up from `insuranceFund` on shortfall; - /// @custom:reverts InsufficientFunds when even the combined balance cannot cover the - /// amount owed. Emits @custom:emits RefundWithdrawn, and @custom:emits InsuranceDraw - /// whenever the insurance fund contributes. + /// transfers value, so the accounting is identical and lives here once. The per-asset + /// `tokenReserved` pool backs the refund in full, and @custom:reverts InsufficientFunds + /// when it cannot cover the amount owed. Emits @custom:emits RefundWithdrawn. /// A zero-amount position is a no-op: it writes nothing and emits nothing, which keeps the /// free-registration lifecycle free of meaningless ledger entries and events. /// @param position Storage pointer to the position being settled. @@ -455,28 +449,14 @@ contract DotnsNameEscrow is // Effects: from here the deposit really is being handed over, so the flag is set. position.claimed = true; - uint256 reserved = tokenReserved[asset]; - - uint256 fromRefundable; - uint256 fromInsurance; - if (reserved >= owed) { - fromRefundable = owed; - // fromInsurance is already 0 from default initialization. - } else { - fromRefundable = reserved; - fromInsurance = owed - reserved; - require( - insuranceFund >= fromInsurance, - InsufficientFunds(tokenId, owed, reserved + insuranceFund) - ); - } + // The per-asset reserve backs every refundable deposit; protocol fees are non-refundable + // and never cover a refund. + require( + tokenReserved[asset] >= owed, InsufficientFunds(tokenId, owed, tokenReserved[asset]) + ); position.amount = 0; - tokenReserved[asset] -= fromRefundable; - if (fromInsurance > 0) { - insuranceFund -= fromInsurance; - emit InsuranceDraw(tokenId, fromInsurance); - } + tokenReserved[asset] -= owed; _pendingWithdrawals[recipient] += owed; @@ -720,8 +700,8 @@ contract DotnsNameEscrow is // instead, and reclaim settles any unwithdrawn value rather than holding it hostage. // // Lifecycle state only. `reclaim` also settles the deposit, which can in principle revert - // `InsufficientFunds` when the reserved balance plus the insurance fund cannot cover the - // amount owed, so a true answer here is a claim about the window rather than a guarantee + // `InsufficientFunds` when the reserved balance cannot cover the amount owed, so a true + // answer here is a claim about the window rather than a guarantee // that the call is funded. The two coincide because `tokenReserved` is by construction the // exact sum of live position amounts: only `deposit` credits it, and only `_settleDeposit` // debits it, by exactly the amount it zeroes. `invariant_reserves_match_positions` holds diff --git a/contracts/escrow/IDotnsNameEscrow.sol b/contracts/escrow/IDotnsNameEscrow.sol index 95622377e..16d7c6776 100644 --- a/contracts/escrow/IDotnsNameEscrow.sol +++ b/contracts/escrow/IDotnsNameEscrow.sol @@ -19,30 +19,30 @@ interface IDotnsNameEscrow { address recipient; } - /// @notice Parameters for recording a cross-tier registration fee into the insurance fund. - /// @dev Funds the shared insurance pool used by `withdraw` to top up refunds whose per-asset - /// reserve is short; `payer` is preserved purely for event accounting since the deposit - /// itself is non-refundable. + /// @notice Parameters for recording a cross-paid registration fee into the protocol fee pot. + /// @dev The pot is non-refundable and only accumulates; it never backs a refund. `payer` is + /// preserved purely for event accounting since the fee itself is non-refundable. /// @param payer Original `msg.sender` of the controller's `register` call. /// @param recipient The NFT registrant the fee was paid on behalf of. - struct InsuranceDepositParams { + struct ProtocolFeeDepositParams { uint256 tokenId; address payer; address recipient; } /// @notice Inputs for charging transfer friction and rebinding the escrow position. - /// @dev The fee charged is the flat reach floor returned by @custom:function - /// PopRules.transferFloor, settled to the insurance fund. The deposit, when present, + /// @dev The fee charged is the name's own price returned by @custom:function + /// PopRules.transferFloor, settled to the protocol fee pot. The deposit, when present, /// travels with the NFT: the position is rebound to the recipient so the new holder is /// the only address that can later release into escrow and unlock the locked value. /// There is no transfer-time refund path. - /// @param reachFloor Required fee paid by the sender on a downward or cross-reach transfer. + /// @param tokenId Token whose escrow position is charged and rebound to the recipient. + /// @param transferFee The name's own curve price on a downward or cross-reach transfer. /// @param payer Original sender of the registrar transfer entrypoint. /// @param to NFT recipient. Becomes the new position recipient whenever a position exists. struct ChargeTransferFeeParams { uint256 tokenId; - uint256 reachFloor; + uint256 transferFee; address payer; address to; } @@ -146,9 +146,9 @@ interface IDotnsNameEscrow { /// @param recipient Address the NFT was returned to, which is also the position recipient. event NameRedeemed(uint256 indexed tokenId, address indexed recipient); - /// @notice Emitted when a cross-tier fee is paid into the insurance fund. + /// @notice Emitted when a cross-paid fee is paid into the protocol fee pot. /// @param payer Original `msg.sender` whose value funded the fee. - /// @param isRegistration True when emitted from `depositInsurance`; false from + /// @param isRegistration True when emitted from `depositProtocolFee`; false from /// `chargeTransferFee`. event CrossTierFeePaid( uint256 indexed tokenId, @@ -158,10 +158,6 @@ interface IDotnsNameEscrow { bool isRegistration ); - /// @notice Emitted when a withdrawal draws from the insurance fund to cover a shortfall in - /// `tokenReserved`. - event InsuranceDraw(uint256 indexed tokenId, uint256 amount); - /// @notice Emitted when overpayment is refunded to the payer. event OverpaymentRefunded(address indexed payer, uint256 amount); @@ -177,8 +173,8 @@ interface IDotnsNameEscrow { /// @notice Thrown when the attached call value is insufficient to cover the computed charge. error InsufficientValue(); - /// @notice Thrown when neither `tokenReserved` nor the insurance fund can cover the refund. - /// @param available Combined balance available across reserves and insurance. + /// @notice Thrown when the per-asset reserve cannot cover the refund owed. + /// @param available Reserve balance available for the asset. error InsufficientFunds(uint256 tokenId, uint256 owed, uint256 available); /// @notice Thrown when assets being deposited are not supported by the escrow. @@ -322,11 +318,11 @@ interface IDotnsNameEscrow { /// Emits @custom:emits NativeDepositRecorded once the deposit is booked. function deposit(DepositParams calldata params) external payable; - /// @notice Records a cross-tier registration fee into the insurance fund. + /// @notice Records a cross-paid registration fee into the protocol fee pot. /// @dev Only the configured controller may call this, otherwise @custom:reverts NotController. /// `msg.value` must be non-zero, otherwise @custom:reverts InvalidAmount. Emits /// @custom:emits CrossTierFeePaid with `isRegistration = true` once the fee is booked. - function depositInsurance(InsuranceDepositParams calldata params) external payable; + function depositProtocolFee(ProtocolFeeDepositParams calldata params) external payable; /// @notice Credits `msg.value` to `recipient`'s pull-payment ledger so the caller can later /// pull the balance with @custom:func claimWithdrawal. @@ -338,7 +334,7 @@ interface IDotnsNameEscrow { /// @param recipient Address whose pending balance should grow by `msg.value`. function creditOverpayment(address recipient) external payable; - /// @notice Charges transfer friction and rebinds the token's escrow position to the new holder. + /// @notice Charges the transfer fee and rebinds the token's escrow position to the new holder. /// @dev Only the configured registrar may call this, otherwise @custom:reverts NotRegistrar. /// When a fee is owed, the attached value must cover it or @custom:reverts /// InsufficientValue. Whenever a position exists for the token and the NFT is leaving its @@ -347,17 +343,17 @@ interface IDotnsNameEscrow { /// escrow does not refund anyone at transfer time; the only path back to the locked /// deposit is for the current holder to release into escrow and wait the cooldown. /// Emits @custom:emits CrossTierFeePaid (non-registration) when a non-zero fee is credited - /// to insurance, and credits any surplus value to the payer on the time-locked refund - /// ledger via @custom:emits RefundCredited. - /// @return charged Amount actually credited to insurance. + /// to the protocol fee pot, and credits any surplus value to the payer on the time-locked + /// refund ledger via @custom:emits RefundCredited. + /// @return charged Amount actually credited to the protocol fee pot. function chargeTransferFee(ChargeTransferFeeParams calldata params) external payable returns (uint256 charged); - /// @notice Returns the cumulative cross-tier fee balance held against future shortfalls. - /// @return balance Current insurance fund balance, in wei. - function insuranceFund() external view returns (uint256 balance); + /// @notice Returns the cumulative protocol fee balance, non-refundable and accumulating. + /// @return balance Current protocol fee balance, in wei. + function protocolFees() external view returns (uint256 balance); /// @notice Releases a token into escrow and starts the withdrawal cooldown. /// @dev First step of the phased lifecycle. The caller must be the current NFT holder and the @@ -386,12 +382,11 @@ interface IDotnsNameEscrow { /// AlreadyClaimed on re-entry). Only the current position recipient (the address that /// released the name, which mirrored the NFT holder at that moment) may call this, /// otherwise @custom:reverts NotRefundRecipient, and `block.timestamp` must have reached - /// `withdrawAvailableAt`, otherwise @custom:reverts WithdrawalTooEarly. Draws from the - /// per-asset `tokenReserved` pool first and falls back to the shared insurance fund on - /// shortfall; if even the combined balance is short, @custom:reverts InsufficientFunds. - /// Funds are not transferred here, only credited to the pull-payment ledger. Emits - /// @custom:emits RefundWithdrawn once the credit lands, and @custom:emits InsuranceDraw - /// whenever the insurance fund tops up a shortfall. + /// `withdrawAvailableAt`, otherwise @custom:reverts WithdrawalTooEarly. Refunds are backed + /// entirely by the per-asset `tokenReserved` pool; if that reserve is short, + /// @custom:reverts InsufficientFunds. Protocol fees never back a refund. Funds are not + /// transferred here, only credited to the pull-payment ledger. Emits @custom:emits + /// RefundWithdrawn once the credit lands. function withdraw(uint256 tokenId) external; /// @notice Pulls the caller's accumulated pending refund balance. @@ -416,13 +411,11 @@ interface IDotnsNameEscrow { /// NotReclaimable. Emits @custom:emits NameReclaimed once custody is transferred. /// Reclaim does not require the deposit to have been withdrawn first. If the position /// still holds value, this call settles it: the amount is debited from `tokenReserved` - /// (topping up from the insurance fund on shortfall, @custom:reverts InsufficientFunds if - /// even the combined balance is short) and credited to the previous recipient's - /// pull-payment balance, claimable through @custom:function claimWithdrawal with no - /// deadline. That is what keeps a name recyclable when its previous holder never returns: - /// the value follows them, the name does not wait for them. Emits @custom:emits - /// RefundWithdrawn on settlement, and @custom:emits InsuranceDraw when the insurance fund - /// tops up a shortfall. + /// (@custom:reverts InsufficientFunds if the reserve is short) and credited to the + /// previous recipient's pull-payment balance, claimable through @custom:function + /// claimWithdrawal with no deadline. That is what keeps a name recyclable when its + /// previous holder never returns: the value follows them, the name does not wait for them. + /// Emits @custom:emits RefundWithdrawn on settlement. /// @param newOwner Address of the new registrant taking over the name. function reclaim(uint256 tokenId, address newOwner) external; diff --git a/contracts/pop/DotnsCostModelRegistry.sol b/contracts/pop/DotnsCostModelRegistry.sol new file mode 100644 index 000000000..86842da0d --- /dev/null +++ b/contracts/pop/DotnsCostModelRegistry.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {IDotnsCostModelRegistry} from "./IDotnsCostModelRegistry.sol"; +import {IDotnsPricing} from "./IDotnsPricing.sol"; + +/// @title DotNS Cost Model Registry +/// @notice Keeps every registered cost model addressable by version and tracks the current one. +/// @dev Holds only pointers, so it stays a plain owner-gated contract. `PopRules` resolves it once +/// through `DotnsConstants.COST_MODEL` and prices the current version for fresh reads and a +/// specific version for in-flight registrations. +/// @custom:security-contact admin@parity.io +contract DotnsCostModelRegistry is Ownable, IDotnsCostModelRegistry { + /// @inheritdoc IDotnsCostModelRegistry + mapping(uint256 version => IDotnsPricing model) public override modelOf; + + /// @inheritdoc IDotnsCostModelRegistry + uint256 public override currentVersion; + + /// @notice Sets the owner permitted to register models. + /// @param owner_ Address that governs the model set. + constructor(address owner_) Ownable(owner_) {} + + /// @inheritdoc IDotnsCostModelRegistry + function register(IDotnsPricing model) external override onlyOwner { + uint256 version = model.version(); + require(version != 0, ZeroVersion()); + require(address(modelOf[version]) == address(0), AlreadyRegistered(version)); + modelOf[version] = model; + currentVersion = version; + emit CostModelRegistered(version, address(model)); + } + + /// @inheritdoc IDotnsCostModelRegistry + function setCurrentVersion(uint256 version) external override onlyOwner { + require(address(modelOf[version]) != address(0), UnknownVersion(version)); + currentVersion = version; + emit CurrentModelSet(version); + } + + /// @inheritdoc IDotnsCostModelRegistry + function current() external view override returns (IDotnsPricing model) { + return modelOf[currentVersion]; + } + + /// @inheritdoc IDotnsCostModelRegistry + function priceForBaseLength(uint256 baseLength) + external + view + override + returns (uint256 weiPrice) + { + IDotnsPricing model = modelOf[currentVersion]; + require(address(model) != address(0), UnknownVersion(currentVersion)); + return model.priceForBaseLength(baseLength); + } + + /// @inheritdoc IDotnsCostModelRegistry + function priceForBaseLengthAtVersion( + uint256 version, + uint256 baseLength + ) + external + view + override + returns (uint256 weiPrice) + { + IDotnsPricing model = modelOf[version]; + require(address(model) != address(0), UnknownVersion(version)); + return model.priceForBaseLength(baseLength); + } +} diff --git a/contracts/pop/DotnsFlatPricing.sol b/contracts/pop/DotnsFlatPricing.sol new file mode 100644 index 000000000..2be10f974 --- /dev/null +++ b/contracts/pop/DotnsFlatPricing.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsPricing} from "./IDotnsPricing.sol"; + +/// @title DotNS Flat Pricing +/// @notice Prices every registration at a single deposit, whatever the base length. +/// @dev The launch cost model: one constant amount for any name the bands admit, so a nine-plus +/// character name costs the same flat deposit and shorter names stay gated by `PopRules`. The +/// deposit is fixed at deployment, so a new amount is a fresh deployment registered under +/// `DotnsConstants.COST_MODEL`. `DotnsScarcityPricing` is the length-sensitive alternative +/// held as a later candidate; it is not the registered default. +/// @custom:security-contact admin@parity.io +contract DotnsFlatPricing is IDotnsPricing { + /// @notice Identifier of the flat model form, mixed into `version`. + /// @dev Separates this form from another model that reuses the same deposit, so `version` + /// cannot collide across model forms. + uint256 public constant FORM_ID = uint256(keccak256("dotns.pricing.flat.v1")); + + /// @notice Deposit in wei charged for every base length. + uint256 public immutable deposit; + + /// @notice Fixes the deposit for the life of this model. + /// @dev Requires a strictly positive deposit so the model can never price at zero; a zero + /// amount triggers @custom:reverts PricingError. + /// @param depositValue Deposit in wei charged for every base length. + constructor(uint256 depositValue) { + require(depositValue > 0, PricingError("Deposit must be greater than 0")); + deposit = depositValue; + } + + /// @inheritdoc IDotnsPricing + /// @dev Returns the same deposit for every base length: the amount does not vary with the + /// label, so the argument is read only to satisfy the interface. + function priceForBaseLength(uint256) external view override returns (uint256 weiPrice) { + return deposit; + } + + /// @inheritdoc IDotnsPricing + /// @dev Two deployments with the same deposit share a version; any change to the deposit + /// changes it. + function version() external view override returns (uint256 modelVersion) { + modelVersion = uint256(keccak256(abi.encode(FORM_ID, deposit))); + } +} diff --git a/contracts/pop/DotnsScarcityPricing.sol b/contracts/pop/DotnsScarcityPricing.sol new file mode 100644 index 000000000..de4b02d08 --- /dev/null +++ b/contracts/pop/DotnsScarcityPricing.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsPricing} from "./IDotnsPricing.sol"; + +/// @title DotNS Scarcity Pricing +/// @notice Prices a registration on a geometric scarcity curve driven by base length. +/// @dev The curve doubles the base fee for each character below nine and halves it for each +/// character from nine upward, never below the floor. The base fee is the curve's value at +/// nine characters. Both parameters are fixed at deployment, so a new curve is a fresh +/// deployment registered under `DotnsConstants.COST_MODEL`. Held as a length-sensitive +/// candidate for a later cost-model version; the launch default is the constant +/// `DotnsFlatPricing`, so this model ships unregistered until governance registers it. +/// @custom:security-contact admin@parity.io +contract DotnsScarcityPricing is IDotnsPricing { + /// @notice Identifier of the scarcity curve form, mixed into `version`. + /// @dev Distinguishes this curve shape from another model that reuses the same base fee and + /// floor, so `version` cannot collide across model forms. + uint256 public constant FORM_ID = uint256(keccak256("dotns.pricing.scarcity.v1")); + + /// @notice Base fee D in wei: the curve's value at nine characters. + uint256 public immutable baseFee; + + /// @notice Price floor F in wei: the least any name can cost. Never above the base fee. + uint256 public immutable minPrice; + + /// @notice Fixes the base fee and floor for the life of this model. + /// @dev Carries the curve invariants: the base fee and floor are both strictly positive, the + /// floor does not exceed the base fee, and the base fee stays within + /// `type(uint256).max / 512` so the multiplication below nine characters cannot overflow. + /// An all-digit label such as "42" strips to base length 0 and reaches the 2**9 + /// multiplier, which sets the /512 ceiling. Any breach triggers @custom:reverts PricingError. + /// @param baseFeeValue Base fee D in wei. + /// @param minPriceValue Price floor F in wei. + constructor(uint256 baseFeeValue, uint256 minPriceValue) { + require(baseFeeValue > 0, PricingError("Base fee must be greater than 0")); + require(minPriceValue > 0, PricingError("Floor must be greater than 0")); + require(minPriceValue <= baseFeeValue, PricingError("Floor cannot exceed the base fee")); + require( + baseFeeValue <= type(uint256).max / 512, + PricingError("Base fee exceeds the scarcity-curve ceiling") + ); + baseFee = baseFeeValue; + minPrice = minPriceValue; + } + + /// @inheritdoc IDotnsPricing + /// @dev Below nine the multiplier is at most 512, and the constructor caps the base fee at + /// `type(uint256).max / 512` so the multiplication cannot overflow. From nine upward the + /// base fee is right-shifted by `baseLength - 9`, so it only decreases and the floor stops + /// a long base length costing nothing. The floor stays at or below the base fee, so it + /// only binds from nine characters upward, never in the doubling range below nine. + function priceForBaseLength(uint256 baseLength) + external + view + override + returns (uint256 weiPrice) + { + uint256 curve = baseLength < 9 + ? baseFee * (2 ** (9 - baseLength)) + : baseFee >> (baseLength - 9); + return curve < minPrice ? minPrice : curve; + } + + /// @inheritdoc IDotnsPricing + /// @dev Two deployments with the same parameters share a version; any change to the base fee or + /// floor changes it. + function version() external view override returns (uint256 modelVersion) { + modelVersion = uint256(keccak256(abi.encode(FORM_ID, baseFee, minPrice))); + } +} diff --git a/contracts/pop/IDotnsCostModelRegistry.sol b/contracts/pop/IDotnsCostModelRegistry.sol new file mode 100644 index 000000000..f2630160d --- /dev/null +++ b/contracts/pop/IDotnsCostModelRegistry.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsPricing} from "./IDotnsPricing.sol"; + +/// @title DotNS Cost Model Registry +/// @notice Holds every cost model the protocol has run and names the current one. +/// @dev The address registered under `DotnsConstants.COST_MODEL` points here, set once and never +/// repointed. Changing the live curve registers a new model, which adds its version and moves +/// the current pointer. Prior models stay live and priceable by version, so a registration +/// committed against an earlier curve settles at the amount it committed to. +/// @custom:security-contact admin@parity.io +interface IDotnsCostModelRegistry { + /// @notice Emitted when a model is registered and becomes current. + /// @param version The model's version identifier. + /// @param model The model address now serving that version. + event CostModelRegistered(uint256 indexed version, address indexed model); + + /// @notice Emitted when the current version is pointed at an already-registered model. + /// @param version The version now serving fresh pricing. + event CurrentModelSet(uint256 indexed version); + + /// @notice Thrown when registering a model whose version is already held. + /// @param version The version already registered. + error AlreadyRegistered(uint256 version); + + /// @notice Thrown when pricing against a version that was never registered. + /// @param version The version with no registered model. + error UnknownVersion(uint256 version); + + /// @notice Thrown when registering a model whose version is zero, which is the sentinel for + /// an unregistered version and so cannot name a real model. + error ZeroVersion(); + + /// @notice Thrown when a registration reveals at a different version than it committed to. + /// @dev Raised where a commit-reveal flow binds a version at commit and checks it at reveal, so + /// the version a name prices at cannot move after the commitment is made. + /// @param committed The version bound when the commitment was made. + /// @param revealed The version supplied at reveal. + error PricingVersionMismatch(uint256 committed, uint256 revealed); + + /// @notice Registers a model and makes it current. + /// @dev Owner-only. Keys the model by its own `version`, so a version can be registered once; + /// a repeat triggers @custom:reverts AlreadyRegistered. Moves the current pointer to the + /// new version and emits @custom:emits CostModelRegistered. + /// @param model The cost model to register. + function register(IDotnsPricing model) external; + + /// @notice Points the current version at an already-registered model. + /// @dev Owner-only. Reverts to a previously registered version without redeploying it, so + /// governance can roll fresh pricing back to an earlier curve. @custom:reverts + /// UnknownVersion when no model is registered for `version`. Emits @custom:emits + /// CurrentModelSet. + /// @param version The already-registered version to make current. + function setCurrentVersion(uint256 version) external; + + /// @notice Returns the model registered for a version, or the zero address when none. + /// @param version The version to look up. + /// @return model The model registered for that version. + function modelOf(uint256 version) external view returns (IDotnsPricing model); + + /// @notice Returns the version currently serving fresh pricing. + /// @return version The current version identifier. + function currentVersion() external view returns (uint256 version); + + /// @notice Returns the current model. + /// @return model The model serving the current version. + function current() external view returns (IDotnsPricing model); + + /// @notice Prices a base length at the current version. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei at the current version. + function priceForBaseLength(uint256 baseLength) external view returns (uint256 weiPrice); + + /// @notice Prices a base length at a specific version. + /// @dev @custom:reverts UnknownVersion when no model is registered for `version`. + /// @param version The version to price against. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei at that version. + function priceForBaseLengthAtVersion( + uint256 version, + uint256 baseLength + ) + external + view + returns (uint256 weiPrice); +} diff --git a/contracts/pop/IDotnsPricing.sol b/contracts/pop/IDotnsPricing.sol new file mode 100644 index 000000000..6429bbba9 --- /dev/null +++ b/contracts/pop/IDotnsPricing.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title DotNS Pricing Cost Model +/// @notice Prices a registration from the base length of its label alone. +/// @dev The seam between name policy and the wei amount a registration costs. `PopRules` and the +/// public commit-reveal controller keep the classification, reservation, and tier rules; the +/// model owns only the amount for a given base length, so the curve can be swapped by +/// registering a new model under `DotnsConstants.COST_MODEL` without touching either. Only the +/// base length crosses the seam: the model reads no personhood band or `PopStatus`. The public +/// controller prices NoStatus deposits through this same path, so the model carries no PoP +/// name. +/// @custom:security-contact admin@parity.io +interface IDotnsPricing { + /// @notice Thrown when a model constructor parameter breaks a pricing invariant. + /// @param reason Human-readable explanation of the failed invariant. + error PricingError(string reason); + + /// @notice Returns the registration cost in wei for a label of the given base length. + /// @dev Pure amount lookup: the caller supplies the digit-stripped base length and the model + /// returns the curve value for it. Runs on the ERC721 transfer floor read, so it stays a + /// view with no state writes. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei for that base length. + function priceForBaseLength(uint256 baseLength) external view returns (uint256 weiPrice); + + /// @notice Returns a stable identifier for this model and its parameters. + /// @dev Changes when the model shape or its parameters change, so clients and telemetry can + /// tell one live curve from another. Not consulted on the pricing path. + /// @return modelVersion Identifier derived from the model form and its parameters. + function version() external view returns (uint256 modelVersion); +} diff --git a/contracts/pop/IPopRules.sol b/contracts/pop/IPopRules.sol index 1a37a7a0d..6fd22c4c9 100644 --- a/contracts/pop/IPopRules.sol +++ b/contracts/pop/IPopRules.sol @@ -12,8 +12,9 @@ pragma solidity ^0.8.34; /// classification. Reservations are keyed by the digit-stripped stem so `alice` and `alice42` /// share a slot. /// -/// Pricing is a flat per-name deposit on the NoStatus tier; verified PopLite and PopFull -/// users pay zero. +/// Amounts come from the cost model registered under `DotnsConstants.COST_MODEL`, which owns +/// the curve; only the base length crosses that seam. Every caller pays the same amount for a +/// given length; personhood only unlocks the premium band. /// @custom:security-contact admin@parity.io interface IPopRules { /// @notice Proof-of-Personhood eligibility tier. @@ -33,12 +34,11 @@ interface IPopRules { /// @param expires UNIX timestamp when the reservation expires. event BaseNameReserved(string indexed baseName, address indexed owner, uint64 expires); - /// @notice Emitted when the spam-deterrent NoStatus starting price is rotated. - /// @dev Owner-only setter @custom:function updateStartingPrice; the new value is consumed - /// by `_priceValidatedName` on the next pricing read. - /// @param oldPrice Previous wei value. - /// @param newPrice New wei value. - event StartingPriceUpdated(uint256 oldPrice, uint256 newPrice); + /// @notice Emitted when the public market for names shorter than nine characters is opened or + /// closed. + /// @dev Owner-only setter @custom:function setShortNamesEnabled. + /// @param enabled Whether names shorter than nine characters may now be bought. + event ShortNamesEnabledUpdated(bool enabled); /// @notice Thrown when a name violates PoP-tier or reservation requirements. /// @param reason Human-readable explanation of the failure condition. @@ -47,8 +47,28 @@ interface IPopRules { /// @notice Thrown when a caller is not an authorised controller on the registrar. error NotRegistry(); + /// @notice Thrown when registering a name whose base stem is held as a live reservation by + /// another user. + /// @param label Caller-supplied label whose stem is reserved. + error NameReserved(string label); + + /// @notice Thrown when registering a label that classifies as governance-reserved at the + /// protocol level. + /// @dev Distinct from @custom:reverts NameReserved so off-chain consumers can tell "wait for + /// the holder to relinquish" apart from "this label is permanently held by governance". + /// @param label Caller-supplied label that classifies as governance-reserved. + error GovernanceReserved(string label); + + /// @notice Thrown on the cross-payer path when the owner's recorded PoP tier does not meet the + /// label's required tier. The direct path's `priceWithCheck` covers this same condition via its + /// own revert. + /// @param label Label whose tier requirement was unmet. + /// @param userStatus Owner's recorded tier. + /// @param required Required tier for the label. + error OwnerStatusInsufficient(string label, PopStatus userStatus, PopStatus required); + /// @notice Bundle returned from metadata-aware pricing queries. - /// @param price Registration cost; typically non-zero only for NoStatus users. + /// @param price Registration cost from the current cost model for the label's base length. /// @param status Required PoP tier for this name. /// @param userStatus Current PoP status recorded for the querying user. /// @param message Human-readable classification description. @@ -83,6 +103,16 @@ interface IPopRules { pure returns (PopStatus requirement, string memory message); + /// @notice Opens or closes the public market for names shorter than nine characters. + /// @dev Owner-only; unauthorised callers trigger @custom:reverts OwnableUnauthorizedAccount. + /// While closed, which is the deploy default, @custom:function priceWithCheck and + /// @custom:function priceWithoutCheck trigger @custom:reverts PopError for a base length + /// below nine, so no public caller buys a short name. The gateway free grant and the + /// registrar's registerReserved path do not read this flag. Emits @custom:emits + /// ShortNamesEnabledUpdated. + /// @param enabled Whether names shorter than nine characters may be bought. + function setShortNamesEnabled(bool enabled) external; + /// @notice Returns the personhood tier recorded for an account. /// @dev Reads the account's dotns-scoped tier from the personhood precompile and maps it to a /// `PopStatus`. This is the direct account-tier read; the same tier otherwise surfaces @@ -92,15 +122,6 @@ interface IPopRules { /// @return tier The account's personhood tier. function personhoodOf(address account) external view returns (PopStatus tier); - /// @notice Updates the spam-deterrent starting price for NoStatus pricing. - /// @dev Owner-only; unauthorised callers trigger @custom:reverts - /// OwnableUnauthorizedAccount. `newStartingPrice` must be strictly positive, otherwise - /// @custom:reverts PopError. The new value flows into `_priceValidatedName` on the next - /// pricing read; no redeploy. Emits @custom:emits StartingPriceUpdated with the prior - /// and new values. - /// @param newStartingPrice New base price in wei. - function updateStartingPrice(uint256 newStartingPrice) external; - /// @notice Creates or refreshes a reservation entry for a PopLite-eligible stem. /// @dev Commit-reveal reservation path. Only an authorised controller on the registrar may /// call this, otherwise @custom:reverts NotRegistry. The caller passes the @@ -195,11 +216,12 @@ interface IPopRules { returns (bool reservedStatus, address owner, uint64 expires); /// @notice Calculates price with PoP classification and reservation enforcement. - /// @dev Reverting pricing path used by the commit-reveal controller. Price is a spam - /// deterrent and is significant only for NoStatus users; verified users pay zero. - /// Non-canonical labels, a base stem held live by another user, a governance-reserved - /// label, or a `userAddress` whose personhood tier does not meet the label's required - /// tier each trigger @custom:reverts PopError. + /// @dev Reverting pricing path used by the commit-reveal controller. Price is the scarcity + /// curve for the label's base length and is charged to every caller, verified or not; + /// personhood only unlocks the premium band. Non-canonical + /// labels, a base stem held live by another user, a governance-reserved label, or a + /// `userAddress` whose personhood tier does not meet the label's required tier each + /// trigger @custom:reverts PopError. /// @param name Domain label. /// @param userAddress Registering user for the given label. /// @return metadata Price with PoP requirements and classification. @@ -211,6 +233,27 @@ interface IPopRules { view returns (PriceWithMeta memory metadata); + /// @notice Calculates price at a specific cost-model version with PoP classification and + /// reservation enforcement. + /// @dev The versioned counterpart of @custom:function priceWithCheck: identical classification, + /// tier gating, and reservation rules, but the amount comes from the model registered for + /// `pricingVersionValue` rather than the current one. The commit-reveal controller prices + /// a reveal at the version bound into its commitment, so a model change between commit and + /// reveal does not move the amount. @custom:reverts UnknownVersion when the version was + /// never registered. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @param pricingVersionValue Cost-model version to price against. + /// @return metadata Price with PoP requirements and classification. + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + returns (PriceWithMeta memory metadata); + /// @notice Calculates price with PoP classification and reservation metadata, without /// reverting on conflicts. /// @dev Non-reverting counterpart to `priceWithCheck`: surfaces the same fields, but reports @@ -231,29 +274,40 @@ interface IPopRules { view returns (PriceWithMeta memory metadata); - /// @notice Friction fee owed when `account` reaches into a label tier above its verification - /// level. - /// @dev Non-zero only when `account` cannot meet the label's required PoP tier; the value is - /// the flat NoStatus deposit. Acts as cross-payer friction at registration time. Use - /// @custom:function transferFloor for transfer-time friction, which folds in the - /// sender-tier-downgrade component as well. Non-canonical labels and labels with exactly - /// one or more than two trailing digits trigger @custom:reverts PopError. - /// @param name Domain label being acted on. - /// @param account Account whose verification reach is being measured. - function reachFee(string calldata name, address account) external view returns (uint256 fee); + /// @notice Calculates price at a specific cost-model version with PoP classification and + /// reservation metadata, without reverting on conflicts. + /// @dev The versioned counterpart of @custom:function priceWithoutCheck: same non-reverting + /// preview behaviour, but the amount comes from the model registered for + /// `pricingVersionValue`. @custom:reverts UnknownVersion when the version was never + /// registered. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @param pricingVersionValue Cost-model version to price against. + /// @return metadata Price with PoP requirements and classification. + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + returns (PriceWithMeta memory metadata); - /// @notice Transfer-time friction floor: the greater of the recipient-reach component and - /// the sender-tier-downgrade component. - /// @dev Returns the flat NoStatus deposit when either (i) the recipient does not meet the - /// label's required tier, or (ii) the recipient's personhood tier is strictly below the - /// sender's. Returns zero when neither condition holds. The two components overlap on - /// pure tier mismatches, so the function takes their maximum rather than their sum to - /// avoid double-charging. Consumed by @custom:function DotnsRegistrar.quoteTransferFee. + /// @notice Transfer-time floor: the greater of the recipient-reach component and the + /// sender-tier-downgrade component, each priced at the name's own length. + /// @dev Re-prices the name at its own length on every move: returns the name's curve price when + /// either (i) the recipient does not meet the label's required tier, or (ii) the + /// recipient's personhood tier is strictly below the sender's, and zero when neither + /// holds. Passing a name to a wallet that could never have registered it therefore costs + /// the name's own curve price. The two components overlap on pure + /// tier mismatches, so the function takes their maximum rather than their sum to avoid + /// double-charging. Consumed by @custom:function DotnsRegistrar.quoteTransferFee. /// Non-canonical labels and labels with exactly one or more than two trailing digits /// trigger @custom:reverts PopError. /// @param name Domain label being transferred. /// @param from Current holder of the name. /// @param to Incoming holder of the name. + /// @return floor Transfer-time floor in wei: the name's own curve price, or zero. function transferFloor( string calldata name, address from, @@ -272,10 +326,19 @@ interface IPopRules { function isBaseName(string calldata name) external pure returns (bool isBase); /// @notice Calculates registration cost for a label. - /// @dev Returns zero for any label shorter than 9 characters; lengths >= 9 pay the flat - /// `startingPrice` deposit. Ignores the caller's personhood status and reservation - /// state. Non-canonical labels trigger @custom:reverts PopError. + /// @dev Prices the label by its base length through the cost model registered under + /// `DotnsConstants.COST_MODEL`. Ignores the caller's personhood status and reservation + /// state. A label whose trailing-digit suffix is neither zero nor exactly two, and any + /// non-canonical label, trigger @custom:reverts PopError. /// @param name Domain label to price. /// @return cost Registration cost in wei. function price(string calldata name) external view returns (uint256 cost); + + /// @notice Returns the current cost-model version. + /// @dev The current version held by the registry under `DotnsConstants.COST_MODEL`. The + /// commit-reveal controller binds it into a commitment and prices the reveal at that + /// version, so a model change between commit and reveal leaves the committed amount + /// unchanged. @custom:reverts PopError when no registry is configured. + /// @return modelVersion Identifier of the current cost model and its parameters. + function pricingVersion() external view returns (uint256 modelVersion); } diff --git a/contracts/pop/PopRules.sol b/contracts/pop/PopRules.sol index da346fb99..304463496 100644 --- a/contracts/pop/PopRules.sol +++ b/contracts/pop/PopRules.sol @@ -11,6 +11,7 @@ import { } from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; import {StringUtils} from "../utils/StringUtils.sol"; import {IPopRules} from "./IPopRules.sol"; +import {IDotnsCostModelRegistry} from "./IDotnsCostModelRegistry.sol"; import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; import {IDotnsController} from "../registrars/IDotnsController.sol"; import {DotnsRegistrar} from "../registrars/DotnsRegistrar.sol"; @@ -18,13 +19,17 @@ import {DotnsConstants} from "../utils/DotnsConstants.sol"; import {IPersonhood} from "../external/personhood/IPersonhood.sol"; /// @title PopRules -/// @notice Implements DotNS classification, flat NoStatus pricing, and base-name reservations. -/// @dev Tier shape: base lengths <= 5 are governance-reserved, base lengths 6-8 require PopFull +/// @notice Implements DotNS classification, cost-model-driven pricing, and base-name reservations. +/// @dev Tiers: base lengths <= 5 are governance-reserved, base lengths 6-8 require PopFull /// (or PopLite when carrying exactly two trailing digits, for gateway-issued lite names), /// base lengths >= 9 are open to any caller as NoStatus when they carry zero or exactly two /// trailing digits. A one-digit suffix and more than two trailing digits are invalid. -/// NoStatus users pay a single flat deposit (`startingPrice`) per name; verified users pay -/// zero on registration. +/// Every caller pays the same amount for a given base length. The amount comes from the cost +/// model registered under `DotnsConstants.COST_MODEL`, which owns the curve; this contract +/// passes it only the base length and keeps the classification, reservation, and tier rules. +/// Personhood only unlocks the premium band. Base lengths below nine are closed to the public +/// paid path until governance sets `shortNamesEnabled`; the gateway and registerReserved do +/// not consult it. /// @custom:security-contact admin@parity.io contract PopRules is Initializable, @@ -35,9 +40,6 @@ contract PopRules is { using StringUtils for *; - /// @notice Wei price for names with 9 characters and up. - uint256 public startingPrice; - /// @notice Active reservations keyed by digit-stripped base name. mapping(string baseName => Reservation reservation) public reservations; @@ -47,6 +49,10 @@ contract PopRules is /// @notice Protocol-level address registry for all DotNS contracts. IDotnsProtocolRegistry public protocolRegistry; + /// @notice Whether the public paid path may register names shorter than nine characters. + /// Closed by default; only governance opens it. + bool public shortNamesEnabled; + // forge-lint: disable-next-line(mixed-case-variable) uint256[50] private __gap; @@ -63,28 +69,19 @@ contract PopRules is /// @notice Initialises the oracle (public entry point). /// @dev Runs once behind the proxy; subsequent calls trigger @custom:reverts - /// InvalidInitialization via the `initializer` modifier. Seeds `startingPrice` through - /// @custom:function updateStartingPrice. - /// @param _startingPrice Base price in wei for NoStatus users. + /// InvalidInitialization via the `initializer` modifier. Amounts come from the cost model + /// registered under `DotnsConstants.COST_MODEL`, so no price is seeded here. /// @param registry Protocol-level address registry used to resolve sibling contracts. - function initialize( - uint256 _startingPrice, - IDotnsProtocolRegistry registry - ) - public - initializer - { + function initialize(IDotnsProtocolRegistry registry) public initializer { __Ownable_init(msg.sender); __ERC165_init(); - updateStartingPrice(_startingPrice); protocolRegistry = registry; } /// @inheritdoc IPopRules - function updateStartingPrice(uint256 newStartingPrice) public override onlyOwner { - require(newStartingPrice > 0, PopError("Price must be greater than 0")); - emit StartingPriceUpdated(startingPrice, newStartingPrice); - startingPrice = newStartingPrice; + function setShortNamesEnabled(bool enabled) external override onlyOwner { + shortNamesEnabled = enabled; + emit ShortNamesEnabledUpdated(enabled); } /// @inheritdoc IPopRules @@ -95,7 +92,7 @@ contract PopRules is returns (PopStatus requirement, string memory message) { _requireCanonicalLabel(name); - return _classifyValidatedName(name); + (requirement, message,) = _classifyValidatedName(name); } /// @inheritdoc IPopRules @@ -156,53 +153,110 @@ contract PopRules is view override returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRules + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, true, pricingVersionValue); + } + + /// @inheritdoc IPopRules + function priceWithoutCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRules + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, true, pricingVersionValue); + } + + /// @notice Shared body for the reservation-enforcing pricing reads. + /// @dev `atVersion` selects the amount source: the current model when false, the model for + /// `pricingVersionValue` when true. Classification, tier gating, and reservation rules are + /// the same on both paths, so they live here once. + function _priceWithCheck( + string calldata name, + address userAddress, + bool atVersion, + uint256 pricingVersionValue + ) + internal + view + returns (PriceWithMeta memory metadata) { _requireCanonicalLabel(name); _enforceReservationRules(name, userAddress); - (PopStatus requiredStatus, string memory classification) = _classifyValidatedName(name); + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); PopStatus userStatus = _personhoodTier(userAddress); - metadata.price = - userStatus == PopStatus.NoStatus ? _priceValidatedName(bytes(name).length) : 0; + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); metadata.status = requiredStatus; metadata.userStatus = userStatus; metadata.message = classification; require(requiredStatus != PopStatus.Reserved, PopError(classification)); - - if (requiredStatus == PopStatus.PopFull) { - require( - userStatus == PopStatus.PopFull, PopError("Requires Full Personhood verification") - ); - } else if (requiredStatus == PopStatus.PopLite) { - require( - userStatus == PopStatus.PopLite || userStatus == PopStatus.PopFull, - PopError("Requires Personhood Lite verification") - ); - } - // requiredStatus == PopStatus.NoStatus falls through: any user tier may register. + require(_meetsReach(requiredStatus, userStatus), PopError(classification)); return metadata; } - /// @inheritdoc IPopRules - function priceWithoutCheck( + /// @notice Shared body for the non-reverting pricing reads. + /// @dev Mirror of @custom:function _priceWithCheck for the front-end preview path: reports a + /// contested reservation through `metadata` rather than reverting. `atVersion` selects the + /// amount source in the same way. + function _priceWithoutCheck( string calldata name, - address userAddress + address userAddress, + bool atVersion, + uint256 pricingVersionValue ) - external + internal view - override returns (PriceWithMeta memory metadata) { _requireCanonicalLabel(name); - (PopStatus requiredStatus, string memory classification) = _classifyValidatedName(name); + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); PopStatus userStatus = _personhoodTier(userAddress); - metadata.price = - userStatus == PopStatus.NoStatus ? _priceValidatedName(bytes(name).length) : 0; + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); metadata.status = requiredStatus; metadata.userStatus = userStatus; metadata.message = classification; @@ -221,25 +275,12 @@ contract PopRules is /// @inheritdoc IPopRules function price(string calldata name) external view override returns (uint256) { _requireCanonicalLabel(name); - return _priceValidatedName(bytes(name).length); + return _priceValidatedName(_validatedBaseLength(name)); } /// @inheritdoc IPopRules - function reachFee( - string calldata name, - address account - ) - external - view - override - returns (uint256 fee) - { - _requireCanonicalLabel(name); - (PopStatus required,) = _classifyValidatedName(name); - if (_meetsReach(required, _personhoodTier(account))) { - return 0; - } - return startingPrice; + function pricingVersion() external view override returns (uint256 modelVersion) { + return _costModelRegistry().currentVersion(); } /// @inheritdoc IPopRules @@ -255,15 +296,16 @@ contract PopRules is { _requireCanonicalLabel(name); if (from == to) return 0; - (PopStatus required,) = _classifyValidatedName(name); + (PopStatus required,, uint256 baseLength) = _classifyValidatedName(name); + uint256 ownPrice = _priceValidatedName(baseLength); PopStatus toTier = _personhoodTier(to); - uint256 reachComponent = _meetsReach(required, toTier) ? 0 : startingPrice; + uint256 reachComponent = _meetsReach(required, toTier) ? 0 : ownPrice; PopStatus fromTier = _personhoodTier(from); // `_personhoodTier` never returns Reserved, so users are in {NoStatus, PopLite, PopFull} // and enum comparison reflects tier ordering directly. - uint256 downgradeComponent = toTier < fromTier ? startingPrice : 0; + uint256 downgradeComponent = toTier < fromTier ? ownPrice : 0; return reachComponent > downgradeComponent ? reachComponent : downgradeComponent; } @@ -289,8 +331,8 @@ contract PopRules is } /// @notice Single canonical "is `userStatus` at reach for `required`?" predicate. - /// @dev Both `reachFee` and `priceWithCheck` build on this so the tier-eligibility rule lives - /// in exactly one place and the two callers cannot disagree about who clears a given label. + /// @dev Both `priceWithCheck` and `transferFloor` build on this so the tier-eligibility rule + /// lives in exactly one place and the callers cannot disagree about who clears a given label. /// `_personhoodTier` never returns `Reserved`, so `userStatus` is in `{NoStatus, PopLite, /// PopFull}` and the enum comparison reflects tier ordering directly. A `Reserved` `required` /// (governance label) is unreachable by any verified user, so the comparison returns false and @@ -300,11 +342,59 @@ contract PopRules is return userStatus >= required; } - function _priceValidatedName(uint256 namelength) internal view returns (uint256 priceValue) { - if (namelength < 9) { - return 0; - } - return startingPrice; + /// @notice Amount for a base length at the current cost-model version. + /// @dev The cost-model registry owns the curve; this contract passes it only the base length. + /// The call is a view because it runs on the ERC721 transfer floor read through + /// @custom:function transferFloor. + function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { + return _costModelRegistry().priceForBaseLength(baseLength); + } + + /// @notice Amount for a base length at a specific cost-model version. + /// @dev Prices an in-flight registration at the version it committed to, so a model change + /// between commit and reveal does not move its cost. @custom:reverts UnknownVersion (from + /// the registry) when the version was never registered. + function _priceValidatedNameAtVersion( + uint256 pricingVersionValue, + uint256 baseLength + ) + internal + view + returns (uint256 priceValue) + { + return _costModelRegistry().priceForBaseLengthAtVersion(pricingVersionValue, baseLength); + } + + /// @notice Resolves the cost-model registry registered under `DotnsConstants.COST_MODEL`. + /// @dev @custom:reverts PopError when no registry is configured, so a pricing read fails closed + /// rather than resolving through the zero address. + function _costModelRegistry() private view returns (IDotnsCostModelRegistry registry) { + address configured = protocolRegistry.get(DotnsConstants.COST_MODEL); + require(configured != address(0), PopError("Cost model not configured")); + return IDotnsCostModelRegistry(configured); + } + + /// @notice Reverts a public paid registration of a base length below nine while the short-name + /// market is closed. + /// @dev The one gate both public price reads share. Base lengths of nine and above are always + /// open. @custom:reverts PopError when a base length below nine is priced while + /// `shortNamesEnabled` is false. The gateway and @custom:function registerReserved never + /// reach this, so neither is gated. + function _requireShortNamesOpen(uint256 baseLength) private view { + require(shortNamesEnabled || baseLength >= 9, PopError("Short names are not for sale")); + } + + /// @notice Validates the digit suffix and returns the base length that pricing and + /// classification both use to place a name in its band. + /// @dev A name carries no digit suffix or exactly two digits; any other count triggers + /// @custom:reverts PopError, so a longer suffix cannot slip a name into a shorter band. + function _validatedBaseLength(string calldata name) internal pure returns (uint256 baseLength) { + uint256 trailingDigits = _countTrailingDigits(name); + require( + trailingDigits == 0 || trailingDigits == 2, + PopError("Name must have no digit suffix or exactly 2 digit suffix") + ); + return bytes(name).length - trailingDigits; } /// @notice Enforces base-name reservation rules. @@ -349,14 +439,7 @@ contract PopRules is /// @param name Domain label. function _stripDigits(string calldata name) internal pure returns (string memory baseName) { bytes calldata bytesName = bytes(name); - uint256 endPosition = bytesName.length; - - while ( - endPosition > 0 && bytesName[endPosition - 1] >= 0x30 - && bytesName[endPosition - 1] <= 0x39 - ) { - endPosition--; - } + uint256 endPosition = bytesName.length - _countTrailingDigits(name); // No trailing digits to strip: return the input verbatim and skip the manual copy. if (endPosition == bytesName.length) return name; @@ -372,31 +455,24 @@ contract PopRules is function _classifyValidatedName(string calldata name) internal pure - returns (PopStatus requirement, string memory message) + returns (PopStatus requirement, string memory message, uint256 baseLength) { - uint256 totallength = bytes(name).length; - uint256 trailingDigits = _countTrailingDigits(name); - - require( - trailingDigits == 0 || trailingDigits == 2, - PopError("Name must have no digit suffix or exactly 2 digit suffix") - ); - - uint256 baselength = totallength - trailingDigits; + baseLength = _validatedBaseLength(name); + uint256 trailingDigits = bytes(name).length - baseLength; - if (baselength <= 5) { - return (PopStatus.Reserved, "Reserved for Governance"); + if (baseLength <= 5) { + return (PopStatus.Reserved, "Reserved for Governance", baseLength); } - if (baselength >= 6 && baselength <= 8) { + if (baseLength >= 6 && baseLength <= 8) { if (trailingDigits == 2) { - return (PopStatus.PopLite, "Requires Lite personhood verification"); + return (PopStatus.PopLite, "Requires Lite personhood verification", baseLength); } - return (PopStatus.PopFull, "Requires Full personhood verification"); + return (PopStatus.PopFull, "Requires Full personhood verification", baseLength); } // Baselength >= 9 is open to any caller with no suffix or the two-digit lite suffix shape. - return (PopStatus.NoStatus, "Available to all"); + return (PopStatus.NoStatus, "Available to all", baseLength); } function _requireCanonicalLabel(string calldata name) internal pure { diff --git a/contracts/registrars/DotnsRegistrar.sol b/contracts/registrars/DotnsRegistrar.sol index e84f6b33b..c0f40c533 100644 --- a/contracts/registrars/DotnsRegistrar.sol +++ b/contracts/registrars/DotnsRegistrar.sol @@ -259,7 +259,7 @@ contract DotnsRegistrar is _syncRecipientStore(factory, to, from, tokenId); } - (uint256 reachFloor, uint256 requiredFee) = + (uint256 transferFee, uint256 requiredFee) = _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); if (requiredFee != 0) { require(msg.value >= requiredFee, TransferFeeRequired(tokenId, to, requiredFee)); @@ -284,7 +284,7 @@ contract DotnsRegistrar is IDotnsNameEscrow(payable(escrow)).chargeTransferFee{value: msg.value}( IDotnsNameEscrow.ChargeTransferFeeParams({ - tokenId: tokenId, reachFloor: reachFloor, payer: msg.sender, to: to + tokenId: tokenId, transferFee: transferFee, payer: msg.sender, to: to }) ); @@ -366,9 +366,10 @@ contract DotnsRegistrar is } /// @notice Quotes the friction fee required for a transfer. - /// @dev Required fee is the reach floor returned by @custom:function PopRules.transferFloor. - /// It is paid by the sender on every downward or cross-reach transfer and settles to the - /// insurance fund. Any prior deposit travels with the NFT: the escrow rebinds the position to + /// @dev Required fee is the name's own price returned by @custom:function + /// PopRules.transferFloor. It is paid by the sender on every downward or cross-reach transfer + /// and settles to the + /// protocol fee pot. Any prior deposit travels with the NFT: the escrow rebinds the position to /// the new holder rather than refunding the sender, so transferring a funded name forfeits the /// locked deposit to the recipient. Self-transfers and escrow-touching transfers return zero. function _quoteTransferFee( @@ -378,7 +379,7 @@ contract DotnsRegistrar is ) private view - returns (address escrow, uint256 reachFloor, uint256 requiredFee) + returns (address escrow, uint256 transferFee, uint256 requiredFee) { if (from == to) return (address(0), 0, 0); @@ -388,7 +389,7 @@ contract DotnsRegistrar is bool isEscrowTouching = to == escrow || from == escrow; IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); - (reachFloor, requiredFee) = + (transferFee, requiredFee) = _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); } @@ -406,7 +407,7 @@ contract DotnsRegistrar is ) private view - returns (uint256 reachFloor, uint256 requiredFee) + returns (uint256 transferFee, uint256 requiredFee) { if (isEscrowTouching) return (0, 0); @@ -420,9 +421,9 @@ contract DotnsRegistrar is string memory label = LabelUtils.stripTld(registry.tld(), fullName); require(bytes(label).length != 0, InvalidLabel()); - reachFloor = + transferFee = IPopRules(registry.get(DotnsConstants.POP_RULES)).transferFloor(label, from, to); - requiredFee = reachFloor; + requiredFee = transferFee; } /// @inheritdoc UUPSUpgradeable diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index 9a56f6e0d..9b990412a 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -10,6 +10,7 @@ import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/Reentrancy import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; import {IDotnsReverseResolver} from "../resolvers/IDotnsReverseResolver.sol"; import {IPopRules} from "../pop/IPopRules.sol"; +import {IDotnsCostModelRegistry} from "../pop/IDotnsCostModelRegistry.sol"; import {StringUtils} from "../utils/StringUtils.sol"; import {IDotnsRegistrarController} from "./IDotnsRegistrarController.sol"; import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; @@ -53,6 +54,13 @@ contract DotnsRegistrarController is /// @notice Stores Mapping of commitment hashes to timestamp committed. mapping(bytes32 hash => uint256 timestamp) public commitments; + /// @notice Cost-model version stamped on a commitment at commit time. + /// @dev Recorded from the registry's current version when `commit` runs, so the reveal can bind + /// a registration to the version that was current then. A caller cannot commit against an + /// arbitrary earlier, cheaper version: the reveal rejects a `pricingVersion` that differs + /// from this stamp. + mapping(bytes32 hash => uint256 version) public committedPricingVersion; + /// @notice Whitelist for addresses allowed to call `registerReserved`. mapping(address user => bool isWhiteListed) public whiteList; @@ -60,7 +68,7 @@ contract DotnsRegistrarController is IDotnsProtocolRegistry public protocolRegistry; /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; + uint256[49] private __gap; /// @notice Restricts calls to whitelisted addresses or the owner. /// @dev Used to gate `registerReserved`, which allows registering reserved names without @@ -130,7 +138,12 @@ contract DotnsRegistrarController is { commitment = keccak256( abi.encode( - registration.label, registration.owner, registration.secret, registration.reserved + registration.label, + registration.owner, + registration.secret, + registration.reserved, + registration.maxPrice, + registration.pricingVersion ) ); } @@ -144,9 +157,19 @@ contract DotnsRegistrarController is ); commitments[commitment] = block.timestamp; + committedPricingVersion[commitment] = _currentPricingVersion(); emit NameCommitted(commitment); } + /// @notice Reads the cost model's current version through the protocol registry. + /// @dev Resolved at commit time so the stamp binds the version live then, not at reveal. + /// @return pricingVersion The current cost-model version. + function _currentPricingVersion() internal view returns (uint256 pricingVersion) { + return + IDotnsCostModelRegistry(protocolRegistry.get(DotnsConstants.COST_MODEL)) + .currentVersion(); + } + /// @inheritdoc IDotnsRegistrarController function register(Registration calldata registration) external payable override nonReentrant { (IDotnsRegistrar registrar, bytes32 labelhash, bytes32 node) = @@ -179,25 +202,33 @@ contract DotnsRegistrarController is bool isDirect = msg.sender == registration.owner; IPopRules.PriceWithMeta memory priced; if (isDirect) { - priced = rules.priceWithCheck(registration.label, registration.owner); + priced = rules.priceWithCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); } else { - priced = rules.priceWithoutCheck(registration.label, registration.owner); + priced = rules.priceWithoutCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); if (priced.status == IPopRules.PopStatus.Reserved) { (IPopRules.PopStatus required,) = rules.classifyName(registration.label); if (required == IPopRules.PopStatus.Reserved) { - revert GovernanceReserved(registration.label); + revert IPopRules.GovernanceReserved(registration.label); } - revert NameReserved(registration.label); + revert IPopRules.NameReserved(registration.label); } require( priced.userStatus >= priced.status, - OwnerStatusInsufficient(registration.label, priced.userStatus, priced.status) + IPopRules.OwnerStatusInsufficient( + registration.label, priced.userStatus, priced.status + ) ); } - uint256 friction = - !isDirect ? rules.transferFloor(registration.label, msg.sender, registration.owner) : 0; - uint256 totalCharged = priced.price > friction ? priced.price : friction; + uint256 totalCharged = priced.price; + require( + totalCharged <= registration.maxPrice, + PriceExceedsMax(registration.label, totalCharged, registration.maxPrice) + ); require(msg.value >= totalCharged, InsufficientValue()); IDotnsReverseResolver reverse; @@ -245,7 +276,7 @@ contract DotnsRegistrarController is /// registration the full `chargeAmount` lands in the refundable deposit position /// keyed to `nameOwner`. On a cross-payer registration the deposit position is /// seeded with a zero amount so the release lifecycle stays reachable, and the same - /// `chargeAmount` routes to the insurance fund via `depositInsurance` keyed to + /// `chargeAmount` routes to the protocol fee pot via `depositProtocolFee` keyed to /// `msg.sender` as the payer. function _settleEscrow( address escrow, @@ -264,8 +295,8 @@ contract DotnsRegistrarController is ); if (!isDirect && chargeAmount > 0) { - IDotnsNameEscrow(payable(escrow)).depositInsurance{value: chargeAmount}( - IDotnsNameEscrow.InsuranceDepositParams({ + IDotnsNameEscrow(payable(escrow)).depositProtocolFee{value: chargeAmount}( + IDotnsNameEscrow.ProtocolFeeDepositParams({ tokenId: tokenId, payer: msg.sender, recipient: nameOwner }) ); @@ -356,7 +387,14 @@ contract DotnsRegistrarController is CommitmentTooOld(commitment, committedAt + maxCommitmentAge, block.timestamp) ); + uint256 stamped = committedPricingVersion[commitment]; + require( + registration.pricingVersion == stamped, + IDotnsCostModelRegistry.PricingVersionMismatch(stamped, registration.pricingVersion) + ); + delete commitments[commitment]; + delete committedPricingVersion[commitment]; } /// @notice Completes a commit-reveal registration: mints (or skips when reclaiming), diff --git a/contracts/registrars/IDotnsRegistrar.sol b/contracts/registrars/IDotnsRegistrar.sol index 2d95fc5fe..b0b88d9b3 100644 --- a/contracts/registrars/IDotnsRegistrar.sol +++ b/contracts/registrars/IDotnsRegistrar.sol @@ -21,7 +21,7 @@ interface IDotnsRegistrar is IERC721 { error EscrowNotConfigured(); /// @notice Thrown when a standard ERC721 transfer is attempted but the recipient - /// tier requires a non-zero reach floor and the caller forwarded no `msg.value`. + /// tier requires a non-zero transfer fee and the caller forwarded no `msg.value`. error TransferFeeRequired(uint256 tokenId, address to, uint256 requiredFee); /// @notice Thrown when @custom:function initialize is called with the zero address as @@ -112,8 +112,8 @@ interface IDotnsRegistrar is IERC721 { function labelOf(uint256 tokenId) external view returns (string memory label); /// @notice Quotes the additional native fee required to transfer a token to `to`. - /// @dev Returns the reach floor from @custom:function PopRules.transferFloor: the - /// maximum of (i) the flat reach component charged when the recipient does not meet + /// @dev Returns the fee from @custom:function PopRules.transferFloor: the name's own price + /// as the maximum of (i) the reach component charged when the recipient does not meet /// the label's required tier and (ii) the downgrade component charged when the /// recipient tier is strictly below the sender tier. Self-transfers and /// escrow-touching transfers (release into escrow, reclaim out of escrow) return @@ -144,7 +144,7 @@ interface IDotnsRegistrar is IERC721 { /// @inheritdoc IERC721 /// @dev The registrar's `_update` hook consults @custom:function PopRules.transferFloor - /// to compute the required reach floor; if the caller does not forward at least that + /// to compute the required transfer fee; if the caller does not forward at least that /// amount as `msg.value`, the transfer reverts with @custom:reverts TransferFeeRequired. /// The `payable` modifier on every transfer overload exists so the fee can be forwarded /// in the same call. @@ -160,13 +160,13 @@ interface IDotnsRegistrar is IERC721 { /// @inheritdoc IERC721 /// @dev Subject to the same fee-on-transfer gate as the four-argument overload; reverts with - /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero reach floor and + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and /// the caller has not forwarded it as `msg.value`. function safeTransferFrom(address from, address to, uint256 tokenId) external payable override; /// @inheritdoc IERC721 /// @dev Subject to the same fee-on-transfer gate as the safe overloads; reverts with - /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero reach floor and + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and /// the caller has not forwarded it as `msg.value`. function transferFrom(address from, address to, uint256 tokenId) external payable override; } diff --git a/contracts/registrars/IDotnsRegistrarController.sol b/contracts/registrars/IDotnsRegistrarController.sol index dacaf6d85..59739f421 100644 --- a/contracts/registrars/IDotnsRegistrarController.sol +++ b/contracts/registrars/IDotnsRegistrarController.sol @@ -21,11 +21,20 @@ interface IDotnsRegistrarController is IDotnsController { /// hash; revealed verbatim at registration time. /// @param reserved True when the registration flows through the whitelisted reserved /// pipeline (`registerReserved`); false for the standard public flow (`register`). + /// @param maxPrice Ceiling in wei the caller accepts for this registration; a reveal charged + /// above it reverts, closing the gap between the price at commit and the price at reveal. + /// @param pricingVersion Cost-model version the caller committed to; the reveal prices the name + /// at this version, so a model change between commit and reveal leaves the amount unchanged. It + /// must equal the version current when `commit` ran, which that call stamps on the commitment; + /// a reveal whose `pricingVersion` differs reverts, so the caller cannot bind an earlier, + /// cheaper version. struct Registration { string label; address owner; bytes32 secret; bool reserved; + uint256 maxPrice; + uint256 pricingVersion; } /// @notice Emitted when a commitment is submitted. @@ -72,34 +81,18 @@ interface IDotnsRegistrarController is IDotnsController { /// @param label Caller-supplied label that failed the minimum-length policy. error LabelTooShort(string label); - /// @notice Thrown when attempting to register a name whose base stem is held as a live - /// reservation by another user. - error NameReserved(string label); - - /// @notice Thrown when attempting to register a label that classifies as - /// governance-reserved at the protocol level. - /// @dev Distinct from @custom:reverts NameReserved so off-chain consumers can tell - /// "wait for the holder to relinquish" apart from "this label is permanently held by - /// governance". - /// @param label Caller-supplied label that the rules engine classifies as governance-reserved. - error GovernanceReserved(string label); - - /// @notice Thrown on the cross-payer path when the owner's recorded PoP tier does not - /// meet the label's required tier. The direct path's @custom:contract IPopRules - /// `priceWithCheck` covers this same condition via its own revert. - /// @param label Label whose tier requirement was unmet. - /// @param userStatus Owner's recorded tier. - /// @param required Required tier for the label. - error OwnerStatusInsufficient( - string label, IPopRules.PopStatus userStatus, IPopRules.PopStatus required - ); - /// @notice Thrown when a label is not a canonical lowercase ASCII DNS label. error InvalidLabel(); /// @notice Thrown when supplied payment is insufficient. error InsufficientValue(); + /// @notice Thrown when the total charge exceeds the ceiling the caller committed to. + /// @param label Label whose charge exceeded the ceiling. + /// @param charged Total charge computed at reveal. + /// @param maxPrice Ceiling the caller committed to. + error PriceExceedsMax(string label, uint256 charged, uint256 maxPrice); + /// @notice Thrown when escrow is not configured in the protocol registry. error EscrowNotConfigured(); @@ -121,8 +114,10 @@ interface IDotnsRegistrarController is IDotnsController { /// @notice Computes the commitment hash for a registration. /// @dev Uses `abi.encode` so the variable-width `label` is length-prefixed and the boundary - /// between `label` and the fixed-width `owner`, `secret`, and `reserved` fields is - /// unambiguous, binding the commitment to the exact tuple. + /// between `label` and the fixed-width `owner`, `secret`, `reserved`, `maxPrice`, and + /// `pricingVersion` fields is unambiguous, binding the commitment to the exact tuple. The + /// price ceiling and cost-model version are part of that tuple, so neither can be altered + /// between commit and reveal. function makeCommitment(Registration calldata registration) external pure @@ -136,7 +131,10 @@ interface IDotnsRegistrarController is IDotnsController { /// (`committedAt + maxCommitmentAge <= block.timestamp` overwrites) and exclusive on the /// reveal side (`register` rejects at the same instant with @custom:reverts /// CommitmentTooOld), so the slot is overwritable from exactly the timestamp at which - /// reveal begins rejecting it. Emits @custom:emits NameCommitted on success. + /// reveal begins rejecting it. Stamps the cost model's current version on the commitment, so + /// the reveal binds to the version live now and rejects a `pricingVersion` bound to an earlier + /// one with @custom:reverts PricingVersionMismatch. Emits @custom:emits NameCommitted on + /// success. function commit(bytes32 commitment) external; /// @notice Registers a name after the commitment delay. @@ -154,12 +152,13 @@ interface IDotnsRegistrarController is IDotnsController { /// `priceWithCheck` but applies it directly via @custom:reverts OwnerStatusInsufficient /// when the owner's recorded tier does not meet the label's required tier, and still /// rejects governance-reserved labels with @custom:reverts GovernanceReserved and live - /// cross-user stem reservations with @custom:reverts NameReserved. The total - /// charge on the cross-payer path is the greater of the owner-side registration price and - /// the owner-tier `transferFloor` friction (never their sum); friction is computed against - /// the owner's tier so a verified payer cannot pay around an unverified owner. The entire - /// charge routes to the escrow insurance fund while seeding a zero-amount deposit slot so - /// the release lifecycle stays reachable. The caller must supply at least the charge + /// cross-user stem reservations with @custom:reverts NameReserved. The cross-payer charge is + /// the owner-side registration price; the path applies no separate transfer friction. The + /// charge routes to the escrow protocol fee pot while seeding a zero-amount deposit slot so + /// the release lifecycle stays reachable. The reveal prices the name at the committed + /// `pricingVersion`, so a model change between commit and reveal leaves the amount unchanged, + /// and rejects a total charge above the committed ceiling with @custom:reverts PriceExceedsMax + /// before checking payment. The caller must supply at least the charge /// (otherwise @custom:reverts InsufficientValue); any overpayment is pushed back to /// `msg.sender` inline and, on failure, credited to the escrow's pull-payment ledger so /// contract receivers cannot block registration. Emits @custom:emits OverpaymentRefunded diff --git a/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index d93bcb298..1a902655c 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -30,14 +30,24 @@ library DotnsConstants { /// forge-lint: disable-next-line(unsafe-typecast) bytes32 internal constant PERSONHOOD_CONTEXT = bytes32("dotns"); - /// @notice Default deploy-time NoStatus rent price passed into - /// `PopRules.initialize` as `_startingPrice`. - /// @dev 10 DOT under revive's 18-decimal Asset Hub convention. Single - /// source of truth for deploy scripts and tests so the value cannot - /// drift between call sites. Live deployments rotate the runtime - /// `startingPrice` on `PopRules` rather than rebuilding consumers - /// against a new constant. - uint256 internal constant RENT_PRICE = 10 ether; + /// @notice Launch deposit passed into the `DotnsFlatPricing` constructor. + /// @dev 10 DOT under revive's 18-decimal Asset Hub convention. A new amount is a fresh model + /// deployment registered under @custom:constant COST_MODEL, so this constant seeds the + /// model rather than being read afterwards. Single source of truth for deploy scripts and + /// tests so the seed cannot drift between call sites. + uint256 internal constant BASE_DEPOSIT = 10 ether; + + /// @notice Price floor F passed into the `DotnsScarcityPricing` candidate's constructor. + /// @dev Below `BASE_DEPOSIT` so that curve falls above nine characters. Seeds the candidate + /// constructor; a new floor is a fresh model deployment. + uint256 internal constant MIN_PRICE = 0.1 ether; + + /// @notice Well-known key for the cost model pricing registrations by base length. + /// @dev Role: single authority for the wei amount a registration costs. `PopRules` resolves + /// it here on every pricing read, so swapping the model is one `set` on the protocol + /// registry without redeploying `PopRules` or its consumers. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant COST_MODEL = bytes32("costModel"); /// @notice Default release cooldown seeded on `DotnsNameEscrow.initialize`. /// @dev Single source of truth for deploy scripts and tests so the value cannot drift between diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index 86d7e43c9..36d39e61c 100644 --- a/deployments/paseo-assethub/420420417.json +++ b/deployments/paseo-assethub/420420417.json @@ -1 +1 @@ -{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0xfe5A45f7fD58D1A6FE09455DB799405b1dcE9411","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} +{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsCostModelRegistry":"0x8bfd1f0957e73716732e725802f13830B5682da4","DotnsFlatPricing":"0xD839B281dF72Df44fF275305E72cAEEc0fDAA648","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0xfe5A45f7fD58D1A6FE09455DB799405b1dcE9411","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} diff --git a/foundry.toml b/foundry.toml index 3c87ab2b9..5e2472c8b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -100,6 +100,11 @@ multiline_func_header = "all" [lint] exclude_lints = ["mixed-case-variable"] +# The post-build lint pass (solar) core-dumps on the full tree under the forge +# version the CI toolchain pulls, aborting every `forge build` (exit 134) and so +# every build-dependent job. Compilation itself is unaffected. Keep lint off the +# build path; run `forge lint` explicitly when the findings are wanted. +lint_on_build = false [rpc_endpoints] paseo_local = "http://127.0.0.1:8545" diff --git a/scripts/deploy/DeployRecords.s.sol b/scripts/deploy/DeployRecords.s.sol index cc5dd2632..87bbd5f4f 100644 --- a/scripts/deploy/DeployRecords.s.sol +++ b/scripts/deploy/DeployRecords.s.sol @@ -7,6 +7,9 @@ import {BaseDeployer} from "./BaseDeployer.s.sol"; import {DotnsResolver} from "../../contracts/resolvers/DotnsResolver.sol"; import {DotnsContentResolver} from "../../contracts/resolvers/DotnsContentResolver.sol"; import {PopRules} from "../../contracts/pop/PopRules.sol"; +import {DotnsFlatPricing} from "../../contracts/pop/DotnsFlatPricing.sol"; +import {DotnsCostModelRegistry} from "../../contracts/pop/DotnsCostModelRegistry.sol"; +import {IDotnsPricing} from "../../contracts/pop/IDotnsPricing.sol"; import {IDotnsProtocolRegistry} from "../../contracts/registry/IDotnsProtocolRegistry.sol"; import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; @@ -27,6 +30,7 @@ contract DeployRecords is BaseDeployer { _deployResolver(owner, protocolRegistry); _deployContentResolver(owner, protocolRegistry); + _deployCostModelStack(owner); _deployPopRules(owner, protocolRegistry); saveDeployments(); @@ -66,6 +70,36 @@ contract DeployRecords is BaseDeployer { ); } + /// @notice Deploys the flat launch model and the cost-model registry, then registers the model + /// so the registry serves it as the current version. + /// @dev The `COST_MODEL` protocol-registry key points at the registry, not the model; the wire + /// stage sets that key. The flat model prices every admitted name at `BASE_DEPOSIT`; + /// `DotnsScarcityPricing` is held as a later candidate and is not registered here. + function _deployCostModelStack(address owner) internal returns (address registry) { + address model = _broadcastDeployCreate3( + owner, + "DotnsFlatPricing.sol:DotnsFlatPricing", + abi.encode(DotnsConstants.BASE_DEPOSIT), + "DotnsFlatPricing" + ); + registry = _broadcastDeployCreate3( + owner, + "DotnsCostModelRegistry.sol:DotnsCostModelRegistry", + abi.encode(owner), + "DotnsCostModelRegistry" + ); + + // Idempotent for pipeline resume: re-running against an already-deployed + // chain finds this version registered, so register only when it is absent + // rather than reverting with AlreadyRegistered. + IDotnsPricing pricing = IDotnsPricing(model); + if (address(DotnsCostModelRegistry(registry).modelOf(pricing.version())) == address(0)) { + vm.startBroadcast(owner); + DotnsCostModelRegistry(registry).register(pricing); + vm.stopBroadcast(); + } + } + function _deployPopRules( address owner, address protocolRegistry @@ -76,10 +110,7 @@ contract DeployRecords is BaseDeployer { proxy = _broadcastDeployUups( owner, "PopRules.sol:PopRules", - abi.encodeCall( - PopRules.initialize, - (DotnsConstants.RENT_PRICE, IDotnsProtocolRegistry(protocolRegistry)) - ), + abi.encodeCall(PopRules.initialize, (IDotnsProtocolRegistry(protocolRegistry))), "PopRules" ); } diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index b71fe7ff3..b9097b0fa 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -5,6 +5,10 @@ import {console} from "forge-std/Script.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; import {PopRules} from "../../contracts/pop/PopRules.sol"; +import {DotnsFlatPricing} from "../../contracts/pop/DotnsFlatPricing.sol"; +import {DotnsCostModelRegistry} from "../../contracts/pop/DotnsCostModelRegistry.sol"; +import {IDotnsPricing} from "../../contracts/pop/IDotnsPricing.sol"; +import {IDotnsCostModelRegistry} from "../../contracts/pop/IDotnsCostModelRegistry.sol"; import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; import {DotnsRegistrarController} from "../../contracts/registrars/DotnsRegistrarController.sol"; import {DotnsPopController} from "../../contracts/registrars/DotnsPopController.sol"; @@ -85,6 +89,8 @@ contract DotnsDeployer is BaseDeployer { address contentResolver; address resolver; address popRules; + address costModel; + address costModelRegistry; address registrarController; address protocolRegistry; address nameEscrow; @@ -131,6 +137,7 @@ contract DotnsDeployer is BaseDeployer { deployment.registry = _deployRegistry(OWNER, deployment.protocolRegistry); deployment.contentResolver = _deployContentResolver(OWNER, deployment.protocolRegistry); deployment.resolver = _deployResolver(OWNER, deployment.protocolRegistry); + (deployment.costModel, deployment.costModelRegistry) = _deployCostModelStack(OWNER); deployment.popRules = _deployPopRules(OWNER, deployment.protocolRegistry); deployment.nameEscrow = _deployNameEscrow(OWNER, deployment.protocolRegistry); deployment.registrarController = @@ -273,6 +280,34 @@ contract DotnsDeployer is BaseDeployer { dotnsResolver = DotnsResolver(proxy); } + function _deployCostModelStack(address owner) + internal + returns (address model, address registry) + { + model = _broadcastDeployCreate3( + owner, + "DotnsFlatPricing.sol:DotnsFlatPricing", + abi.encode(DotnsConstants.BASE_DEPOSIT), + "DotnsFlatPricing" + ); + registry = _broadcastDeployCreate3( + owner, + "DotnsCostModelRegistry.sol:DotnsCostModelRegistry", + abi.encode(owner), + "DotnsCostModelRegistry" + ); + + // Idempotent for pipeline resume: re-running against an already-deployed + // chain finds this version registered, so register only when it is absent + // rather than reverting with AlreadyRegistered. + IDotnsPricing pricing = IDotnsPricing(model); + if (address(DotnsCostModelRegistry(registry).modelOf(pricing.version())) == address(0)) { + vm.startBroadcast(owner); + DotnsCostModelRegistry(registry).register(pricing); + vm.stopBroadcast(); + } + } + function _deployPopRules( address owner, address protocolRegistryProxy @@ -283,10 +318,7 @@ contract DotnsDeployer is BaseDeployer { proxy = _broadcastDeployUups( owner, "PopRules.sol:PopRules", - abi.encodeCall( - PopRules.initialize, - (DotnsConstants.RENT_PRICE, IDotnsProtocolRegistry(protocolRegistryProxy)) - ), + abi.encodeCall(PopRules.initialize, (IDotnsProtocolRegistry(protocolRegistryProxy))), "PopRules" ); popRules = PopRules(proxy); @@ -419,6 +451,9 @@ contract DotnsDeployer is BaseDeployer { protocolRegistry.set(DotnsConstants.REVERSE_RESOLVER, deployment.reverseResolver); protocolRegistry.set(DotnsConstants.RESOLVER, deployment.resolver); protocolRegistry.set(DotnsConstants.CONTENT_RESOLVER, deployment.contentResolver); + // Point at the cost-model registry before PopRules so no pricing read resolves an unset + // key. + protocolRegistry.set(DotnsConstants.COST_MODEL, deployment.costModelRegistry); protocolRegistry.set(DotnsConstants.POP_RULES, deployment.popRules); protocolRegistry.set(DotnsConstants.STORE_FACTORY, deployment.storeFactory); protocolRegistry.set(DotnsConstants.NAME_ESCROW, deployment.nameEscrow); @@ -459,6 +494,11 @@ contract DotnsDeployer is BaseDeployer { _verifyWhitelistOperator(deployment, whitelistOperator); require(DotnsRegistry(deployment.registry).recordExists(bytes32(0)), "Root record missing"); + require( + IDotnsCostModelRegistry(deployment.costModelRegistry).priceForBaseLength(9) + == DotnsConstants.BASE_DEPOSIT, + "CostModel: launch price mismatch" + ); console.log("=== Deployment verification complete ==="); } @@ -542,6 +582,7 @@ contract DotnsDeployer is BaseDeployer { DotnsConstants.CONTENT_RESOLVER, deployment.contentResolver, "Key: contentResolver" ); _assertKey(DotnsConstants.POP_RULES, deployment.popRules, "Key: popRules"); + _assertKey(DotnsConstants.COST_MODEL, deployment.costModelRegistry, "Key: costModel"); _assertKey(DotnsConstants.STORE_FACTORY, deployment.storeFactory, "Key: storeFactory"); _assertKey(DotnsConstants.NAME_ESCROW, deployment.nameEscrow, "Key: nameEscrow"); _assertKey(DotnsConstants.POP_CONTROLLER, deployment.popController, "Key: popController"); diff --git a/scripts/deploy/WireDeployments.s.sol b/scripts/deploy/WireDeployments.s.sol index 619d1bade..cdbc49590 100644 --- a/scripts/deploy/WireDeployments.s.sol +++ b/scripts/deploy/WireDeployments.s.sol @@ -43,6 +43,7 @@ contract WireDeployments is BaseDeployer { address contentResolver; address resolver; address popRules; + address costModelRegistry; address registrarController; address protocolRegistry; address multicall3; @@ -82,6 +83,7 @@ contract WireDeployments is BaseDeployer { addr.contentResolver = _readAddress("DotnsContentResolver"); addr.resolver = _readAddress("DotnsResolver"); addr.popRules = _readAddress("PopRules"); + addr.costModelRegistry = _readAddress("DotnsCostModelRegistry"); addr.registrarController = _readAddress("DotnsRegistrarController"); addr.protocolRegistry = _readAddress("DotnsProtocolRegistry"); addr.multicall3 = _readAddress("Multicall3"); @@ -111,6 +113,9 @@ contract WireDeployments is BaseDeployer { registry.set(DotnsConstants.REVERSE_RESOLVER, addr.reverseResolver); registry.set(DotnsConstants.RESOLVER, addr.resolver); registry.set(DotnsConstants.CONTENT_RESOLVER, addr.contentResolver); + // Point at the cost-model registry before PopRules so no pricing read resolves an unset + // key. + registry.set(DotnsConstants.COST_MODEL, addr.costModelRegistry); registry.set(DotnsConstants.POP_RULES, addr.popRules); registry.set(DotnsConstants.STORE_FACTORY, addr.storeFactory); registry.set(DotnsConstants.NAME_ESCROW, addr.nameEscrow); @@ -203,6 +208,7 @@ contract WireDeployments is BaseDeployer { "Key: contentResolver" ); require(registry.get(DotnsConstants.POP_RULES) == addr.popRules, "Key: popRules"); + require(registry.get(DotnsConstants.COST_MODEL) == addr.costModelRegistry, "Key: costModel"); require( registry.get(DotnsConstants.STORE_FACTORY) == addr.storeFactory, "Key: storeFactory" ); diff --git a/scripts/genesis/build-genesis.sh b/scripts/genesis/build-genesis.sh index 0c8d3387a..8088f8a42 100755 --- a/scripts/genesis/build-genesis.sh +++ b/scripts/genesis/build-genesis.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -# ============================================================================= # DotNS pallet-revive genesis builder # # Deploys the full DotNS contract set to a local anvil, then extracts the @@ -22,7 +21,6 @@ set -euo pipefail # # Requires: forge, anvil, cast (foundry), node >= 18, jq — all already present # in the release workflow. Run it after `forge build`, from the repo root. -# ============================================================================= OUT="${1:-./release}" mkdir -p "$OUT" diff --git a/scripts/genesis/extract-genesis.mjs b/scripts/genesis/extract-genesis.mjs index d36ec1348..e883624d5 100644 --- a/scripts/genesis/extract-genesis.mjs +++ b/scripts/genesis/extract-genesis.mjs @@ -28,9 +28,7 @@ import { readFileSync, writeFileSync } from "fs"; import { fileURLToPath } from "url"; import { resolve } from "path"; -// ============================================================================= // EIP-1967 implementation storage slot -// ============================================================================= export const EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; @@ -40,9 +38,7 @@ export const BEACON_IMPL_SLOT = "0x" + "0".repeat(63) + "1"; const ZERO_ADDR = "0x" + "0".repeat(40); -// ============================================================================= // Helpers -// ============================================================================= /** Pad a hex string to N bytes (left-pad with zeros) */ export function padHex(hex, bytes) { @@ -287,9 +283,7 @@ export function buildGenesis(stateData, deployments, log = () => {}, tld) { return tld ? { tld, accounts: genesisAccounts } : { accounts: genesisAccounts }; } -// ============================================================================= // CLI -// ============================================================================= function getArg(args, name) { const idx = args.indexOf(`--${name}`); diff --git a/scripts/genesis/extract-genesis.test.mjs b/scripts/genesis/extract-genesis.test.mjs index edd384797..b501c3d36 100644 --- a/scripts/genesis/extract-genesis.test.mjs +++ b/scripts/genesis/extract-genesis.test.mjs @@ -24,9 +24,7 @@ import { BEACON_IMPL_SLOT, } from "./extract-genesis.mjs"; -// ============================================================================= // Fixture: a miniature anvil dump shaped like the real DotNS deploy -// ============================================================================= const REGISTRY = "0x00000000000000000000000000000000000000a1"; const REGISTRY_IMPL = "0x00000000000000000000000000000000000000a2"; @@ -100,9 +98,7 @@ const MANIFEST = { const addressesOf = (genesis) => genesis.accounts.map((a) => a.address).sort(); -// ============================================================================= // addressFromWord -// ============================================================================= test("addressFromWord reads the low 20 bytes, including a packed pointer", () => { assert.equal(addressFromWord(word(STORE_IMPL)), STORE_IMPL); @@ -139,9 +135,7 @@ test("a hash-shaped word yields a candidate that is dropped for having no code", ); }); -// ============================================================================= // Discovery -// ============================================================================= test("beacon implementations are extracted (the previewnet regression)", () => { const genesis = buildGenesis(fixture(), MANIFEST); @@ -221,9 +215,7 @@ test("underscore-prefixed manifest keys are metadata, not contracts", () => { assert.equal(genesis.accounts.length, 6); }); -// ============================================================================= // Output shape -// ============================================================================= test("accounts carry padded storage, hex balance and nonce", () => { const genesis = buildGenesis(fixture(), MANIFEST); @@ -246,9 +238,7 @@ test("accounts carry padded storage, hex balance and nonce", () => { ); }); -// ============================================================================= // The proxy/implementation guard -// ============================================================================= test("findMissingImplementations catches a proxy whose impl never deployed", () => { // The blind spot this exists for: referencedContracts only follows a pointer whose diff --git a/scripts/shell/pre-commit.sh b/scripts/shell/pre-commit.sh index 64ce0db6a..e8f21f2c2 100755 --- a/scripts/shell/pre-commit.sh +++ b/scripts/shell/pre-commit.sh @@ -125,6 +125,22 @@ validate_abi_contracts() { run_validation "$file" "line-ending validation" awk '/\r/ { exit 1 }' "$file" } +# Rejects decorative separator comments: a comment whose content is a run of +# rule characters, such as a line of dashes or equals under a heading. Prose +# and bullet lists are untouched because they carry words, not a bare run. +_reject_separator_comments() { + if grep -nE '^[[:space:]]*(//+|/\*|\*|#)[[:space:]]*[-=*_~#]{6,}|^[[:space:]]*/{6,}[[:space:]]*$' "$1" >&2; then + return 1 + fi + return 0 +} + +validate_no_separator_comments() { + local file="$1" + + run_validation "$file" "decorative-separator check" _reject_separator_comments "$file" +} + echo "pre-commit: validating repository files" while IFS= read -r -d '' file; do [ -f "$file" ] || continue @@ -161,6 +177,14 @@ while IFS= read -r -d '' file; do validate_abi_contracts "$file" ;; esac + + case "$file" in + lib/*|node_modules/*) + ;; + *.sol|*.ts|*.tsx|*.js|*.cjs|*.mjs|*.sh|*.bash|*.py) + validate_no_separator_comments "$file" + ;; + esac done < <(git ls-files -z) if command -v actionlint > /dev/null 2>&1; then diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index 3ca3b5692..256517093 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -4,6 +4,10 @@ pragma solidity ^0.8.34; import {Test} from "forge-std/Test.sol"; import {PopRules, IPopRules} from "../../contracts/pop/PopRules.sol"; +import {DotnsFlatPricing} from "../../contracts/pop/DotnsFlatPricing.sol"; +import {DotnsScarcityPricing} from "../../contracts/pop/DotnsScarcityPricing.sol"; +import {DotnsCostModelRegistry} from "../../contracts/pop/DotnsCostModelRegistry.sol"; +import {IDotnsPricing} from "../../contracts/pop/IDotnsPricing.sol"; import {DotnsRegistrar} from "../../contracts/registrars/DotnsRegistrar.sol"; import { DotnsRegistrarController, @@ -63,6 +67,16 @@ abstract contract BaseDotns is Test { /// @notice Deployed PoP oracle instance. PopRules public popRules; + /// @notice Deployed flat model seeding the cost-model registry as the launch version. + DotnsFlatPricing public flatPricing; + + /// @notice Deployed scarcity model, a later candidate held for the version-registry suites; not + /// the registered default. + DotnsScarcityPricing public scarcityPricing; + + /// @notice Deployed cost-model registry resolved by PopRules under `COST_MODEL`. + DotnsCostModelRegistry public costModelRegistry; + /// @notice Deployed DotNS registrar instance. DotnsRegistrar public dotnsRegistrar; @@ -140,12 +154,16 @@ abstract contract BaseDotns is Test { /// @notice Deployed name escrow instance. DotnsNameEscrow public dotnsNameEscrow; - /// @notice Rent price applied to PoP NoStatus users for spam resistance. - /// @dev This value is passed into PopRules initialisation in this base test. - /// @dev Aliased to @custom:constant DotnsConstants.RENT_PRICE so deploy scripts and the test - /// base see the same value; downstream test suites reference `RENT_PRICE` + /// @notice Base deposit the flat launch model charges for every admitted name. + /// @dev Aliased to @custom:constant DotnsConstants.BASE_DEPOSIT so deploy scripts and the test + /// base see the same value; downstream test suites reference `BASE_DEPOSIT` /// directly. - uint256 public constant RENT_PRICE = DotnsConstants.RENT_PRICE; + uint256 public constant BASE_DEPOSIT = DotnsConstants.BASE_DEPOSIT; + + /// @notice Price floor F seeded into PopRules initialisation in this base test. + /// @dev Aliased to @custom:constant DotnsConstants.MIN_PRICE so deploy scripts and the test + /// base see the same seed; downstream test suites reference `MIN_PRICE` directly. + uint256 public constant MIN_PRICE = DotnsConstants.MIN_PRICE; /// @notice Default escrow cooldown used in tests. Bounded by the escrow's /// @custom:constant MAX_COOLDOWN ceiling. @@ -180,10 +198,10 @@ abstract contract BaseDotns is Test { string internal constant BASE_LABEL_C = "carolboy"; // baselength >= 9 classifies as NoStatus with no suffix or exactly two trailing digits. - /// @notice NoStatus classification label fixture A. - string internal constant NOSTATUS_LABEL_A = "nostatususer01"; - /// @notice NoStatus classification label fixture B. - string internal constant NOSTATUS_LABEL_B = "anothernostatus02"; + /// @notice NoStatus classification label fixture A. Nine-character stem, so it prices at D. + string internal constant NOSTATUS_LABEL_A = "nostatusa01"; + /// @notice NoStatus classification label fixture B. Nine-character stem, so it prices at D. + string internal constant NOSTATUS_LABEL_B = "nostatusb02"; /// @notice The bare TLD label the whole suite runs against. /// @dev Single definition point for the fixture's TLD. Change this one line to run every test @@ -260,11 +278,22 @@ abstract contract BaseDotns is Test { dotnsContentResolver = DotnsContentResolver(dotnsContentResolverAddress); vm.label(dotnsContentResolverAddress, "DotnsContentResolver"); + flatPricing = new DotnsFlatPricing(BASE_DEPOSIT); + vm.label(address(flatPricing), "DotnsFlatPricing"); + scarcityPricing = new DotnsScarcityPricing(BASE_DEPOSIT, MIN_PRICE); + vm.label(address(scarcityPricing), "DotnsScarcityPricing"); + costModelRegistry = new DotnsCostModelRegistry(owner); + vm.label(address(costModelRegistry), "DotnsCostModelRegistry"); + costModelRegistry.register(IDotnsPricing(address(flatPricing))); + address popRulesAddress = Upgrades.deployUUPSProxy( - "PopRules.sol:PopRules", abi.encodeCall(PopRules.initialize, (RENT_PRICE, registry)) + "PopRules.sol:PopRules", abi.encodeCall(PopRules.initialize, (registry)) ); popRules = PopRules(popRulesAddress); vm.label(popRulesAddress, "PopRules"); + // Open the short-name market so the band and registration suites exercise names below nine + // characters. The default-closed state is covered directly in PopRules unit tests. + popRules.setShortNamesEnabled(true); address dotnsResolverAddress = Upgrades.deployUUPSProxy( "DotnsResolver.sol:DotnsResolver", abi.encodeCall(DotnsResolver.initialize, (registry)) @@ -319,6 +348,7 @@ abstract contract BaseDotns is Test { protocolRegistry.set(DotnsConstants.REGISTRY, dotnsRegistryAddress); protocolRegistry.set(DotnsConstants.REVERSE_RESOLVER, dotnsReverseResolverAddress); protocolRegistry.set(DotnsConstants.POP_RULES, popRulesAddress); + protocolRegistry.set(DotnsConstants.COST_MODEL, address(costModelRegistry)); protocolRegistry.set(DotnsConstants.STORE_FACTORY, address(storeFactory)); protocolRegistry.set(DotnsConstants.RESOLVER, dotnsResolverAddress); protocolRegistry.set(DotnsConstants.CONTENT_RESOLVER, dotnsContentResolverAddress); @@ -695,7 +725,12 @@ abstract contract BaseDotns is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: nameOwner, secret: secret, reserved: reserveName + label: label, + owner: nameOwner, + secret: secret, + reserved: reserveName, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); diff --git a/test/fuzz/escrow/DotnsNameEscrowFuzz.t.sol b/test/fuzz/escrow/DotnsNameEscrowFuzz.t.sol index 8dae75a52..0727b28fe 100644 --- a/test/fuzz/escrow/DotnsNameEscrowFuzz.t.sol +++ b/test/fuzz/escrow/DotnsNameEscrowFuzz.t.sol @@ -141,7 +141,12 @@ contract DotnsNameEscrowFuzzTest is BaseDotns { keccak256(abi.encodePacked(nameLabel, nameOwner, block.timestamp, address(this))); registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: reserved + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: reserved, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); diff --git a/test/fuzz/pop/PopFuzz.t.sol b/test/fuzz/pop/PopFuzz.t.sol index 52cfdf380..2e1e55d77 100644 --- a/test/fuzz/pop/PopFuzz.t.sol +++ b/test/fuzz/pop/PopFuzz.t.sol @@ -41,6 +41,22 @@ contract PopRulesFuzzTest is BaseDotns { popRules.priceWithCheck(nameLabel, ed); } + function testFuzz_price_matches_flat_model(uint256 seed, uint256 length) public view { + length = bound(length, 3, 63); + string memory nameLabel = _makeAlpha(seed, length); + + // The launch model prices every length at the flat deposit. + assertEq(popRules.price(nameLabel), BASE_DEPOSIT); + } + + function testFuzz_price_is_monotonic_and_floored(uint256 seed, uint256 length) public view { + length = bound(length, 3, 62); + uint256 shorter = popRules.price(_makeAlpha(seed, length)); + uint256 longer = popRules.price(_makeAlpha(seed, length + 1)); + assertGe(shorter, longer, "price must not increase with length"); + assertGe(longer, MIN_PRICE, "price must never fall below the floor"); + } + function testFuzz_governance_names_always_revert( uint256 seed, uint256 length, diff --git a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol index 51330f2dc..bd48f03e1 100644 --- a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol @@ -79,7 +79,12 @@ contract DotnsPopControllerFuzz is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(fullLabel, ed, block.timestamp)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: fullLabel, owner: ed, secret: secret, reserved: true + label: fullLabel, + owner: ed, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); diff --git a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol index 73da11a48..9ff47f3a8 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -86,14 +86,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { assertEq(dotnsRegistrar.ownerOf(tokenId), registrant); } - function testFuzz_register_refunds_overpayment_inline_when_price_is_zero( - uint256 extra, - uint256 salt - ) - public - { + function testFuzz_register_refunds_overpayment_inline(uint256 extra, uint256 salt) public { address registrant = tiago; - string memory nameLabel = _labelPriceZero(bound(salt, 0, 64)); + string memory nameLabel = _labelPopLitePriced(bound(salt, 0, 64)); _grantPopLite(registrant); @@ -101,27 +96,26 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, registrant, false); uint256 requiredPrice = popRules.priceWithCheck(nameLabel, registrant).price; - assertEq(requiredPrice, 0); + assertGt(requiredPrice, 0); extra = bound(extra, 0, 5 ether); uint256 balanceBefore = registrant.balance; vm.startPrank(registrant); - dotnsRegistrarController.register{value: extra}(registration); + dotnsRegistrarController.register{value: requiredPrice + extra}(registration); vm.stopPrank(); - // Zero-priced mint with overpayment: the EOA payer receives the full - // `extra` back inline, leaving the pull ledger untouched. + // Overpayment is refunded inline to the EOA payer, leaving the pull ledger untouched. assertEq( registrant.balance, - balanceBefore, - "zero-priced EOA mint must net out balances when overpaid" + balanceBefore - requiredPrice, + "payer nets out to exactly the price when overpaid" ); assertEq( dotnsNameEscrow.pendingWithdrawal(registrant), 0, - "zero-priced EOA mint must not credit the pull ledger" + "EOA mint must not credit the pull ledger" ); bytes32 labelhash = keccak256(bytes(nameLabel)); @@ -148,7 +142,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, nameOwner, true, payer); uint256 requiredPrice = popRules.priceWithCheck(nameLabel, nameOwner).price; - assertEq(requiredPrice, 0); + assertGt(requiredPrice, 0); extra = bound(extra, 0, 5 ether); @@ -159,9 +153,11 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { dotnsRegistrarController.register{value: requiredPrice + extra}(registration); vm.stopPrank(); - // EOA payers receive the refund inline. Owner's wallet stays untouched + // EOA payers receive the overpayment refund inline. Owner's wallet stays untouched // and the pull ledger is bypassed for both parties. - assertEq(payer.balance, payerBalanceBefore, "EOA payer must net to zero on a free mint"); + assertEq( + payer.balance, payerBalanceBefore - requiredPrice, "EOA payer pays exactly the price" + ); assertEq( dotnsNameEscrow.pendingWithdrawal(payer), 0, @@ -192,7 +188,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, sender, true); vm.startPrank(sender); - dotnsRegistrarController.register{value: 0}(registration); + dotnsRegistrarController.register{value: popRules.price(nameLabel)}(registration); vm.stopPrank(); bytes32 labelhash = keccak256(bytes(nameLabel)); @@ -225,8 +221,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { IDotnsRegistrarController.Registration memory primaryRegistration = _commitFor(primaryName, nameOwner, true); + uint256 primaryPrice = popRules.price(primaryName); vm.prank(nameOwner); - dotnsRegistrarController.register{value: 0}(primaryRegistration); + dotnsRegistrarController.register{value: primaryPrice}(primaryRegistration); assertEq(dotnsReverseResolver.nameOf(nameOwner), string.concat(primaryName, ".dot")); @@ -237,8 +234,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { IDotnsRegistrarController.Registration memory giftedRegistration = _commitFor(giftedName, nameOwner, true, payer); + uint256 giftedPrice = popRules.price(giftedName); vm.prank(payer); - dotnsRegistrarController.register{value: 0}(giftedRegistration); + dotnsRegistrarController.register{value: giftedPrice}(giftedRegistration); assertEq(dotnsReverseResolver.nameOf(nameOwner), string.concat(primaryName, ".dot")); } @@ -277,7 +275,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, depositor, false); uint256 ownerPrice = popRules.priceWithCheck(nameLabel, depositor).price; - assertEq(ownerPrice, RENT_PRICE, "NoStatus price baseline must match RENT_PRICE"); + assertEq(ownerPrice, BASE_DEPOSIT, "NoStatus price baseline must match BASE_DEPOSIT"); vm.prank(depositor); dotnsRegistrarController.register{value: ownerPrice}(registration); @@ -287,7 +285,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { uint256 tokenId = uint256(node); IDotnsNameEscrow.ReleasePosition memory atMint = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(atMint.amount, RENT_PRICE, "position must hold full deposit after register"); + assertEq(atMint.amount, BASE_DEPOSIT, "position must hold full deposit after register"); assertEq(atMint.recipient, depositor, "position recipient must be the depositor at mint"); uint256 reservesBefore = dotnsNameEscrow.reserves(address(0)); @@ -303,7 +301,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory afterTransfer = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(afterTransfer.amount, RENT_PRICE, "deposit amount must travel with the NFT"); + assertEq(afterTransfer.amount, BASE_DEPOSIT, "deposit amount must travel with the NFT"); assertEq( afterTransfer.recipient, recipientSeed, @@ -337,8 +335,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { IDotnsRegistrarController.Registration memory registration = _commitFor(nameLabel, sender, true); + uint256 registrationPrice = popRules.price(nameLabel); vm.prank(sender); - dotnsRegistrarController.register{value: 0}(registration); + dotnsRegistrarController.register{value: registrationPrice}(registration); bytes32 labelhash = keccak256(bytes(nameLabel)); bytes32 node = _namehash(dotNode, labelhash); @@ -381,7 +380,12 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { keccak256(abi.encodePacked(nameLabel, nameOwner, block.timestamp, address(this))); registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: reserved + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: reserved, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -398,13 +402,15 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { return string(abi.encodePacked("popful", _uintToAlphaFixed(salt, 2))); } - /// @notice Generate a label that classifies as NoStatus and carries a non-zero price. + /// @notice Generate a NoStatus label with a nine-character stem, so it prices at the base + /// fee D on the curve. function _labelNoStatusPriced(uint256 salt) internal pure returns (string memory label) { - return string(abi.encodePacked("nostatus", _uintToAlphaFixed(salt, 2), "01")); + return string(abi.encodePacked("nostatu", _uintToAlphaFixed(salt, 2), "01")); } - /// @notice Generate a label that classifies as PopLite and prices to zero. - function _labelPriceZero(uint256 salt) internal pure returns (string memory label) { + /// @notice Generate an 8-char PopLite-tier label (base length 6) that prices at 8D on the + /// curve, so the amount is non-zero. + function _labelPopLitePriced(uint256 salt) internal pure returns (string memory label) { return string(abi.encodePacked("free", _uintToAlphaFixed(salt, 2), "01")); } diff --git a/test/intergration/BasicDotns.reverts.t.sol b/test/intergration/BasicDotns.reverts.t.sol index bd940ef0d..e1a019cbf 100644 --- a/test/intergration/BasicDotns.reverts.t.sol +++ b/test/intergration/BasicDotns.reverts.t.sol @@ -28,7 +28,12 @@ contract BasicDotnsIntegrationReverts is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, registrant, block.timestamp)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: registrant, secret: secret, reserved: true + label: nameLabel, + owner: registrant, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -38,7 +43,7 @@ contract BasicDotnsIntegrationReverts is BaseDotns { vm.expectRevert( abi.encodeWithSelector( - IPopRules.PopError.selector, "Requires Full Personhood verification" + IPopRules.PopError.selector, "Requires Full personhood verification" ) ); dotnsRegistrarController.register(registration); diff --git a/test/intergration/BasicDotns.t.sol b/test/intergration/BasicDotns.t.sol index b75d05835..8cdfa755a 100644 --- a/test/intergration/BasicDotns.t.sol +++ b/test/intergration/BasicDotns.t.sol @@ -5,8 +5,6 @@ import {BaseDotns} from "../base/BaseDotns.t.sol"; import {IDotnsRegistry} from "../../contracts/registry/IDotnsRegistry.sol"; import {IDotnsRegistrarController} from "../../contracts/registrars/IDotnsRegistrarController.sol"; import {ILabelStore} from "../../contracts/store/ILabelStore.sol"; -import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; /// @title BasicDotnsIntegration /// @notice End-to-end happy-path integration coverage for registration, @@ -91,16 +89,6 @@ contract BasicDotnsIntegration is BaseDotns { ); } - /// @notice Returns true when the personhood precompile reports `account` at - /// tier `Lite` (1) or `Full` (2) under the dotns context. - /// @dev Reads the precompile mock installed by @custom:contract BaseDotns so the integration - /// flow gates pricing assertions on the same tier the controller sees. - function _personhoodTierIsAtLeastLite(address account) internal view returns (bool) { - IPersonhood.PersonhoodInfo memory info = IPersonhood(DotnsConstants.PERSONHOOD) - .personhoodStatus(account, DotnsConstants.PERSONHOOD_CONTEXT); - return info.status >= 1; - } - /// @notice Drives a full end-to-end registration, records, subname, and /// transfer flow under the configuration described by `flow`. /// @dev Aggregates the assertions that every PoP-tier-specific test case @@ -108,10 +96,9 @@ contract BasicDotnsIntegration is BaseDotns { /// bag. function _flowEndToEnd(FlowParams memory flow) internal { uint256 quotedPriceBefore = popRules.priceWithCheck(flow.name, flow.nameOwner).price; - - if (_personhoodTierIsAtLeastLite(flow.nameOwner)) { - assertEq(quotedPriceBefore, 0); - } + // Cross-entry-point consistency: the concrete curve amounts are covered by the PopRules + // unit tests; this only asserts that priceWithCheck and price agree. + assertEq(quotedPriceBefore, popRules.price(flow.name)); _commitAndRegister(flow.name, flow.nameOwner, flow.reserved); @@ -186,10 +173,9 @@ contract BasicDotnsIntegration is BaseDotns { uint256 transferRecipientQuotedPrice = popRules.priceWithCheck(flow.transferRecipientNewName, flow.transferTo).price; - - if (_personhoodTierIsAtLeastLite(flow.transferTo)) { - assertEq(transferRecipientQuotedPrice, 0); - } + // Cross-entry-point consistency: the concrete curve amounts are covered by the PopRules + // unit tests; this only asserts that priceWithCheck and price agree. + assertEq(transferRecipientQuotedPrice, popRules.price(flow.transferRecipientNewName)); _commitAndRegister(flow.transferRecipientNewName, flow.transferTo, false); @@ -247,7 +233,12 @@ contract BasicDotnsIntegration is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(giftedName, victim, block.timestamp, payer)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: giftedName, owner: victim, secret: secret, reserved: true + label: giftedName, + owner: victim, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); diff --git a/test/intergration/NoStatusDepositLifecycle.t.sol b/test/intergration/NoStatusDepositLifecycle.t.sol index cca852c63..d80397d19 100644 --- a/test/intergration/NoStatusDepositLifecycle.t.sol +++ b/test/intergration/NoStatusDepositLifecycle.t.sol @@ -16,25 +16,25 @@ import {IDotnsNameEscrow} from "../../contracts/escrow/IDotnsNameEscrow.sol"; /// unbounded number of NoStatus names over time. contract NoStatusDepositLifecycle is BaseDotns { /// @notice NoStatus label fixture (baselength >= 9 classifies as NoStatus). - string internal constant DEPOSIT_LABEL = "depositname01"; + string internal constant DEPOSIT_LABEL = "depositxy01"; function test_NoStatus_register_then_transfer_then_holder_claims_refund() public { address depositor = ed; address recipient = leonardo; uint256 ownerPrice = popRules.priceWithCheck(DEPOSIT_LABEL, depositor).price; - assertEq(ownerPrice, RENT_PRICE, "NoStatus price baseline must match RENT_PRICE"); + assertEq(ownerPrice, BASE_DEPOSIT, "NoStatus price baseline must match BASE_DEPOSIT"); - // Register pays D = RENT_PRICE into the depositor's position. + // Register pays D = BASE_DEPOSIT into the depositor's position. _commitAndRegister(DEPOSIT_LABEL, depositor, false); uint256 tokenId = _tokenIdForLabel(DEPOSIT_LABEL); IDotnsNameEscrow.ReleasePosition memory atMint = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(atMint.amount, RENT_PRICE, "position must hold full RENT_PRICE deposit"); + assertEq(atMint.amount, BASE_DEPOSIT, "position must hold full BASE_DEPOSIT deposit"); assertEq(atMint.recipient, depositor, "position recipient must be the depositor at mint"); assertEq( dotnsNameEscrow.reserves(address(0)), - RENT_PRICE, + BASE_DEPOSIT, "tokenReserved must reflect the seeded deposit" ); assertEq( @@ -55,14 +55,14 @@ contract NoStatusDepositLifecycle is BaseDotns { IDotnsNameEscrow.ReleasePosition memory afterTransfer = dotnsNameEscrow.getReleasePosition(tokenId); assertEq( - afterTransfer.amount, RENT_PRICE, "deposit must travel with the NFT, not be cleared" + afterTransfer.amount, BASE_DEPOSIT, "deposit must travel with the NFT, not be cleared" ); assertEq( afterTransfer.recipient, recipient, "position recipient must rebind to the new holder" ); assertEq( dotnsNameEscrow.reserves(address(0)), - RENT_PRICE, + BASE_DEPOSIT, "tokenReserved must not move while the deposit follows the NFT" ); assertEq( @@ -108,7 +108,7 @@ contract NoStatusDepositLifecycle is BaseDotns { assertEq( dotnsNameEscrow.pendingWithdrawal(recipient), - RENT_PRICE, + BASE_DEPOSIT, "deposit lands on the current holder's pull-payment ledger" ); assertEq( @@ -121,10 +121,10 @@ contract NoStatusDepositLifecycle is BaseDotns { vm.prank(recipient); uint256 claimed = dotnsNameEscrow.claimWithdrawal(); - assertEq(claimed, RENT_PRICE, "claim must return the full deposit"); + assertEq(claimed, BASE_DEPOSIT, "claim must return the full deposit"); assertEq( recipient.balance - balanceBefore, - RENT_PRICE, + BASE_DEPOSIT, "current holder balance must increase by the full deposit" ); assertEq( diff --git a/test/intergration/WhitelistOperatorFlow.t.sol b/test/intergration/WhitelistOperatorFlow.t.sol index 328a2bdb1..ca3fbdf36 100644 --- a/test/intergration/WhitelistOperatorFlow.t.sol +++ b/test/intergration/WhitelistOperatorFlow.t.sol @@ -28,7 +28,12 @@ contract WhitelistOperatorFlow is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, user, "operator")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: user, secret: secret, reserved: true + label: nameLabel, + owner: user, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); vm.startPrank(user); diff --git a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol index c6d467d41..e81a2bd1b 100644 --- a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol +++ b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol @@ -56,21 +56,32 @@ contract DotnsNameEscrowInvariantTest is BaseDotns { } /// @notice Escrow native balance must always cover the full liability set: tracked - /// reserves, the insurance fund, unclaimed pull-payment balances, and the time-locked + /// reserves, the protocol fees, unclaimed pull-payment balances, and the time-locked /// refund-ledger entries credited by the refund-on-leave path. Under the deposit-binds- /// to-depositor model these four flows are economically distinct; solvency is only /// meaningful against their sum. function invariant_solvency() public view { uint256 escrowBalance = address(dotnsNameEscrow).balance; uint256 reservedAmount = dotnsNameEscrow.reserves(address(0)); - uint256 insurance = dotnsNameEscrow.insuranceFund(); + uint256 protocolFees = dotnsNameEscrow.protocolFees(); uint256 pending = handler.totalPendingWithdrawals(); uint256 refundEntries = handler.totalPendingRefundEntries(); assertGe( escrowBalance, - reservedAmount + insurance + pending + refundEntries, - "Escrow balance must cover reserves + insurance + pending withdrawals + refund entries" + reservedAmount + protocolFees + pending + refundEntries, + "Escrow balance must cover reserves + protocol fees + pending withdrawals + refund entries" + ); + } + + /// @notice The handler's mirror of protocol-fee inflows must equal the escrow's on-chain + /// protocol-fee balance. Protocol fees only ever accrue, so every inflow the handler + /// tracks (cross-tier register and payable transfer) must sum to exactly the balance. + function invariant_protocol_fees_match_tracked_inflows() public view { + assertEq( + handler.ghost_protocolFeesPaidIn(), + dotnsNameEscrow.protocolFees(), + "Tracked protocol-fee inflows must equal on-chain protocol fees" ); } @@ -210,9 +221,9 @@ contract DotnsNameEscrowInvariantTest is BaseDotns { /// @notice Anything the escrow reports reclaimable can actually be paid out when reclaimed. /// @dev Lifecycle state only. `reclaim` also settles the deposit and can revert - /// `InsufficientFunds` when the reserved balance plus the insurance fund cannot cover the - /// amount owed, so a true answer is a claim about the window rather than a guarantee that - /// the call is funded. The two coincide because `tokenReserved` is by construction the + /// `InsufficientFunds` when the reserved balance cannot cover the amount owed, so a true + /// answer is a claim about the window rather than a guarantee that the call is funded. The + /// two coincide because `tokenReserved` is by construction the /// exact sum of live position amounts: only `deposit` credits it, and only `_settleDeposit` /// debits /// it, by exactly the amount it zeroes. `invariant_reserves_match_positions` holds that @@ -226,8 +237,6 @@ contract DotnsNameEscrowInvariantTest is BaseDotns { /// @notice Asserts settlement solvency for every reclaimable token in a set. function _assertFundable(uint256[] memory tokenIds) private view { - uint256 insurance = dotnsNameEscrow.insuranceFund(); - for (uint256 i; i < tokenIds.length; ++i) { uint256 tokenId = tokenIds[i]; @@ -238,8 +247,8 @@ contract DotnsNameEscrowInvariantTest is BaseDotns { assertLe( position.amount, - dotnsNameEscrow.reserves(position.asset) + insurance, - "a reclaimable position must be settleable from reserves plus insurance" + dotnsNameEscrow.reserves(position.asset), + "a reclaimable position must be settleable from reserves" ); } } diff --git a/test/invariant/escrow/EscrowHandler.t.sol b/test/invariant/escrow/EscrowHandler.t.sol index 1e31fe7bb..752da2722 100644 --- a/test/invariant/escrow/EscrowHandler.t.sol +++ b/test/invariant/escrow/EscrowHandler.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.34; -import {Test, Vm} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import { DotnsRegistrarController, IDotnsRegistrarController @@ -51,16 +51,11 @@ contract EscrowHandler is Test { /// @notice Label used to register each tokenId; required for re-registration after finalise. mapping(uint256 tokenId => string label) public labelByTokenId; - /// @notice Cumulative native amount credited into the insurance fund by handler-driven flows. - /// @dev Increments via cross-tier register (`depositInsurance`) and payable `transferFrom` - /// (`chargeTransferFee`). Counterpart to `ghost_insurancePaidOut`. - uint256 public ghost_insurancePaidIn; - - /// @notice Cumulative native amount drawn out of the insurance fund. - /// @dev Updated by parsing `InsuranceDraw` events emitted from `withdraw()`. The - /// conservation invariant asserts `ghost_insurancePaidIn - ghost_insurancePaidOut - /// == escrow.insuranceFund()`. - uint256 public ghost_insurancePaidOut; + /// @notice Cumulative native amount credited into protocol fees by handler-driven flows. + /// @dev Increments via cross-tier register (`depositProtocolFee`) and payable `transferFrom` + /// (`chargeTransferFee`). Protocol fees only ever accrue, so a conservation invariant + /// asserts this equals `escrow.protocolFees()`. + uint256 public ghost_protocolFeesPaidIn; /// @notice Cumulative native amount credited to recipients via `withdraw()`. uint256 public ghost_pendingCredits; @@ -124,7 +119,12 @@ contract EscrowHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: actor, secret: secret, reserved: true + label: label, + owner: actor, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -160,7 +160,7 @@ contract EscrowHandler is Test { /// @dev Bounds inputs with `bound()` to keep handler runs within meaningful state. /// Picks a payer and an owner from the actor set (different where possible), /// randomly aligns or splits their PoP statuses, and dispatches the controller - /// `register()` call from the payer. Routes to the deposit, depositInsurance, + /// `register()` call from the payer. Routes to the deposit, depositProtocolFee, /// or skip branch depending on the resulting tier prices. Revert-safe: if the /// computed price is zero on both sides (PoPLite/PoPFull no-cost path) the call /// still completes but ghost state is only updated where state actually changed. @@ -196,7 +196,12 @@ contract EscrowHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: ownerAddr, secret: secret, reserved: true + label: label, + owner: ownerAddr, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -218,10 +223,9 @@ contract EscrowHandler is Test { // Under the A1 max-not-sum rule the controller charges // `max(priced.price, friction)` on the cross-payer path and routes the - // whole charge into the insurance fund via `depositInsurance`. The + // whole charge into protocol fees via `depositProtocolFee`. The // refundable deposit position is seeded at zero amount, so the only - // mutation invariant tracking has to mirror here is the insurance leg. - uint256 priorInsurance = escrow.insuranceFund(); + // mutation invariant tracking has to mirror here is the protocol-fee leg. bytes32 labelhash = keccak256(bytes(label)); bytes32 node = keccak256(abi.encodePacked(DOT_NODE, labelhash)); uint256 tokenId = uint256(node); @@ -235,29 +239,22 @@ contract EscrowHandler is Test { uint256 charge = ownerPrice > frictionForCharge ? ownerPrice : frictionForCharge; // Skip when no value moves: a zero charge produces a free zero-amount - // position with no insurance or reserves delta, so adding it to the + // position with no protocol-fee or reserves delta, so adding it to the // ghost-state token set adds noise without exercising any new branch. if (charge == 0) return; - vm.recordLogs(); vm.prank(payer); try controller.register{value: charge}(registration) { - Vm.Log[] memory logs = vm.getRecordedLogs(); - uint256 newInsurance = escrow.insuranceFund(); - _depositedTokenIds.push(tokenId); labelByTokenId[tokenId] = label; // Cross-payer registrations seed a zero-amount refundable position; // ghost-state mirrors that by leaving `depositAmounts` at zero. depositAmounts[tokenId] = 0; - if (newInsurance > priorInsurance) { - ghost_insurancePaidIn += (newInsurance - priorInsurance); - } - - // Track InsuranceDraw outflows surfaced by this transaction (defensive; the - // register path itself does not draw insurance, but recordLogs is already on). - _accountInsuranceDraws(logs); + // The whole cross-payer charge becomes protocol fee. Accumulate the + // independently-computed `charge` so the conservation invariant verifies the + // escrow credited exactly what the caller was charged, not an echo of its own state. + ghost_protocolFeesPaidIn += charge; } catch { return; } @@ -307,8 +304,7 @@ contract EscrowHandler is Test { /// @notice Withdraws refund for a released token after cooldown. /// @dev Picks from _releasedTokenIds, warps past cooldown, withdraws. - /// Moves the token to _withdrawnTokenIds. Records pending credits and any - /// `InsuranceDraw` event amounts via `vm.recordLogs`. + /// Moves the token to _withdrawnTokenIds and records the pending credit. /// @param tokenSeed Seed for selecting which released token to withdraw. function withdrawRefund(uint256 tokenSeed) external { if (_releasedTokenIds.length == 0) return; @@ -326,10 +322,8 @@ contract EscrowHandler is Test { uint256 owed = position.amount; - vm.recordLogs(); vm.prank(recipient); escrow.withdraw(tokenId); - Vm.Log[] memory logs = vm.getRecordedLogs(); // A zero-amount position settles nothing: no value moves, and the position stays released // and unwithdrawn, so its holder can still redeem it for the rest of the window. Filing it @@ -342,7 +336,6 @@ contract EscrowHandler is Test { _withdrawnTokenIds.push(tokenId); _removeReleased(index); ghost_pendingCredits += owed; - _accountInsuranceDraws(logs); } /// @notice Redeems a released token back to its previous holder inside the redeem window. @@ -399,7 +392,12 @@ contract EscrowHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: actor, secret: secret, reserved: true + label: label, + owner: actor, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -412,11 +410,8 @@ contract EscrowHandler is Test { uint256 price = popRules.priceWithCheck(label, actor).price; - vm.recordLogs(); vm.prank(actor); try controller.register{value: price}(registration) { - Vm.Log[] memory logs = vm.getRecordedLogs(); - _removeReleased(index); _depositedTokenIds.push(tokenId); depositAmounts[tokenId] = price; @@ -424,7 +419,6 @@ contract EscrowHandler is Test { labelByTokenId[tokenId] = label; ghost_pendingCredits += outstanding; - _accountInsuranceDraws(logs); } catch { return; } @@ -516,7 +510,12 @@ contract EscrowHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: actor, secret: secret, reserved: true + label: label, + owner: actor, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -555,7 +554,7 @@ contract EscrowHandler is Test { // Zero-value transfer path: skip whenever the registrar's quote returns a // non-zero fee. The quote folds in both the price-delta path and the - // reach-floor path, so the handler stays in sync with whatever fee + // transfer-fee path, so the handler stays in sync with whatever fee // branches the contract grows over time. string memory label = labelByTokenId[tokenId]; // Read once for documentation continuity; the actual gating check uses the quote. @@ -606,7 +605,7 @@ contract EscrowHandler is Test { if (to == address(0)) return; // Use the registrar's own quote so the value attached matches whatever the - // contract actually requires. This includes the reach-floor branch: when + // contract actually requires. This includes the transfer-fee branch: when // the recipient's verification level is below the label's required tier, // the registrar charges the flat NoStatus deposit even though the // price-delta path returns zero. Using `quoteTransferFee` makes the handler @@ -614,24 +613,13 @@ contract EscrowHandler is Test { uint256 requiredFee = registrar.quoteTransferFee(tokenId, to); if (requiredFee == 0) return; - uint256 priorInsurance = escrow.insuranceFund(); - - vm.recordLogs(); vm.prank(currentOwner); try registrar.transferFrom{value: requiredFee}(currentOwner, to, tokenId) { - Vm.Log[] memory logs = vm.getRecordedLogs(); - - // Read the on-chain insurance delta rather than predicting it. The - // chargeTransferFee path credits the reach floor to insurance; when - // the NFT is leaving its prior position recipient the position is - // rebound to the new holder rather than refunded, so reserves stay - // put and only insurance moves. Mirroring the formula in the handler - // would re-create the drift this guard is meant to prevent. - uint256 newInsurance = escrow.insuranceFund(); - if (newInsurance > priorInsurance) { - ghost_insurancePaidIn += (newInsurance - priorInsurance); - } - _accountInsuranceDraws(logs); + // The transfer fee is credited in full to protocol fees: the position rebinds + // to the new holder rather than refunding, so reserves stay put. Accumulate the + // independently-quoted `requiredFee` so the conservation invariant verifies the + // escrow credited exactly the quoted fee. + ghost_protocolFeesPaidIn += requiredFee; // Sync the amount as a safety net against future downgrade paths. // Under the deposit-follows-name design the leaving-recipient branch @@ -679,7 +667,7 @@ contract EscrowHandler is Test { /// @notice Returns the sum of pending pull-payment balances across all actors. /// @dev Used by the full-solvency invariant to assert escrow native balance covers - /// `reserves + insuranceFund + outstanding-pending-balances`. + /// `reserves + protocolFees + outstanding-pending-balances`. /// @return total Aggregate pending balance owed to the actor set. function totalPendingWithdrawals() external view returns (uint256 total) { uint256 length = actors.length; @@ -691,7 +679,7 @@ contract EscrowHandler is Test { /// @notice Returns the sum of outstanding time-locked refund entries across all actors. /// @dev Used by the full-solvency invariant to capture overpayment refunds that /// @custom:function chargeTransferFee credits via @custom:function _creditRefund when the - /// attached value exceeds the reach floor. Under the deposit-follows-name model the + /// attached value exceeds the transfer fee. Under the deposit-follows-name model the /// deposit itself never lands on this ledger; only payer overpayments do. Iterates each /// actor's entry list and sums each entry's amount. /// @return total Aggregate refund-ledger liability owed to the actor set. @@ -767,25 +755,6 @@ contract EscrowHandler is Test { return address(0); } - /// @notice Scans recorded logs for `InsuranceDraw` events and accumulates the amount - /// drawn into `ghost_insurancePaidOut`. - /// @dev Single canonical accounting helper used by every handler call that may trigger a draw - /// (currently `withdraw()`). Other inner calls forward an empty log array, which - /// is a no-op. - /// @param logs Recorded logs from the most recent inner call. - function _accountInsuranceDraws(Vm.Log[] memory logs) internal { - // keccak256("InsuranceDraw(uint256,uint256)") - bytes32 sig = keccak256("InsuranceDraw(uint256,uint256)"); - uint256 length = logs.length; - for (uint256 i; i < length; ++i) { - Vm.Log memory entry = logs[i]; - if (entry.emitter != address(escrow)) continue; - if (entry.topics.length == 0 || entry.topics[0] != sig) continue; - uint256 amount = abi.decode(entry.data, (uint256)); - ghost_insurancePaidOut += amount; - } - } - /// @notice Allows the handler to receive ETH refunds. receive() external payable {} } diff --git a/test/invariant/pop/CostModelVersionHandler.t.sol b/test/invariant/pop/CostModelVersionHandler.t.sol new file mode 100644 index 000000000..038669f21 --- /dev/null +++ b/test/invariant/pop/CostModelVersionHandler.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; + +import {DotnsCostModelRegistry} from "../../../contracts/pop/DotnsCostModelRegistry.sol"; +import {DotnsScarcityPricing} from "../../../contracts/pop/DotnsScarcityPricing.sol"; +import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; +import {PopRules} from "../../../contracts/pop/PopRules.sol"; +import { + IDotnsRegistrarController, + DotnsRegistrarController +} from "../../../contracts/registrars/DotnsRegistrarController.sol"; + +/// @title CostModelVersionHandler +/// @notice Bounded random-action handler that registers cost models, moves the current version +/// around, and drives commit-reveal registrations bound to a version. +/// @dev Ghost-tracks every version ever registered and the amount each returns at three base +/// lengths, so the invariant suite can assert a version's price never moves and that +/// commit-reveal always settles at the committed version. +contract CostModelVersionHandler is Test { + DotnsCostModelRegistry public registry; + PopRules public popRules; + DotnsRegistrarController public controller; + address public owner; + address public actor; + + /// @notice Every version ever registered, in registration order. + uint256[] public versions; + + /// @notice Expected amount each version returns at base length seven. + mapping(uint256 version => uint256 price) public expectedAt7; + + /// @notice Expected amount each version returns at base length nine. + mapping(uint256 version => uint256 price) public expectedAt9; + + /// @notice Expected amount each version returns at base length twelve. + mapping(uint256 version => uint256 price) public expectedAt12; + + /// @notice Marks versions already tracked so a re-registration is skipped. + mapping(uint256 version => bool tracked) public tracked; + + uint256 internal feeCounter; + uint256 internal labelNonce; + + constructor( + DotnsCostModelRegistry registry_, + PopRules popRules_, + DotnsRegistrarController controller_, + address owner_, + address actor_ + ) { + registry = registry_; + popRules = popRules_; + controller = controller_; + owner = owner_; + actor = actor_; + feeCounter = 1; + // Track the model already registered as the current version at construction. + _track(registry.currentVersion()); + } + + /// @notice Number of distinct versions tracked so far. + function versionCount() external view returns (uint256 count) { + return versions.length; + } + + /// @notice Registers a fresh immutable model at a new base fee and makes it current. + function registerModel(uint256 seed) external { + uint256 baseFee = bound(seed, 1 ether, 400 ether) + feeCounter * 1 wei; + feeCounter++; + DotnsScarcityPricing model = new DotnsScarcityPricing(baseFee, 0.1 ether); + uint256 version = model.version(); + if (address(registry.modelOf(version)) != address(0)) return; + + vm.prank(owner); + registry.register(IDotnsPricing(address(model))); + _track(version); + } + + /// @notice Points the current version at any already-registered version, older or newer. + function pointCurrent(uint256 idx) external { + if (versions.length == 0) return; + uint256 version = versions[idx % versions.length]; + vm.prank(owner); + registry.setCurrentVersion(version); + } + + /// @notice Commits a registration under the current version, moves the current pointer, then + /// reveals, asserting the charge settles at the committed version. + function bindMoveReveal(uint256 seed) external { + string memory label = _uniqueLabel(); + if (!controller.available(label)) return; + + uint256 committedVersion = registry.currentVersion(); + uint256 committedPrice = popRules.price(label); + + IDotnsRegistrarController.Registration memory registration = + IDotnsRegistrarController.Registration({ + label: label, + owner: actor, + secret: keccak256(abi.encodePacked(label, seed)), + reserved: false, + maxPrice: committedPrice, + pricingVersion: committedVersion + }); + + bytes32 commitment = controller.makeCommitment(registration); + vm.prank(actor); + controller.commit(commitment); + vm.warp(block.timestamp + controller.minCommitmentAge() + 1); + + // Move the current pointer to any registered version before revealing. + if (versions.length != 0) { + vm.prank(owner); + registry.setCurrentVersion(versions[seed % versions.length]); + } + + vm.deal(actor, committedPrice); + vm.prank(actor); + controller.register{value: committedPrice}(registration); + + assertFalse( + controller.available(label), "reveal must settle at the committed version and mint" + ); + } + + function _track(uint256 version) internal { + if (tracked[version]) return; + tracked[version] = true; + versions.push(version); + expectedAt7[version] = registry.priceForBaseLengthAtVersion(version, 7); + expectedAt9[version] = registry.priceForBaseLengthAtVersion(version, 9); + expectedAt12[version] = registry.priceForBaseLengthAtVersion(version, 12); + } + + /// @notice Produces a unique lowercase-letter label of base length eleven (NoStatus band). + function _uniqueLabel() internal returns (string memory label) { + uint256 n = labelNonce++; + bytes memory suffix = new bytes(4); + for (uint256 i = 0; i < 4; i++) { + suffix[3 - i] = bytes1(uint8(97 + uint8(n % 26))); + n /= 26; + } + return string.concat("invcost", string(suffix)); + } +} diff --git a/test/invariant/pop/CostModelVersionInvariant.t.sol b/test/invariant/pop/CostModelVersionInvariant.t.sol new file mode 100644 index 000000000..1405eb6bc --- /dev/null +++ b/test/invariant/pop/CostModelVersionInvariant.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {BaseDotns} from "../../base/BaseDotns.t.sol"; +import {CostModelVersionHandler} from "./CostModelVersionHandler.t.sol"; + +/// @title CostModelVersionInvariantTest +/// @notice Asserts the cost-model registry stays coherent under any sequence of model +/// registrations and current-version moves, including reverting to older versions. +contract CostModelVersionInvariantTest is BaseDotns { + /// @notice Handler driving model registration and version moves. Prices are ghost-tracked. + CostModelVersionHandler public handler; + + function setUp() public override { + super.setUp(); + handler = new CostModelVersionHandler( + costModelRegistry, popRules, dotnsRegistrarController, owner, ed + ); + targetContract(address(handler)); + + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = handler.registerModel.selector; + selectors[1] = handler.pointCurrent.selector; + selectors[2] = handler.bindMoveReveal.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + /// @notice Every registered version keeps returning the same amount for the life of the run, + /// because each model is immutable no matter how the current pointer moves. + function invariant_registered_versions_price_is_immutable() public view { + uint256 count = handler.versionCount(); + for (uint256 i = 0; i < count; i++) { + uint256 version = handler.versions(i); + assertEq( + costModelRegistry.priceForBaseLengthAtVersion(version, 7), + handler.expectedAt7(version), + "version price at 7 must not move" + ); + assertEq( + costModelRegistry.priceForBaseLengthAtVersion(version, 9), + handler.expectedAt9(version), + "version price at 9 must not move" + ); + assertEq( + costModelRegistry.priceForBaseLengthAtVersion(version, 12), + handler.expectedAt12(version), + "version price at 12 must not move" + ); + } + } + + /// @notice The current pointer always resolves to its registered model, and the fresh price + /// PopRules serves equals that model's amount. + function invariant_current_pointer_is_coherent() public view { + uint256 current = costModelRegistry.currentVersion(); + assertEq( + address(costModelRegistry.current()), + address(costModelRegistry.modelOf(current)), + "current must resolve to its registered model" + ); + + string memory label = "longnamehere"; + assertEq( + popRules.price(label), + costModelRegistry.priceForBaseLengthAtVersion(current, bytes(label).length), + "fresh price must equal the current model" + ); + } +} diff --git a/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol index bc9ed480b..70b4ae56e 100644 --- a/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol +++ b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol @@ -103,7 +103,7 @@ contract DotnsRegistrarControllerInvariantTest is BaseDotns { /// @notice Every unit of native value the escrow holds is accounted for by exactly one ledger. /// @dev The escrow's balance is the sum of three obligations: `tokenReserved`, the deposits - /// held against live positions; `insuranceFund`, the accumulated cross-tier fees; and + /// held against live positions; `protocolFees`, the accumulated cross-tier fees; and /// `_pendingWithdrawals`, the amounts credited to recipients and awaiting collection. /// Value only ever moves between these three, never into or out of the set, so their total /// tracks the balance exactly. @@ -120,8 +120,9 @@ contract DotnsRegistrarControllerInvariantTest is BaseDotns { /// The time-locked refund ledger is not part of this sum because this handler never /// credits it. `DotnsNameEscrowInvariant.invariant_solvency` covers all four ledgers. function invariant_value_conservation() public view { + // Escrow balance equals reserves + protocol fees + pending withdrawals. uint256 reservedAmount = dotnsNameEscrow.reserves(address(0)); - uint256 insurance = dotnsNameEscrow.insuranceFund(); + uint256 protocolFees = dotnsNameEscrow.protocolFees(); address[] memory actorList = handler.getActors(); uint256 pendingTotal; @@ -132,8 +133,8 @@ contract DotnsRegistrarControllerInvariantTest is BaseDotns { uint256 escrowBalance = address(dotnsNameEscrow).balance; assertEq( escrowBalance, - reservedAmount + insurance + pendingTotal, - "Escrow balance must equal reserves + insurance + pending withdrawals" + reservedAmount + protocolFees + pendingTotal, + "Escrow balance must equal reserves + protocol fees + pending withdrawals" ); } diff --git a/test/invariant/registrar/RegistrarControllerHandler.t.sol b/test/invariant/registrar/RegistrarControllerHandler.t.sol index e6cf12a4b..edbb9816a 100644 --- a/test/invariant/registrar/RegistrarControllerHandler.t.sol +++ b/test/invariant/registrar/RegistrarControllerHandler.t.sol @@ -72,6 +72,11 @@ contract RegistrarControllerHandler is Test { /// @notice Owners of reserved registrations (same index as `_reservedLabels`). address[] internal _reservedOwners; + /// @notice Labels whose original owner holds a forward-confirmed reverse record pointing at + /// them. Transferring such a label away would clear that owner's reverse resolution, so + /// the transfer actions leave these names with their owner. + mapping(bytes32 labelhash => bool backsReverse) private _backsReverse; + /// @notice Commitments consumed by a successful registration. bytes32[] internal _consumedCommitments; @@ -142,6 +147,9 @@ contract RegistrarControllerHandler is Test { if (!_isActor[actor]) { _isActor[actor] = true; actors.push(actor); + // Fund the actor so the paid registration path is exercised rather than reverting on a + // zero balance. Far above any registration deposit, so it never runs dry over a run. + vm.deal(actor, 1_000_000_000 ether); } actorStatus[actor] = status; @@ -185,7 +193,12 @@ contract RegistrarControllerHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: actor, secret: secret, reserved: reserved + label: label, + owner: actor, + secret: secret, + reserved: reserved, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -201,6 +214,10 @@ contract RegistrarControllerHandler is Test { // Get price and register uint256 price = popRules.priceWithCheck(label, actor).price; + // The controller sets a default reverse record for a reserved self-registration only when + // the owner has none, so only this first name becomes the owner's reverse entry. + bool setsReverse = reserved && bytes(reverseResolver.nameOf(actor)).length == 0; + vm.prank(actor); controller.register{value: price}(registration); @@ -212,9 +229,10 @@ contract RegistrarControllerHandler is Test { labelRegistered[keccak256(bytes(label))] = true; ++registrationCount; - if (reserved) { + if (setsReverse) { _reservedLabels.push(label); _reservedOwners.push(actor); + _backsReverse[keccak256(bytes(label))] = true; } } @@ -231,7 +249,12 @@ contract RegistrarControllerHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: actor, secret: secret, reserved: true + label: label, + owner: actor, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -264,7 +287,12 @@ contract RegistrarControllerHandler is Test { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: actor, secret: secret, reserved: reserved + label: label, + owner: actor, + secret: secret, + reserved: reserved, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); @@ -277,6 +305,10 @@ contract RegistrarControllerHandler is Test { uint256 price = popRules.priceWithCheck(label, actor).price; uint256 balanceBefore = actor.balance; + // The controller sets a default reverse record for a reserved self-registration only when + // the owner has none, so only this first name becomes the owner's reverse entry. + bool setsReverse = reserved && bytes(reverseResolver.nameOf(actor)).length == 0; + vm.prank(actor); controller.register{value: price + overpayment}(registration); @@ -291,9 +323,10 @@ contract RegistrarControllerHandler is Test { labelRegistered[keccak256(bytes(label))] = true; ++registrationCount; - if (reserved) { + if (setsReverse) { _reservedLabels.push(label); _reservedOwners.push(actor); + _backsReverse[keccak256(bytes(label))] = true; } } @@ -349,6 +382,9 @@ contract RegistrarControllerHandler is Test { uint256 index = registrationSeed % _registeredLabels.length; string memory label = _registeredLabels[index]; + // Leave a reverse-backing name with its owner: moving it clears their forward-confirmed + // reverse resolution, which the reserved-name invariant relies on. + if (_backsReverse[keccak256(bytes(label))]) return; address currentOwner = _registeredOwners[index]; address recipient = _pickDifferentActor(currentOwner, recipientSeed); @@ -379,6 +415,9 @@ contract RegistrarControllerHandler is Test { uint256 index = registrationSeed % _registeredLabels.length; string memory label = _registeredLabels[index]; + // Leave a reverse-backing name with its owner: moving it clears their forward-confirmed + // reverse resolution, which the reserved-name invariant relies on. + if (_backsReverse[keccak256(bytes(label))]) return; address currentOwner = _registeredOwners[index]; bytes32 labelhash = keccak256(bytes(label)); @@ -438,10 +477,28 @@ contract RegistrarControllerHandler is Test { /// @dev Uses incrementing nonce to ensure uniqueness across calls. /// @return label A unique label string with minimum 3 characters. function _generateUniqueLabel() internal returns (string memory label) { - label = string(abi.encodePacked("name", vm.toString(labelNonce))); + // A NoStatus-eligible label: all lowercase letters and eleven or more characters, so it + // carries no trailing digits, sits in the open band, and any funded actor can register it. + // A digit-suffixed or short label would classify as reserved or rejected and the register + // call would revert before it exercised anything. + label = string(abi.encodePacked("reginvname", _uniqueAlpha(labelNonce))); ++labelNonce; } + /// @notice Encodes `n` as a non-empty base-26 lowercase-letter string, so successive nonces + /// yield distinct all-letter suffixes. + /// @param n Value to encode. + /// @return alpha Lowercase-letter encoding of `n`. + function _uniqueAlpha(uint256 n) internal pure returns (string memory alpha) { + bytes memory buf; + uint256 value = n; + do { + buf = abi.encodePacked(bytes1(uint8(97 + (value % 26))), buf); + value /= 26; + } while (value > 0); + alpha = string(buf); + } + /// @notice Removes a commitment from the active commitments array. /// @param commitment The commitment hash to remove. function _removeActiveCommitment(bytes32 commitment) internal { diff --git a/test/invariant/registry/RegistryHandler.t.sol b/test/invariant/registry/RegistryHandler.t.sol index c23b0e719..7f27c358b 100644 --- a/test/invariant/registry/RegistryHandler.t.sol +++ b/test/invariant/registry/RegistryHandler.t.sol @@ -224,7 +224,12 @@ contract RegistryHandler is Test { keccak256(abi.encodePacked(label, domainOwner, block.timestamp, labelNonce)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: domainOwner, secret: secret, reserved: true + label: label, + owner: domainOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = controller.makeCommitment(registration); diff --git a/test/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index 06496ea17..20d2cc355 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -33,8 +33,8 @@ contract GhostNft is ERC721 { /// @custom:contract DotnsNameEscrow, plus the pull-payment and solvency guarantees. contract DotnsNameEscrowTest is BaseDotns { /// @notice Default label used across most tests. - /// @dev 14-char label that classifies as NoStatus; the flat deposit equals RENT_PRICE. - string internal constant LABEL = "longerlabela01"; + /// @dev 14-char label that classifies as NoStatus; the flat deposit equals BASE_DEPOSIT. + string internal constant LABEL = "labelnine01"; /// @notice Register `label` for `nameOwner` under the NoStatus PoP tier and return its tokenId. function _registerNoStatus( @@ -77,7 +77,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(pos.amount, RENT_PRICE, "amount should equal RENT_PRICE"); + assertEq(pos.amount, BASE_DEPOSIT, "amount should equal BASE_DEPOSIT"); assertEq(pos.asset, address(0), "asset should be native (address(0))"); assertFalse(pos.released, "released should be false"); assertFalse(pos.claimed, "claimed should be false"); @@ -112,20 +112,20 @@ contract DotnsNameEscrowTest is BaseDotns { // call claimWithdrawal to actually receive the funds. assertEq( dotnsNameEscrow.pendingWithdrawal(ed), - RENT_PRICE, + BASE_DEPOSIT, "pending withdrawal should be credited" ); vm.prank(ed); dotnsNameEscrow.claimWithdrawal(); - assertEq(ed.balance, balanceBefore + RENT_PRICE, "ed should receive RENT_PRICE refund"); + assertEq(ed.balance, balanceBefore + BASE_DEPOSIT, "ed should receive BASE_DEPOSIT refund"); IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); assertTrue(pos.claimed, "claimed should be true"); uint256 reservesAfter = dotnsNameEscrow.reserves(address(0)); - assertEq(reservesAfter, reservesBefore - RENT_PRICE, "reserves should be decremented"); + assertEq(reservesAfter, reservesBefore - BASE_DEPOSIT, "reserves should be decremented"); } function test_reclaim_transfers_custody_to_new_owner() public { @@ -178,22 +178,21 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq(ghost.ownerOf(ghostId), ed, "ghost NFT must remain with the original owner"); } - function test_pop_full_name_releases_through_zero_amount_position() public { - string memory popLabel = "popfullname"; + function test_self_registration_seeds_funded_position() public { + string memory popLabel = "selfregis"; bytes32 node = _register(popLabel, ed, IPopRules.PopStatus.PopFull); uint256 tokenId = uint256(node); vm.prank(ed); dotnsRegistrar.approve(address(dotnsNameEscrow), tokenId); - // Zero-priced PopFull mint still seeds a position so release/withdraw stay reachable. vm.prank(ed); dotnsNameEscrow.release(tokenId); IDotnsNameEscrow.ReleasePosition memory position = dotnsNameEscrow.getReleasePosition(tokenId); - assertTrue(position.released, "PopFull mint must be releasable"); - assertEq(position.amount, 0, "zero-priced mint seeds a zero-amount position"); + assertTrue(position.released, "self-registered name must be releasable"); + assertEq(position.amount, BASE_DEPOSIT, "self-registration locks a refundable deposit"); assertEq(position.recipient, ed, "position is bound to the registrant"); } @@ -268,14 +267,14 @@ contract DotnsNameEscrowTest is BaseDotns { function test_revert_deposit_already_funded() public { uint256 tokenId = _registerNoStatus(LABEL, ed); - vm.deal(address(dotnsRegistrarController), RENT_PRICE); + vm.deal(address(dotnsRegistrarController), BASE_DEPOSIT); vm.prank(address(dotnsRegistrarController)); vm.expectRevert( abi.encodeWithSelector(IDotnsNameEscrow.PositionAlreadyFunded.selector, tokenId) ); - dotnsNameEscrow.deposit{value: RENT_PRICE}( + dotnsNameEscrow.deposit{value: BASE_DEPOSIT}( IDotnsNameEscrow.DepositParams({ - tokenId: tokenId, asset: address(0), amount: RENT_PRICE, recipient: ed + tokenId: tokenId, asset: address(0), amount: BASE_DEPOSIT, recipient: ed }) ); } @@ -327,7 +326,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory before = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(before.recipient, ed); - assertEq(before.amount, RENT_PRICE); + assertEq(before.amount, BASE_DEPOSIT); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); assertEq(quotedFee, 0, "same-tier NoStatus transfer should be free"); @@ -350,7 +349,7 @@ contract DotnsNameEscrowTest is BaseDotns { leonardo, "position must rebind to the new holder when the NFT leaves the depositor" ); - assertEq(afterTransfer.amount, RENT_PRICE, "deposit must travel with the name"); + assertEq(afterTransfer.amount, BASE_DEPOSIT, "deposit must travel with the name"); assertEq( dotnsNameEscrow.reserves(address(0)), @@ -369,7 +368,7 @@ contract DotnsNameEscrowTest is BaseDotns { ); } - function test_zero_amount_position_rebinds_to_new_holder_on_transfer() public { + function test_funded_position_rebinds_to_new_holder_on_transfer() public { string memory label = BASE_LABEL_A; _grantPopFull(ed); @@ -378,11 +377,11 @@ contract DotnsNameEscrowTest is BaseDotns { uint256 tokenId = _tokenIdForLabel(label); IDotnsNameEscrow.ReleasePosition memory before = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(before.recipient, ed, "zero position starts with registrant"); - assertEq(before.amount, 0, "PopFull registration has no refundable deposit"); + assertEq(before.recipient, ed, "funded position starts with registrant"); + assertEq(before.amount, BASE_DEPOSIT, "self-registration locks a refundable deposit"); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); - assertEq(quotedFee, 0, "same-tier PopFull transfer should be free"); + assertEq(quotedFee, 0, "same-tier transfer costs nothing"); uint256 edRefundsBefore = dotnsNameEscrow.pendingRefundCount(ed); uint256 leonardoRefundsBefore = dotnsNameEscrow.pendingRefundCount(leonardo); @@ -392,9 +391,9 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory afterTransfer = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(afterTransfer.recipient, leonardo, "zero marker must follow current holder"); - assertEq(afterTransfer.amount, 0, "no deposit may be created on marker rebind"); - assertEq(dotnsNameEscrow.pendingRefundCount(ed), edRefundsBefore, "no refund for marker"); + assertEq(afterTransfer.recipient, leonardo, "position must follow the current holder"); + assertEq(afterTransfer.amount, BASE_DEPOSIT, "deposit travels with the name on rebind"); + assertEq(dotnsNameEscrow.pendingRefundCount(ed), edRefundsBefore, "no refund on rebind"); assertEq( dotnsNameEscrow.pendingRefundCount(leonardo), leonardoRefundsBefore, @@ -411,11 +410,11 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq( dotnsRegistrar.ownerOf(tokenId), address(dotnsNameEscrow), "escrow owns released NFT" ); - assertTrue(afterRelease.released, "new holder can release transferred zero marker"); + assertTrue(afterRelease.released, "new holder can release the transferred name"); assertEq(afterRelease.recipient, leonardo, "release recipient is the current holder"); } - function test_PopFull_to_PopLite_on_PopLite_tier_name_pays_D() public { + function test_downgrade_transfer_pays_name_price() public { string memory liteLabel = "lights01"; _grantPopFull(ed); @@ -424,42 +423,42 @@ contract DotnsNameEscrowTest is BaseDotns { _commitAndRegister(liteLabel, ed, false); uint256 tokenId = _tokenIdForLabel(liteLabel); - uint256 startingPrice = popRules.startingPrice(); + uint256 ownPrice = popRules.price(liteLabel); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); - assertEq(quotedFee, startingPrice, "holder-downgrade should fire at D"); + assertEq(quotedFee, ownPrice, "downgrade re-prices at the name's own length"); - uint256 priorInsurance = dotnsNameEscrow.insuranceFund(); + uint256 priorProtocolFees = dotnsNameEscrow.protocolFees(); vm.deal(ed, quotedFee); vm.prank(ed); dotnsRegistrar.transferFrom{value: quotedFee}(ed, leonardo, tokenId); assertEq( - dotnsNameEscrow.insuranceFund() - priorInsurance, - startingPrice, - "downgrade friction must settle to insurance" + dotnsNameEscrow.protocolFees() - priorProtocolFees, + ownPrice, + "downgrade fee settles to protocol fees" ); } function test_transfer_charges_friction_and_rebinds_position() public { // Downward cross-tier transfer of a funded NoStatus name: the friction fee settles to - // insurance and the deposit travels with the NFT. There is no transfer-time refund: + // protocol fees and the deposit travels with the NFT. There is no transfer-time refund: // `position.recipient` rebinds to the new holder, the locked deposit follows, and only // the new holder can later release into escrow. Promoting `ed` to PopFull before the - // transfer forces `PopRules.transferFloor` to return `startingPrice` while the position - // still carries the original `RENT_PRICE` deposit, so both legs of `chargeTransferFee` + // transfer forces `PopRules.transferFloor` to return the base fee D while the position + // still carries the original `BASE_DEPOSIT` deposit, so both legs of `chargeTransferFee` // run in one call. uint256 tokenId = _registerNoStatus(LABEL, ed); _grantPopFull(ed); - uint256 startingPrice = popRules.startingPrice(); + uint256 startingPrice = popRules.price(LABEL); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); assertEq(quotedFee, startingPrice, "PopFull holder downgrading to NoStatus pays D"); uint256 reservesBefore = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceBefore = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesBefore = dotnsNameEscrow.protocolFees(); uint256 edRefundsBefore = dotnsNameEscrow.pendingRefundCount(ed); uint256 leonardoRefundsBefore = dotnsNameEscrow.pendingRefundCount(leonardo); @@ -469,7 +468,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(pos.recipient, leonardo, "position must rebind to the new holder"); - assertEq(pos.amount, RENT_PRICE, "deposit amount untouched on rebind"); + assertEq(pos.amount, BASE_DEPOSIT, "deposit amount untouched on rebind"); assertEq( dotnsNameEscrow.reserves(address(0)), @@ -477,9 +476,9 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves must not move when the deposit follows the NFT" ); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, startingPrice, - "friction fee settles to insurance independently of the deposit" + "friction fee settles to protocol fees independently of the deposit" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -504,24 +503,24 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory before = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(before.recipient, ed, "precondition: position bound to ed"); - assertEq(before.amount, RENT_PRICE, "precondition: deposit at RENT_PRICE"); + assertEq(before.amount, BASE_DEPOSIT, "precondition: deposit at BASE_DEPOSIT"); uint256 reservesBefore = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceBefore = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesBefore = dotnsNameEscrow.protocolFees(); uint256 refundCountBefore = dotnsNameEscrow.pendingRefundCount(ed); - uint256 fee = popRules.startingPrice(); + uint256 fee = popRules.price(LABEL); vm.deal(address(dotnsRegistrar), fee); vm.prank(address(dotnsRegistrar)); dotnsNameEscrow.chargeTransferFee{value: fee}( IDotnsNameEscrow.ChargeTransferFeeParams({ - tokenId: tokenId, reachFloor: fee, payer: leonardo, to: ed + tokenId: tokenId, transferFee: fee, payer: leonardo, to: ed }) ); IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(pos.recipient, ed, "position recipient untouched when to == recipient"); - assertEq(pos.amount, RENT_PRICE, "deposit amount untouched when to == recipient"); + assertEq(pos.amount, BASE_DEPOSIT, "deposit amount untouched when to == recipient"); assertEq( dotnsNameEscrow.reserves(address(0)), @@ -529,9 +528,9 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves unchanged when no refund fires" ); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, fee, - "fee leg still settles to insurance" + "fee leg still settles to protocol fees" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -552,7 +551,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(pos.recipient, leonardo, "position recipient must follow the NFT"); - assertEq(pos.amount, RENT_PRICE, "deposit amount untouched on rebind"); + assertEq(pos.amount, BASE_DEPOSIT, "deposit amount untouched on rebind"); assertEq( dotnsNameEscrow.pendingRefundCount(ed), 0, @@ -595,7 +594,7 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq( dotnsNameEscrow.pendingWithdrawal(leonardo), - RENT_PRICE, + BASE_DEPOSIT, "deposit lands on the new holder's pull-payment ledger" ); @@ -603,8 +602,10 @@ contract DotnsNameEscrowTest is BaseDotns { vm.prank(leonardo); uint256 claimed = dotnsNameEscrow.claimWithdrawal(); - assertEq(claimed, RENT_PRICE, "claim returns the full deposit"); - assertEq(leonardo.balance - balanceBefore, RENT_PRICE, "current holder receives the refund"); + assertEq(claimed, BASE_DEPOSIT, "claim returns the full deposit"); + assertEq( + leonardo.balance - balanceBefore, BASE_DEPOSIT, "current holder receives the refund" + ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), 0, @@ -621,7 +622,7 @@ contract DotnsNameEscrowTest is BaseDotns { uint256 tokenId = _registerNoStatus(LABEL, ed); uint256 reservesAtStart = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceAtStart = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesAtStart = dotnsNameEscrow.protocolFees(); uint256 edRefundsAtStart = dotnsNameEscrow.pendingRefundCount(ed); uint256 leonardoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(leonardo); uint256 tiagoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(tiago); @@ -632,7 +633,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory midPos = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(midPos.recipient, leonardo, "position rebinds to leonardo on the first hop"); - assertEq(midPos.amount, RENT_PRICE, "deposit amount untouched on the first hop"); + assertEq(midPos.amount, BASE_DEPOSIT, "deposit amount untouched on the first hop"); // Same-tier free transfer; quote must agree this is a zero-fee move that still rebinds. uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, tiago); @@ -646,7 +647,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory tailPos = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(tailPos.recipient, tiago, "position rebinds again to tiago on the second hop"); - assertEq(tailPos.amount, RENT_PRICE, "deposit amount untouched on the second hop"); + assertEq(tailPos.amount, BASE_DEPOSIT, "deposit amount untouched on the second hop"); assertEq( dotnsNameEscrow.reserves(address(0)), @@ -654,9 +655,9 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves must stay put while the deposit follows the NFT" ); assertEq( - dotnsNameEscrow.insuranceFund(), - insuranceAtStart, - "same-tier hops must not credit insurance" + dotnsNameEscrow.protocolFees(), + protocolFeesAtStart, + "same-tier hops must not credit protocol fees" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -697,7 +698,12 @@ contract DotnsNameEscrowTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(label, nameOwner, block.timestamp, payer)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: nameOwner, secret: secret, reserved: true + label: label, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -718,35 +724,35 @@ contract DotnsNameEscrowTest is BaseDotns { /// @dev `priced.price` equals D because the owner is NoStatus, and `transferFloor` returns /// D because the payer's PopFull tier downgrades into the owner's NoStatus tier. The /// controller charges `max(priced.price, friction) = D` and routes the entire charge - /// into the insurance fund; the owner-side refundable position is seeded with zero + /// into protocol fees; the owner-side refundable position is seeded with zero /// amount, so reserves must not move. function test_cross_payer_verified_sponsors_nostatus_pays_only_D() public { - string memory label = "crosspayerlabel01"; + string memory label = "crosspayr01"; _grantPopFull(leonardo); // ed left at default NoStatus tier. - uint256 priorInsurance = dotnsNameEscrow.insuranceFund(); + uint256 priorProtocolFees = dotnsNameEscrow.protocolFees(); uint256 priorReserves = dotnsNameEscrow.reserves(address(0)); uint256 priorBalance = leonardo.balance; - uint256 tokenId = _crossPayerRegister(label, leonardo, ed, RENT_PRICE); + uint256 tokenId = _crossPayerRegister(label, leonardo, ed, BASE_DEPOSIT); - // Insurance must grow by exactly D and reserves must stay flat: the entire charge + // Protocol fees must grow by exactly D and reserves must stay flat: the entire charge // routes to the friction reserve on the cross-payer path under the max rule. assertEq( - dotnsNameEscrow.insuranceFund() - priorInsurance, - RENT_PRICE, - "insurance must grow by exactly D on cross-payer NoStatus sponsorship" + dotnsNameEscrow.protocolFees() - priorProtocolFees, + BASE_DEPOSIT, + "protocol fees must grow by exactly D on cross-payer NoStatus sponsorship" ); assertEq( dotnsNameEscrow.reserves(address(0)), priorReserves, - "reserves must not move when the cross-payer charge routes entirely to insurance" + "reserves must not move when the cross-payer charge routes entirely to protocol fees" ); assertEq( priorBalance - leonardo.balance, - RENT_PRICE, + BASE_DEPOSIT, "payer must be debited exactly D, never above" ); @@ -757,75 +763,75 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq(pos.amount, 0, "no refundable deposit seeded for cross-payer registration"); } - /// @notice Verified payer sponsoring a verified owner on a verified-tier label must pay - /// zero under the A1 max-not-sum rule. - /// @dev `priced.price` is zero because the owner is verified, and `transferFloor` is zero - /// because there is no downgrade and reach is met. The controller charges `max(0, 0) - /// = 0` and `_settleEscrow` skips the `depositInsurance` leg, leaving the insurance - /// fund and reserves untouched. - function test_cross_payer_verified_sponsors_verified_pays_zero() public { + /// @notice A cross-payer sponsoring a verified owner pays the name's own curve price into + /// protocol fees; personhood buys the owner access, not a free ride for the sponsor. + /// @dev `priced.price` is the curve price for the owner and `transferFloor` is at most that + /// same price, so the controller charges the owner-side price and `_settleEscrow` routes + /// it through `depositProtocolFee`, seeding a zero-amount refundable position. + function test_cross_payer_pays_the_curve_into_fees() public { string memory label = BASE_LABEL_A; _grantPopFull(leonardo); _grantPopFull(ed); - uint256 priorInsurance = dotnsNameEscrow.insuranceFund(); + uint256 ownPrice = popRules.price(label); + uint256 priorProtocolFees = dotnsNameEscrow.protocolFees(); uint256 priorReserves = dotnsNameEscrow.reserves(address(0)); uint256 priorBalance = leonardo.balance; - uint256 tokenId = _crossPayerRegister(label, leonardo, ed, 0); + uint256 tokenId = _crossPayerRegister(label, leonardo, ed, ownPrice); assertEq( - dotnsNameEscrow.insuranceFund(), - priorInsurance, - "insurance must not move when no charge is owed" + dotnsNameEscrow.protocolFees() - priorProtocolFees, + ownPrice, + "cross-payer charge settles to protocol fees" ); assertEq( dotnsNameEscrow.reserves(address(0)), priorReserves, - "reserves must not move when no deposit is seeded" + "reserves must not move on the cross-payer path" + ); + assertEq( + priorBalance - leonardo.balance, ownPrice, "payer is debited the cross-payer charge" ); - assertEq(leonardo.balance, priorBalance, "payer must not be debited when charge is zero"); - // The zero-amount position is still seeded so the release lifecycle stays reachable. IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(pos.recipient, ed, "position recipient must be the registrant"); - assertEq(pos.amount, 0, "no refundable deposit seeded when the cross-payer charge is zero"); + assertEq(pos.amount, 0, "cross-payer registration seeds no refundable deposit"); } - /// @notice Defensive coverage for the cross-payer branch where the owner-side price is - /// zero but the payer-to-owner downgrade friction is non-zero. - /// @dev Reaches the `friction > priced.price` arm of the A1 max rule: a PopFull payer - /// sponsoring a PopLite owner on a PopLite-tier label. The owner is verified for - /// the tier so `priced.price = 0`, but the payer's PopFull tier downgrades into the - /// owner's PopLite tier, so `transferFloor` returns D. The charge collapses to D and - /// the whole amount routes to insurance with no refundable deposit. - function test_cross_payer_friction_only_when_priced_owner_is_zero() public { + /// @notice A cross-payer whose tier downgrades into the owner's is still charged only the + /// name's own price, never the owner price plus the downgrade friction. + /// @dev The owner-side price is the curve price and the downgrade friction can at most equal + /// it, so `max(ownerPrice, friction)` collapses to the owner price. The whole charge + /// routes to protocol fees with no refundable deposit. + function test_cross_payer_downgrade_charges_only_owner_price() public { string memory label = LITE_LABEL_A; _grantPopFull(leonardo); _grantPopLite(ed); - uint256 priorInsurance = dotnsNameEscrow.insuranceFund(); + uint256 ownPrice = popRules.price(label); + uint256 priorProtocolFees = dotnsNameEscrow.protocolFees(); uint256 priorReserves = dotnsNameEscrow.reserves(address(0)); uint256 priorBalance = leonardo.balance; - uint256 tokenId = _crossPayerRegister(label, leonardo, ed, RENT_PRICE); + uint256 tokenId = _crossPayerRegister(label, leonardo, ed, ownPrice); assertEq( - dotnsNameEscrow.insuranceFund() - priorInsurance, - RENT_PRICE, - "downgrade-only friction must still settle the full D into insurance" + dotnsNameEscrow.protocolFees() - priorProtocolFees, + ownPrice, + "downgrade never exceeds the name's own price" ); assertEq( dotnsNameEscrow.reserves(address(0)), priorReserves, - "reserves must stay flat: no refundable deposit on the cross-payer path" + "reserves stay flat on the cross-payer path" ); assertEq( priorBalance - leonardo.balance, - RENT_PRICE, - "payer must be debited the friction-only charge, not the sum" + ownPrice, + "payer is debited the owner-side price, not the sum" ); IDotnsNameEscrow.ReleasePosition memory pos = dotnsNameEscrow.getReleasePosition(tokenId); @@ -859,7 +865,9 @@ contract DotnsNameEscrowTest is BaseDotns { dotnsNameEscrow.claimWithdrawal(); assertEq( - ed.balance, balanceBefore + RENT_PRICE, "withdraw should still transfer correct amount" + ed.balance, + balanceBefore + BASE_DEPOSIT, + "withdraw should still transfer correct amount" ); } } diff --git a/test/unit/escrow/DotnsNameEscrowRedeem.t.sol b/test/unit/escrow/DotnsNameEscrowRedeem.t.sol index ccd51b2f1..4f182c3c2 100644 --- a/test/unit/escrow/DotnsNameEscrowRedeem.t.sol +++ b/test/unit/escrow/DotnsNameEscrowRedeem.t.sol @@ -3,6 +3,9 @@ pragma solidity ^0.8.34; import {BaseDotns} from "../../base/BaseDotns.t.sol"; import {IDotnsNameEscrow} from "../../../contracts/escrow/IDotnsNameEscrow.sol"; +import { + IDotnsRegistrarController +} from "../../../contracts/registrars/IDotnsRegistrarController.sol"; import {IPopRules} from "../../../contracts/pop/IPopRules.sol"; /// @title DotnsNameEscrowRedeemTest @@ -11,14 +14,14 @@ import {IPopRules} from "../../../contracts/pop/IPopRules.sol"; /// and the deposit settlement that makes the second possible without stranding value. /// @dev The defect these cover: reclaim used to gate on the `claimed` flag, which is only set by /// `withdraw`. A holder who released a name and never withdrew removed the label from -/// circulation permanently. For the zero-amount positions seeded by free registrations there -/// is nothing to withdraw, so that was the default outcome rather than an edge case. +/// circulation permanently. For the zero-amount positions seeded by cross-payer registrations +/// there is nothing to withdraw, so that was the default outcome rather than an edge case. contract DotnsNameEscrowRedeemTest is BaseDotns { /// @notice 14-char label classifying as NoStatus, so registration seeds a funded position. string internal constant FUNDED_LABEL = "redeemlabela01"; - /// @notice 6-char digit-free label classifying as PopFull: registration is free, so the - /// position it seeds carries a zero amount. + /// @notice 6-char digit-free label classifying as PopFull, registered on the cross-payer path + /// so its deposit position carries a zero amount. string internal constant FREE_LABEL = "redeem"; /// @notice Register `label` for `nameOwner` at `status` and return its tokenId. @@ -34,6 +37,47 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { tokenId = _tokenIdForLabel(label); } + /// @notice Register `label` for `nameOwner` on the cross-payer path (`payer` pays, `nameOwner` + /// receives the name) and return its tokenId. + /// @dev A cross-payer registration routes the whole charge to the protocol fee pot and seeds a + /// zero-amount refundable position keyed to `nameOwner`, which is the registration shape + /// that produces a zero-amount position. `nameOwner` must carry `status`, because the + /// controller prices the owner's tier on this path. + function _registerCrossPayer( + string memory label, + address nameOwner, + address payer, + IPopRules.PopStatus status + ) + internal + returns (uint256 tokenId) + { + _setUserPopStatus(nameOwner, status); + + bytes32 secret = keccak256(abi.encodePacked(label, nameOwner, block.timestamp)); + IDotnsRegistrarController.Registration memory registration = + IDotnsRegistrarController.Registration({ + label: label, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() + }); + + bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); + vm.prank(payer); + dotnsRegistrarController.commit(commitment); + + vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); + + uint256 charge = popRules.priceWithoutCheck(label, nameOwner).price; + vm.prank(payer); + dotnsRegistrarController.register{value: charge}(registration); + + tokenId = _tokenIdForLabel(label); + } + /// @notice Approve the escrow for `tokenId` and release it as `caller`. function _approveAndRelease(uint256 tokenId, address caller) internal { vm.startPrank(caller); @@ -50,9 +94,7 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { position = dotnsNameEscrow.getReleasePosition(tokenId); } - // -------------------------------------------------------------------------------------- // release stamps both clocks - // -------------------------------------------------------------------------------------- function test_release_stamps_independent_withdraw_and_redeem_clocks() public { uint256 tokenId = _registerAt(FUNDED_LABEL, ed, IPopRules.PopStatus.NoStatus); @@ -142,9 +184,7 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { revert("redeemWindow slot not found; storage layout changed"); } - // -------------------------------------------------------------------------------------- // redeem: the previous holder's undo - // -------------------------------------------------------------------------------------- function test_redeem_returns_the_name_and_moves_no_value() public { uint256 tokenId = _registerAt(FUNDED_LABEL, ed, IPopRules.PopStatus.NoStatus); @@ -277,13 +317,12 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { } /// @dev The mirror of the case above, and the reason `claimed` is only set when value actually - /// moves. A free registration has nothing to withdraw, so `withdraw` pays the holder + /// moves. A zero-amount position has nothing to withdraw, so `withdraw` pays the holder /// nothing, if it still flagged the position claimed it would silently forfeit their - /// right to recover their own name for no consideration whatsoever. `withdraw` is also the - /// step the old contract required before a name could be recycled, so it is a call holders - /// have every reason to make. + /// right to recover their own name for no consideration whatsoever. `withdraw` is also a + /// step a holder has every reason to make before a name can be recycled. function test_zero_amount_withdrawal_does_not_forfeit_the_redeem_right() public { - uint256 tokenId = _registerAt(FREE_LABEL, ed, IPopRules.PopStatus.PopFull); + uint256 tokenId = _registerCrossPayer(FREE_LABEL, ed, leonardo, IPopRules.PopStatus.PopFull); assertEq(_positionOf(tokenId).amount, 0, "this case needs a zero-amount position"); _approveAndRelease(tokenId, ed); @@ -309,9 +348,7 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { ); } - // -------------------------------------------------------------------------------------- // reclaim: permissionless once the window elapses - // -------------------------------------------------------------------------------------- function test_revert_reclaim_while_inside_the_redeem_window() public { uint256 tokenId = _registerAt(FUNDED_LABEL, ed, IPopRules.PopStatus.NoStatus); @@ -395,17 +432,20 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { ); } - // -------------------------------------------------------------------------------------- // the zero-amount case: what the bug actually was - // -------------------------------------------------------------------------------------- - /// @dev The headline regression test. A free registration seeds a zero-amount position, so its - /// holder has nothing to withdraw and therefore no reason ever to call `withdraw`. Under - /// the old `released && claimed` gate that made the name permanently unregisterable. + /// @dev The headline regression test. A cross-payer registration seeds a zero-amount position, + /// so its holder has nothing to withdraw and therefore no reason ever to call `withdraw`. + /// Under a `released && claimed` reclaim gate that would make the name permanently + /// unregisterable. function test_zero_amount_release_becomes_reclaimable_without_any_withdrawal() public { - uint256 tokenId = _registerAt(FREE_LABEL, ed, IPopRules.PopStatus.PopFull); + uint256 tokenId = _registerCrossPayer(FREE_LABEL, ed, leonardo, IPopRules.PopStatus.PopFull); - assertEq(_positionOf(tokenId).amount, 0, "a free registration seeds a zero-amount position"); + assertEq( + _positionOf(tokenId).amount, + 0, + "a cross-payer registration seeds a zero-amount position" + ); _approveAndRelease(tokenId, ed); @@ -437,9 +477,7 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { assertEq(address(dotnsNameEscrow).balance, escrowBalanceBefore, "and moves no value"); } - // -------------------------------------------------------------------------------------- // governance: updateRedeemWindow - // -------------------------------------------------------------------------------------- function test_updateRedeemWindow_sets_the_value_and_emits() public { uint256 current = dotnsNameEscrow.redeemWindow(); diff --git a/test/unit/pop/DotnsCostModelRegistry.t.sol b/test/unit/pop/DotnsCostModelRegistry.t.sol new file mode 100644 index 000000000..3b206bf4b --- /dev/null +++ b/test/unit/pop/DotnsCostModelRegistry.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {DotnsCostModelRegistry} from "../../../contracts/pop/DotnsCostModelRegistry.sol"; +import {IDotnsCostModelRegistry} from "../../../contracts/pop/IDotnsCostModelRegistry.sol"; +import {DotnsScarcityPricing} from "../../../contracts/pop/DotnsScarcityPricing.sol"; +import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; + +/// @title ZeroVersionModel +/// @notice Minimal cost model whose version identifier is zero, used to exercise the registry's +/// zero-version guard. +contract ZeroVersionModel is IDotnsPricing { + function priceForBaseLength(uint256) external pure override returns (uint256 weiPrice) { + return 1 ether; + } + + function version() external pure override returns (uint256 modelVersion) { + return 0; + } +} + +/// @title DotnsCostModelRegistryTests +/// @notice Unit tests for model registration, current-version tracking, and versioned pricing. +contract DotnsCostModelRegistryTests is Test { + uint256 internal constant BASE_FEE = 10 ether; + uint256 internal constant FLOOR = 0.1 ether; + + address internal owner; + DotnsCostModelRegistry internal registry; + DotnsScarcityPricing internal modelA; + DotnsScarcityPricing internal modelB; + + function setUp() public { + owner = makeAddr("owner"); + registry = new DotnsCostModelRegistry(owner); + modelA = new DotnsScarcityPricing(BASE_FEE, FLOOR); + modelB = new DotnsScarcityPricing(BASE_FEE * 2, FLOOR); + + vm.prank(owner); + registry.register(IDotnsPricing(address(modelA))); + } + + function test_register_sets_current_version_and_model() public view { + assertEq(registry.currentVersion(), modelA.version()); + assertEq(address(registry.current()), address(modelA)); + assertEq(registry.priceForBaseLength(9), BASE_FEE); + } + + function test_register_only_owner() public { + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)) + ); + registry.register(IDotnsPricing(address(modelB))); + } + + function test_register_reverts_on_duplicate_version() public { + uint256 versionA = modelA.version(); + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IDotnsCostModelRegistry.AlreadyRegistered.selector, versionA) + ); + registry.register(IDotnsPricing(address(modelA))); + } + + function test_second_model_moves_current_but_keeps_old_priceable() public { + vm.prank(owner); + registry.register(IDotnsPricing(address(modelB))); + + // Current now serves model B. + assertEq(registry.currentVersion(), modelB.version()); + assertEq(registry.priceForBaseLength(9), BASE_FEE * 2); + + // Both versions stay priceable by version. + assertEq(registry.priceForBaseLengthAtVersion(modelA.version(), 9), BASE_FEE); + assertEq(registry.priceForBaseLengthAtVersion(modelB.version(), 9), BASE_FEE * 2); + } + + function test_unknown_version_reverts() public { + uint256 unknown = uint256(keccak256("no such version")); + vm.expectRevert( + abi.encodeWithSelector(IDotnsCostModelRegistry.UnknownVersion.selector, unknown) + ); + registry.priceForBaseLengthAtVersion(unknown, 9); + } + + function test_setCurrentVersion_reverts_to_older_version() public { + DotnsScarcityPricing modelC = new DotnsScarcityPricing(BASE_FEE * 4, FLOOR); + vm.startPrank(owner); + registry.register(IDotnsPricing(address(modelB))); + registry.register(IDotnsPricing(address(modelC))); + // Point current back at the first version. + registry.setCurrentVersion(modelA.version()); + vm.stopPrank(); + + assertEq(registry.currentVersion(), modelA.version()); + assertEq(address(registry.current()), address(modelA)); + assertEq(registry.priceForBaseLength(9), BASE_FEE); + + // The other versions stay queryable at their own amounts. + assertEq(registry.priceForBaseLengthAtVersion(modelB.version(), 9), BASE_FEE * 2); + assertEq(registry.priceForBaseLengthAtVersion(modelC.version(), 9), BASE_FEE * 4); + } + + function test_setCurrentVersion_reverts_for_unknown_version() public { + uint256 unknown = uint256(keccak256("never registered")); + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IDotnsCostModelRegistry.UnknownVersion.selector, unknown) + ); + registry.setCurrentVersion(unknown); + } + + function test_setCurrentVersion_only_owner() public { + uint256 versionA = modelA.version(); + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)) + ); + registry.setCurrentVersion(versionA); + } + + function test_register_reverts_for_zero_version() public { + ZeroVersionModel zeroModel = new ZeroVersionModel(); + vm.prank(owner); + vm.expectRevert(IDotnsCostModelRegistry.ZeroVersion.selector); + registry.register(IDotnsPricing(address(zeroModel))); + } + + function test_register_emits_cost_model_registered() public { + uint256 versionB = modelB.version(); + vm.expectEmit(true, true, false, false, address(registry)); + emit IDotnsCostModelRegistry.CostModelRegistered(versionB, address(modelB)); + vm.prank(owner); + registry.register(IDotnsPricing(address(modelB))); + } + + function test_setCurrentVersion_emits_current_model_set() public { + uint256 versionB = modelB.version(); + vm.prank(owner); + registry.register(IDotnsPricing(address(modelB))); + + uint256 versionA = modelA.version(); + vm.expectEmit(true, false, false, false, address(registry)); + emit IDotnsCostModelRegistry.CurrentModelSet(versionA); + vm.prank(owner); + registry.setCurrentVersion(versionA); + } +} diff --git a/test/unit/pop/DotnsFlatPricing.t.sol b/test/unit/pop/DotnsFlatPricing.t.sol new file mode 100644 index 000000000..4a7b18d41 --- /dev/null +++ b/test/unit/pop/DotnsFlatPricing.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; + +import {DotnsFlatPricing} from "../../../contracts/pop/DotnsFlatPricing.sol"; +import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; + +/// @title DotnsFlatPricingTests +/// @notice Unit tests for the flat launch model, its zero-deposit guard, and its version +/// identifier. +contract DotnsFlatPricingTests is Test { + uint256 internal constant DEPOSIT = 10 ether; + + DotnsFlatPricing internal pricing; + + function setUp() public { + pricing = new DotnsFlatPricing(DEPOSIT); + } + + function test_prices_every_base_length_at_the_deposit() public view { + assertEq(pricing.priceForBaseLength(0), DEPOSIT); + assertEq(pricing.priceForBaseLength(6), DEPOSIT); + assertEq(pricing.priceForBaseLength(9), DEPOSIT); + assertEq(pricing.priceForBaseLength(40), DEPOSIT); + } + + function testFuzz_price_is_constant(uint256 baseLength) public view { + assertEq(pricing.priceForBaseLength(baseLength), DEPOSIT); + } + + function test_constructor_reverts_for_zero_deposit() public { + vm.expectRevert( + abi.encodeWithSelector( + IDotnsPricing.PricingError.selector, "Deposit must be greater than 0" + ) + ); + new DotnsFlatPricing(0); + } + + function test_version_is_stable_for_the_same_deposit() public { + DotnsFlatPricing twin = new DotnsFlatPricing(DEPOSIT); + assertEq(pricing.version(), twin.version()); + } + + function test_version_changes_with_the_deposit() public { + DotnsFlatPricing other = new DotnsFlatPricing(DEPOSIT * 2); + assertTrue(pricing.version() != other.version()); + } + + function test_version_differs_from_another_model_form() public view { + // The form identifier is mixed into the version, so a different model shape cannot collide + // with the flat model at the same amount. + uint256 scarcityFormId = uint256(keccak256("dotns.pricing.scarcity.v1")); + assertTrue(pricing.version() != uint256(keccak256(abi.encode(scarcityFormId, DEPOSIT)))); + } +} diff --git a/test/unit/pop/DotnsScarcityPricing.t.sol b/test/unit/pop/DotnsScarcityPricing.t.sol new file mode 100644 index 000000000..93ca56c25 --- /dev/null +++ b/test/unit/pop/DotnsScarcityPricing.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; + +import {DotnsScarcityPricing} from "../../../contracts/pop/DotnsScarcityPricing.sol"; +import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; + +/// @title DotnsScarcityPricingTests +/// @notice Unit tests for the scarcity curve, its constructor guards, and its version identifier. +contract DotnsScarcityPricingTests is Test { + uint256 internal constant BASE_FEE = 10 ether; + uint256 internal constant FLOOR = 0.1 ether; + + DotnsScarcityPricing internal pricing; + + function setUp() public { + pricing = new DotnsScarcityPricing(BASE_FEE, FLOOR); + } + + function test_pivot_at_nine_is_base_fee() public view { + assertEq(pricing.priceForBaseLength(9), BASE_FEE); + } + + function test_doubles_below_nine() public view { + assertEq(pricing.priceForBaseLength(8), BASE_FEE * 2); + assertEq(pricing.priceForBaseLength(7), BASE_FEE * 4); + // Base length 0 (an all-digit label) reaches the 2**9 multiplier. + assertEq(pricing.priceForBaseLength(0), BASE_FEE * 512); + } + + function test_halves_above_nine() public view { + assertEq(pricing.priceForBaseLength(10), BASE_FEE / 2); + assertEq(pricing.priceForBaseLength(11), BASE_FEE / 4); + } + + function test_floor_binds_for_long_names() public view { + // Far enough up the curve that the halved amount falls below the floor. + assertEq(pricing.priceForBaseLength(40), FLOOR); + } + + function test_constructor_reverts_for_zero_base_fee() public { + vm.expectRevert( + abi.encodeWithSelector( + IDotnsPricing.PricingError.selector, "Base fee must be greater than 0" + ) + ); + new DotnsScarcityPricing(0, FLOOR); + } + + function test_constructor_reverts_for_zero_floor() public { + vm.expectRevert( + abi.encodeWithSelector( + IDotnsPricing.PricingError.selector, "Floor must be greater than 0" + ) + ); + new DotnsScarcityPricing(BASE_FEE, 0); + } + + function test_constructor_reverts_when_floor_exceeds_base_fee() public { + vm.expectRevert( + abi.encodeWithSelector( + IDotnsPricing.PricingError.selector, "Floor cannot exceed the base fee" + ) + ); + new DotnsScarcityPricing(BASE_FEE, BASE_FEE + 1); + } + + function test_constructor_reverts_above_ceiling() public { + uint256 tooHigh = type(uint256).max / 512 + 1; + vm.expectRevert( + abi.encodeWithSelector( + IDotnsPricing.PricingError.selector, "Base fee exceeds the scarcity-curve ceiling" + ) + ); + new DotnsScarcityPricing(tooHigh, FLOOR); + } + + function test_ceiling_boundary_is_exactly_safe() public { + // At the exact ceiling the base length 0 multiplier of 512 must still not overflow. + uint256 ceiling = type(uint256).max / 512; + DotnsScarcityPricing atCeiling = new DotnsScarcityPricing(ceiling, FLOOR); + assertEq(atCeiling.priceForBaseLength(0), ceiling * 512); + } + + function test_version_stable_on_identical_redeploy() public { + DotnsScarcityPricing twin = new DotnsScarcityPricing(BASE_FEE, FLOOR); + assertEq(pricing.version(), twin.version()); + } + + function test_version_differs_on_differing_params() public { + DotnsScarcityPricing otherFee = new DotnsScarcityPricing(BASE_FEE * 2, FLOOR); + DotnsScarcityPricing otherFloor = new DotnsScarcityPricing(BASE_FEE, FLOOR * 2); + assertTrue(pricing.version() != otherFee.version()); + assertTrue(pricing.version() != otherFloor.version()); + } +} diff --git a/test/unit/pop/PopRules.t.sol b/test/unit/pop/PopRules.t.sol index 99b119691..25130feb9 100644 --- a/test/unit/pop/PopRules.t.sol +++ b/test/unit/pop/PopRules.t.sol @@ -2,8 +2,16 @@ pragma solidity ^0.8.34; import {BaseDotns} from "../../base/BaseDotns.t.sol"; -import {IPopRules} from "../../../contracts/pop/IPopRules.sol"; +import {PopRules, IPopRules} from "../../../contracts/pop/PopRules.sol"; import {IDotnsController} from "../../../contracts/registrars/IDotnsController.sol"; +import { + DotnsProtocolRegistry, + IDotnsProtocolRegistry +} from "../../../contracts/registry/DotnsProtocolRegistry.sol"; +import {Upgrades} from "openzeppelin-foundry-upgrades/Upgrades.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title PopRulesTests /// @notice Unit tests for PopRules name classification, pricing checks, and base-name reservation @@ -69,18 +77,22 @@ contract PopRulesTests is BaseDotns { popRules.classifyName("andrew123"); } - function test_flat_price_does_not_scale_with_length() public view { - // Three NoStatus labels across the previously-tiered length bands (9, 12, 17 chars) - // must all price identically under the flat deposit. The prior curve charged - // `startingPrice * (15 - length)` for lengths 9-14 and `startingPrice / 2` for >=15, - // so any two of these three would have differed. - uint256 minLengthPrice = popRules.price("ninechars"); - uint256 midLengthPrice = popRules.price("longnamehere"); - uint256 longLengthPrice = popRules.price("thisisaverylongname"); + function test_trailing_digits_do_not_change_price() public view { + assertEq(popRules.price("andrew01"), popRules.price("andrew")); + } + + function test_verified_person_pays_the_deposit_for_premium() public { + _grantPopFull(ed); - assertEq(minLengthPrice, midLengthPrice); - assertEq(midLengthPrice, longLengthPrice); - assertGt(minLengthPrice, 0); + assertEq(popRules.priceWithCheck("alicebob", ed).price, BASE_DEPOSIT); + assertEq(popRules.priceWithCheck("lights", ed).price, BASE_DEPOSIT); + } + + function test_transfer_reprices_at_own_length() public { + _grantPopFull(leonardo); + + assertEq(popRules.transferFloor("lights", leonardo, tiago), BASE_DEPOSIT); + assertEq(popRules.transferFloor("lights", leonardo, leonardo), 0); } function test_price_with_check_revert_governance() public { @@ -93,7 +105,7 @@ contract PopRulesTests is BaseDotns { function test_price_with_check_revert_full_needed() public { vm.expectRevert( abi.encodeWithSelector( - IPopRules.PopError.selector, "Requires Full Personhood verification" + IPopRules.PopError.selector, "Requires Full personhood verification" ) ); popRules.priceWithCheck("alicebob", ed); @@ -106,6 +118,7 @@ contract PopRulesTests is BaseDotns { assertEq(uint256(priceMetadata.status), uint256(IPopRules.PopStatus.PopLite)); assertEq(uint256(priceMetadata.userStatus), uint256(IPopRules.PopStatus.PopFull)); + assertEq(priceMetadata.price, BASE_DEPOSIT); } function test_poplite_user_can_access_nostatus_name() public { @@ -115,6 +128,7 @@ contract PopRulesTests is BaseDotns { assertEq(uint256(priceMetadata.status), uint256(IPopRules.PopStatus.NoStatus)); assertEq(uint256(priceMetadata.userStatus), uint256(IPopRules.PopStatus.PopLite)); + assertEq(priceMetadata.price, BASE_DEPOSIT); } function test_base_reservation_blocks_others() public { @@ -151,6 +165,103 @@ contract PopRulesTests is BaseDotns { assertEq(priceMetadata.price, popRules.price("lights")); } + function test_short_names_closed_reverts_direct_path() public { + vm.prank(owner); + popRules.setShortNamesEnabled(false); + _grantPopFull(ed); + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Short names are not for sale") + ); + popRules.priceWithCheck("alicebob", ed); + } + + function test_short_names_closed_reverts_sponsored_path() public { + vm.prank(owner); + popRules.setShortNamesEnabled(false); + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Short names are not for sale") + ); + popRules.priceWithoutCheck("alicebob", ed); + } + + function test_open_band_priced_while_short_names_closed() public { + vm.prank(owner); + popRules.setShortNamesEnabled(false); + _grantPopLite(ed); + // longnamehere is 12 characters, so the switch never gates it. + assertEq(popRules.priceWithCheck("longnamehere", ed).price, BASE_DEPOSIT); + } + + function test_enabling_short_names_opens_the_market() public { + vm.prank(owner); + popRules.setShortNamesEnabled(false); + _grantPopFull(ed); + vm.prank(owner); + popRules.setShortNamesEnabled(true); + assertEq(popRules.priceWithCheck("alicebob", ed).price, BASE_DEPOSIT); + } + + function test_setShortNamesEnabled_emits() public { + vm.expectEmit(address(popRules)); + emit IPopRules.ShortNamesEnabledUpdated(false); + vm.prank(owner); + popRules.setShortNamesEnabled(false); + } + + function test_setShortNamesEnabled_only_owner() public { + vm.prank(ed); + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, ed) + ); + popRules.setShortNamesEnabled(false); + } + + function test_price_matches_model() public view { + string memory label = "thisisaverylongname"; + uint256 baseLength = bytes(label).length; + assertEq(popRules.price(label), flatPricing.priceForBaseLength(baseLength)); + assertEq(popRules.price(label), costModelRegistry.priceForBaseLength(baseLength)); + } + + function test_priceWithCheck_matches_model() public view { + string memory label = "longnamehere"; + IPopRules.PriceWithMeta memory metadata = popRules.priceWithCheck(label, ed); + assertEq(metadata.price, costModelRegistry.priceForBaseLength(bytes(label).length)); + } + + function test_transferFloor_matches_model() public { + // A PopFull sender handing a NoStatus name to a NoStatus recipient pays the name's own + // price, which is the registered model's amount for its base length. + string memory label = "longnamehere"; + _grantPopFull(ed); + uint256 floor = popRules.transferFloor(label, ed, leonardo); + assertEq(floor, costModelRegistry.priceForBaseLength(bytes(label).length)); + } + + function test_pricingVersion_matches_registry() public view { + assertEq(popRules.pricingVersion(), costModelRegistry.currentVersion()); + assertEq(popRules.pricingVersion(), flatPricing.version()); + } + + function test_price_reverts_when_cost_model_unconfigured() public { + // A PopRules bound to a registry with no COST_MODEL key fails closed on any pricing read. + vm.startPrank(owner); + address freshRegistry = Upgrades.deployUUPSProxy( + "DotnsProtocolRegistry.sol:DotnsProtocolRegistry", + abi.encodeCall(DotnsProtocolRegistry.initialize, (TLD_LABEL)) + ); + address freshPopRules = Upgrades.deployUUPSProxy( + "PopRules.sol:PopRules", + abi.encodeCall(PopRules.initialize, (IDotnsProtocolRegistry(freshRegistry))) + ); + vm.stopPrank(); + + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Cost model not configured") + ); + PopRules(freshPopRules).price("longnamehere"); + } + function test_reserveBaseNameForPop_reverts_for_non_controller() public { vm.prank(ed); vm.expectRevert(IPopRules.NotRegistry.selector); diff --git a/test/unit/registrar/DotnsPopController.t.sol b/test/unit/registrar/DotnsPopController.t.sol index b3952d67d..bb08c9527 100644 --- a/test/unit/registrar/DotnsPopController.t.sol +++ b/test/unit/registrar/DotnsPopController.t.sol @@ -413,7 +413,12 @@ contract DotnsPopControllerTests is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(label, ed, block.timestamp)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: ed, secret: secret, reserved: true + label: label, + owner: ed, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -469,7 +474,12 @@ contract DotnsPopControllerTests is BaseDotns { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: "longnamebob01", owner: tiago, secret: keccak256("secret"), reserved: true + label: "longnamebob01", + owner: tiago, + secret: keccak256("secret"), + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); diff --git a/test/unit/registrar/DotnsRegistrarController.t.sol b/test/unit/registrar/DotnsRegistrarController.t.sol index f7635e482..d64a0ec0a 100644 --- a/test/unit/registrar/DotnsRegistrarController.t.sol +++ b/test/unit/registrar/DotnsRegistrarController.t.sol @@ -4,6 +4,9 @@ pragma solidity ^0.8.34; import {BaseDotns, IDotnsRegistrarController} from "../../base/BaseDotns.t.sol"; import {IPopRules} from "../../../contracts/pop/IPopRules.sol"; +import {DotnsScarcityPricing} from "../../../contracts/pop/DotnsScarcityPricing.sol"; +import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; +import {IDotnsCostModelRegistry} from "../../../contracts/pop/IDotnsCostModelRegistry.sol"; import {ILabelStore} from "../../../contracts/store/ILabelStore.sol"; import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; import {IDotnsRoleManager} from "../../../contracts/access/IDotnsRoleManager.sol"; @@ -73,7 +76,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { function test_commit_sets_timestamp() public { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: "alicebob", owner: ed, secret: keccak256("secret"), reserved: true + label: "alicebob", + owner: ed, + secret: keccak256("secret"), + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -88,7 +96,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { function test_commit_allows_recommit_after_expiry() public { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: "alicebob", owner: ed, secret: keccak256("secret"), reserved: true + label: "alicebob", + owner: ed, + secret: keccak256("secret"), + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -108,7 +121,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { function test_commit_allows_recommit_at_exact_expiry_boundary() public { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: "alicebob", owner: ed, secret: keccak256("secret"), reserved: true + label: "alicebob", + owner: ed, + secret: keccak256("secret"), + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -136,7 +154,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: keccak256("boundary"), reserved: true + label: nameLabel, + owner: nameOwner, + secret: keccak256("boundary"), + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -173,7 +196,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "store")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: true + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -181,7 +209,8 @@ contract DotnsRegistrarControllerTest is BaseDotns { vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); - dotnsRegistrarController.register{value: 0}(registration); + uint256 registrationPrice = popRules.priceWithCheck(nameLabel, nameOwner).price; + dotnsRegistrarController.register{value: registrationPrice}(registration); vm.stopPrank(); bytes32 labelHash = keccak256(bytes(nameLabel)); @@ -206,7 +235,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "lite")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: true + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -214,7 +248,8 @@ contract DotnsRegistrarControllerTest is BaseDotns { vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); - dotnsRegistrarController.register{value: 0}(registration); + uint256 registrationPrice = popRules.priceWithCheck(nameLabel, nameOwner).price; + dotnsRegistrarController.register{value: registrationPrice}(registration); vm.stopPrank(); (bool isReserved, address reservationOwner,) = popRules.isBaseNameReserved("lights"); @@ -232,7 +267,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(giftedLabel, tiago, ed, block.timestamp)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: giftedLabel, owner: tiago, secret: secret, reserved: true + label: giftedLabel, + owner: tiago, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -260,7 +300,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "reserved")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: true + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -290,7 +335,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "reserved")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: true + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -395,7 +445,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "whitelisted")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: true + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -413,6 +468,37 @@ contract DotnsRegistrarControllerTest is BaseDotns { assertEq(dotnsRegistry.owner(node), nameOwner); } + function test_registerReserved_bypasses_closed_short_name_gate() public { + vm.prank(owner); + popRules.setShortNamesEnabled(false); + + string memory nameLabel = "reserved"; // base length 8, closed on the public path + address nameOwner = ed; + + vm.prank(owner); + dotnsRegistrarController.whiteListAddress(ed, true); + + vm.startPrank(ed); + bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "gate-closed")); + IDotnsRegistrarController.Registration memory registration = + IDotnsRegistrarController.Registration({ + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() + }); + bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); + dotnsRegistrarController.commit(commitment); + vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); + dotnsRegistrarController.registerReserved(registration); + vm.stopPrank(); + + bytes32 node = _namehash(dotNode, keccak256(bytes(nameLabel))); + assertEq(IERC721(address(dotnsRegistrar)).ownerOf(uint256(node)), nameOwner); + } + function test_removed_from_whitelist_cannot_register_reserved() public { string memory nameLabel = "reserved02"; address nameOwner = ed; @@ -429,7 +515,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "removed")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: true + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -453,7 +544,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "dotted")); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: nameLabel, owner: nameOwner, secret: secret, reserved: false + label: nameLabel, + owner: nameOwner, + secret: secret, + reserved: false, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -572,7 +668,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(popfullLabel, ownerAddr, block.timestamp)); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: popfullLabel, owner: ownerAddr, secret: secret, reserved: true + label: popfullLabel, + owner: ownerAddr, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -589,7 +690,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { vm.prank(payer); vm.expectRevert( abi.encodeWithSelector( - IDotnsRegistrarController.OwnerStatusInsufficient.selector, + IPopRules.OwnerStatusInsufficient.selector, popfullLabel, IPopRules.PopStatus.NoStatus, IPopRules.PopStatus.PopFull @@ -659,7 +760,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { uint256 tokenId = _tokenIdForLabel(nameLabel); IDotnsNameEscrow.ReleasePosition memory before = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(before.amount, RENT_PRICE, "NoStatus mint must seed RENT_PRICE deposit"); + assertEq(before.amount, BASE_DEPOSIT, "NoStatus mint must seed BASE_DEPOSIT deposit"); assertEq(before.recipient, ed, "deposit recipient must be original registrant at mint"); uint256 transferFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); @@ -673,7 +774,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { dotnsRegistrar.transferFrom{value: 0}(ed, leonardo, tokenId); IDotnsNameEscrow.ReleasePosition memory after_ = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(after_.amount, RENT_PRICE, "deposit amount must travel with the NFT"); + assertEq(after_.amount, BASE_DEPOSIT, "deposit amount must travel with the NFT"); assertEq( after_.recipient, leonardo, "position must rebind to the new holder, not be deleted" ); @@ -720,7 +821,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory between = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(between.amount, RENT_PRICE, "deposit must travel through the first hop"); + assertEq(between.amount, BASE_DEPOSIT, "deposit must travel through the first hop"); assertEq(between.recipient, leonardo, "position recipient rebinds to leonardo on outbound"); // Return leg leonardo back to ed rebinds the position back to ed. @@ -728,7 +829,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { dotnsRegistrar.transferFrom{value: 0}(leonardo, ed, tokenId); IDotnsNameEscrow.ReleasePosition memory after_ = dotnsNameEscrow.getReleasePosition(tokenId); - assertEq(after_.amount, RENT_PRICE, "deposit must travel through the return hop"); + assertEq(after_.amount, BASE_DEPOSIT, "deposit must travel through the return hop"); assertEq(after_.recipient, ed, "position recipient rebinds back to ed on the return leg"); assertEq( @@ -747,4 +848,178 @@ contract DotnsRegistrarControllerTest is BaseDotns { "intermediate holder receives no refund on either leg" ); } + + function _nostatusRegistration( + string memory label, + address nameOwner, + uint256 maxPrice, + uint256 pricingVersion + ) + internal + pure + returns (IDotnsRegistrarController.Registration memory registration) + { + registration = IDotnsRegistrarController.Registration({ + label: label, + owner: nameOwner, + secret: keccak256(abi.encodePacked(label, nameOwner)), + reserved: false, + maxPrice: maxPrice, + pricingVersion: pricingVersion + }); + } + + function test_register_reverts_when_charge_exceeds_maxPrice() public { + string memory label = "ceilingtest01"; + address nameOwner = ed; + uint256 price = popRules.price(label); + IDotnsRegistrarController.Registration memory registration = + _nostatusRegistration(label, nameOwner, price - 1, popRules.pricingVersion()); + + _commitRegistrationAndWaitMinimumAge(registration); + + vm.prank(nameOwner); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsRegistrarController.PriceExceedsMax.selector, label, price, price - 1 + ) + ); + dotnsRegistrarController.register{value: price}(registration); + } + + function test_register_succeeds_when_maxPrice_exactly_met() public { + string memory label = "exactmaxtest01"; + address nameOwner = ed; + uint256 price = popRules.price(label); + IDotnsRegistrarController.Registration memory registration = + _nostatusRegistration(label, nameOwner, price, popRules.pricingVersion()); + + _commitRegistrationAndWaitMinimumAge(registration); + + vm.prank(nameOwner); + dotnsRegistrarController.register{value: price}(registration); + + assertEq(IERC721(address(dotnsRegistrar)).ownerOf(_tokenIdForLabel(label)), nameOwner); + } + + function test_maxPrice_bound_into_commitment() public { + string memory label = "maxbindtest01"; + address nameOwner = ed; + uint256 price = popRules.price(label); + IDotnsRegistrarController.Registration memory committed = + _nostatusRegistration(label, nameOwner, type(uint256).max, popRules.pricingVersion()); + + _commitRegistrationAndWaitMinimumAge(committed); + + // Reveal with a different ceiling: the commitment hash no longer matches. + IDotnsRegistrarController.Registration memory tampered = committed; + tampered.maxPrice = price; + bytes32 expected = dotnsRegistrarController.makeCommitment(tampered); + + vm.prank(nameOwner); + vm.expectRevert( + abi.encodeWithSelector(IDotnsRegistrarController.CommitmentNotFound.selector, expected) + ); + dotnsRegistrarController.register{value: price}(tampered); + } + + function test_pricingVersion_bound_into_commitment() public { + string memory label = "verbindtest01"; + address nameOwner = ed; + uint256 price = popRules.price(label); + IDotnsRegistrarController.Registration memory committed = + _nostatusRegistration(label, nameOwner, type(uint256).max, popRules.pricingVersion()); + + _commitRegistrationAndWaitMinimumAge(committed); + + // Reveal with a different committed version: the commitment hash no longer matches. + IDotnsRegistrarController.Registration memory tampered = committed; + tampered.pricingVersion = committed.pricingVersion + 1; + bytes32 expected = dotnsRegistrarController.makeCommitment(tampered); + + vm.prank(nameOwner); + vm.expectRevert( + abi.encodeWithSelector(IDotnsRegistrarController.CommitmentNotFound.selector, expected) + ); + dotnsRegistrarController.register{value: price}(tampered); + } + + function test_reveal_prices_at_committed_version_after_model_swap() public { + string memory label = "versionlock01"; + address nameOwner = ed; + uint256 committedVersion = popRules.pricingVersion(); + uint256 committedPrice = popRules.price(label); + + IDotnsRegistrarController.Registration memory registration = + _nostatusRegistration(label, nameOwner, committedPrice, committedVersion); + + _commitRegistrationAndWaitMinimumAge(registration); + + // Governance registers a pricier model mid-window, moving the current version. + DotnsScarcityPricing pricierModel = new DotnsScarcityPricing(BASE_DEPOSIT * 2, MIN_PRICE); + vm.prank(owner); + costModelRegistry.register(IDotnsPricing(address(pricierModel))); + assertTrue(popRules.pricingVersion() != committedVersion); + + // The reveal still settles at the committed version's amount and succeeds. + vm.prank(nameOwner); + dotnsRegistrarController.register{value: committedPrice}(registration); + + assertEq(IERC721(address(dotnsRegistrar)).ownerOf(_tokenIdForLabel(label)), nameOwner); + } + + function test_reveal_reverts_when_committed_version_not_current() public { + string memory label = "unknownver01"; + address nameOwner = ed; + // A caller cannot bind an arbitrary version: commit stamps the version current then, and + // the reveal rejects a `pricingVersion` that differs from it. + uint256 stampedVersion = costModelRegistry.currentVersion(); + uint256 wrongVersion = uint256(keccak256("never registered version")); + + IDotnsRegistrarController.Registration memory registration = + _nostatusRegistration(label, nameOwner, type(uint256).max, wrongVersion); + + _commitRegistrationAndWaitMinimumAge(registration); + + vm.prank(nameOwner); + vm.expectRevert( + abi.encodeWithSelector( + IDotnsCostModelRegistry.PricingVersionMismatch.selector, + stampedVersion, + wrongVersion + ) + ); + dotnsRegistrarController.register{value: BASE_DEPOSIT}(registration); + } + + function test_reveal_prices_at_committed_version_across_current_moves() public { + string memory label = "movingcur01"; + address nameOwner = ed; + + // Register a second model and commit under it as the current version. + DotnsScarcityPricing modelV2 = new DotnsScarcityPricing(BASE_DEPOSIT * 2, MIN_PRICE); + vm.prank(owner); + costModelRegistry.register(IDotnsPricing(address(modelV2))); + uint256 committedVersion = costModelRegistry.currentVersion(); + uint256 committedPrice = popRules.price(label); + + IDotnsRegistrarController.Registration memory registration = + _nostatusRegistration(label, nameOwner, committedPrice, committedVersion); + _commitRegistrationAndWaitMinimumAge(registration); + + // Move current forward to a third model, then revert it back to the first registered + // version, which the harness seeds with the flat launch model. + DotnsScarcityPricing modelV3 = new DotnsScarcityPricing(BASE_DEPOSIT * 4, MIN_PRICE); + uint256 firstVersion = flatPricing.version(); + vm.prank(owner); + costModelRegistry.register(IDotnsPricing(address(modelV3))); + vm.prank(owner); + costModelRegistry.setCurrentVersion(firstVersion); + + // The reveal still charges the committed version's amount, no matter how current moved. + vm.prank(nameOwner); + dotnsRegistrarController.register{value: committedPrice}(registration); + + assertEq(IERC721(address(dotnsRegistrar)).ownerOf(_tokenIdForLabel(label)), nameOwner); + } } diff --git a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol index 3312c2b41..14ec3d3ae 100644 --- a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol +++ b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol @@ -50,15 +50,21 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { bytes32 secret = keccak256("new-owner-reclaim"); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: newOwner, secret: secret, reserved: true + label: label, + owner: newOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(newOwner); dotnsRegistrarController.commit(commitment); vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); + uint256 registrationPrice = popRules.price(label); vm.prank(newOwner); - dotnsRegistrarController.register{value: 0}(registration); + dotnsRegistrarController.register{value: registrationPrice}(registration); assertEq(dotnsRegistrar.ownerOf(tokenId), newOwner); (bool isReserved, address reservationOwner,) = popRules.isBaseNameReserved("lights"); @@ -86,7 +92,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: nameOwner, secret: keccak256(abi.encodePacked(label, nameOwner, payer)), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -94,18 +102,17 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { dotnsRegistrarController.commit(commitment); vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); - uint256 insuranceBefore = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesBefore = dotnsNameEscrow.protocolFees(); vm.prank(payer); dotnsRegistrarController.register{value: expectedCharge}(registration); - // Cross-payer charge is the greater of owner-side price and reach-floor friction, - // never their sum. The whole charge routes to the insurance fund; the refundable - // deposit branch is reserved for direct registrants. + // Cross-payer charge is the name's own curve price. The whole charge routes to protocol + // fees; the refundable deposit branch is reserved for direct registrants. assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, expectedCharge, - "cross-payer must credit max(ownerPrice, reachFloor) to insurance" + "cross-payer must credit the name's own curve price to protocol fees" ); } @@ -129,7 +136,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: nameOwner, secret: keccak256(abi.encodePacked(label, nameOwner, payer, "short")), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -142,7 +151,7 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { dotnsRegistrarController.register{value: expectedCharge - 1}(registration); } - function test_register_cross_payer_routes_owner_price_to_insurance() public { + function test_register_cross_payer_routes_owner_price_to_protocol_fees() public { string memory label = NOSTATUS_LABEL_A; address payer = leonardo; address nameOwner = ed; @@ -158,7 +167,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: nameOwner, secret: keccak256(abi.encodePacked(label, nameOwner, payer, "ins")), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); @@ -166,7 +177,7 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { dotnsRegistrarController.commit(commitment); vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); - uint256 insuranceBefore = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesBefore = dotnsNameEscrow.protocolFees(); vm.prank(payer); dotnsRegistrarController.register{value: ownerPrice}(registration); @@ -177,13 +188,13 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { assertEq(position.amount, 0, "cross-payer must not seed a refundable position"); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, ownerPrice, - "cross-payer price must accrue to insurance" + "cross-payer price must accrue to protocol fees" ); } - function test_register_creates_escrow_position_for_zero_priced_registration() public { + function test_register_creates_funded_position_for_self_registration() public { string memory label = BASE_LABEL_A; address nameOwner = ed; _grantPopFull(nameOwner); @@ -193,21 +204,24 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: nameOwner, secret: keccak256(abi.encodePacked(label, nameOwner, "popfull")), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(nameOwner); dotnsRegistrarController.commit(commitment); vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); + uint256 registrationPrice = popRules.priceWithCheck(label, nameOwner).price; vm.prank(nameOwner); - dotnsRegistrarController.register{value: 0}(registration); + dotnsRegistrarController.register{value: registrationPrice}(registration); uint256 tokenId = _tokenIdForLabel(label); IDotnsNameEscrow.ReleasePosition memory atMint = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(atMint.recipient, nameOwner, "position must bind the registrant at mint"); - assertEq(atMint.amount, 0, "zero-priced mint seeds a zero-amount position"); + assertEq(atMint.amount, registrationPrice, "self-registration seeds a funded position"); assertFalse(atMint.released, "fresh position is not yet released"); assertFalse(atMint.claimed, "fresh position is not yet claimed"); @@ -218,7 +232,7 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory atRelease = dotnsNameEscrow.getReleasePosition(tokenId); - assertTrue(atRelease.released, "zero-priced registration must still be releasable"); + assertTrue(atRelease.released, "funded registration must still be releasable"); assertEq(atRelease.recipient, nameOwner, "release recipient must be the registrant"); } @@ -248,7 +262,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: address(probe), secret: keccak256("probe-reclaim"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(address(probe)); @@ -282,7 +298,12 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { bytes32 secret = keccak256("seed-reserved"); IDotnsRegistrarController.Registration memory registration = IDotnsRegistrarController.Registration({ - label: label, owner: nameOwner, secret: secret, reserved: true + label: label, + owner: nameOwner, + secret: secret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); dotnsRegistrarController.commit(commitment); @@ -293,7 +314,12 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { bytes32 secondSecret = keccak256("seed-reserved-2"); IDotnsRegistrarController.Registration memory secondRegistration = IDotnsRegistrarController.Registration({ - label: label, owner: nameOwner, secret: secondSecret, reserved: true + label: label, + owner: nameOwner, + secret: secondSecret, + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); vm.startPrank(owner); @@ -336,7 +362,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: registrant, secret: keccak256("eoa-overpay-happy"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(registrant); @@ -379,7 +407,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: address(receiver), secret: keccak256("accepting-contract-overpay"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(address(receiver)); @@ -427,7 +457,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: address(receiver), secret: keccak256("contract-overpay"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(address(receiver)); @@ -469,7 +501,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: replayLabel, owner: address(attacker), secret: keccak256("reentry-replay"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); attacker.arm(replay); @@ -478,7 +512,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: address(attacker), secret: keccak256("reentrant-overpay"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 commitment = dotnsRegistrarController.makeCommitment(registration); vm.prank(address(attacker)); @@ -531,7 +567,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: replayLabel, owner: address(attacker), secret: keccak256("reentry"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 replayCommitment = dotnsRegistrarController.makeCommitment(replay); vm.prank(address(attacker)); @@ -543,7 +581,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { label: label, owner: address(attacker), secret: keccak256("outer-reclaim"), - reserved: true + reserved: true, + maxPrice: type(uint256).max, + pricingVersion: popRules.pricingVersion() }); bytes32 outerCommitment = dotnsRegistrarController.makeCommitment(outer); vm.prank(address(attacker));