From bebd473e600cded70f336b1857ff3afe2afd01e8 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Fri, 7 Aug 2026 07:40:14 +0200 Subject: [PATCH 01/13] feat: price dotNS names on a scarcity curve Replace flat pricing with a geometric curve on base length: the price doubles for each character below nine and flattens to the base fee from nine upward. Every caller pays the curve; personhood gates access to the premium band rather than discounting it. Transfers re-price at the name's own length. Rename the escrow insurance fund to protocol fees, a non-refundable pot that only accumulates, and remove the dead refund-shortfall draw so refunds draw solely on the per-asset reserve. --- README.md | 52 ++++---- contracts/escrow/DotnsNameEscrow.sol | 42 +++---- contracts/escrow/IDotnsNameEscrow.sol | 54 ++++---- contracts/pop/IPopRules.sol | 67 +++++----- contracts/pop/PopRules.sol | 119 ++++++++---------- contracts/registrars/DotnsRegistrar.sol | 2 +- .../registrars/DotnsRegistrarController.sol | 6 +- contracts/registrars/IDotnsRegistrar.sol | 4 +- .../registrars/IDotnsRegistrarController.sol | 2 +- test/fuzz/pop/PopFuzz.t.sol | 8 ++ .../DotnsRegistrarControllerFuzz.t.sol | 39 +++--- test/intergration/BasicDotns.reverts.t.sol | 2 +- test/intergration/BasicDotns.t.sol | 10 +- .../escrow/DotnsNameEscrowInvariant.t.sol | 2 +- test/invariant/escrow/EscrowHandler.t.sol | 26 ++-- .../DotnsRegistrarControllerInvariant.t.sol | 2 +- test/unit/escrow/DotnsNameEscrow.t.sol | 117 +++++++++-------- test/unit/pop/PopRules.t.sol | 47 +++++-- .../registrar/DotnsRegistrarController.t.sol | 6 +- .../DotnsRegistrarControllerLifecycle.t.sol | 20 +-- 20 files changed, 305 insertions(+), 322 deletions(-) diff --git a/README.md b/README.md index b657d6322..4410764b0 100644 --- a/README.md +++ b/README.md @@ -20,41 +20,43 @@ Current network addresses and deployment notes are listed in [DEPLOYMENTS.md](./ ## 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 price comes from one number. The base fee D is set in the native token and equals ten DOT at launch. A name's price depends only on its base length, the character count once a trailing number is set aside: -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. +price(n) = D · 2^(9 − n) for a base length n below nine, and D for n of nine or more. -### Registration matrix +The price doubles for each character below nine and flattens to D from nine upward. Short names are scarce, so they cost more, and the curve follows how the count of available names falls as they shorten. A trailing number never changes the price, because it comes off before the length is measured, so `andrew` and `andrew01` both cost 8D. A name carries no digits or exactly two; any other trailing-digit count is rejected. -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. +| Base length | Price | +|---|---| +| 9 or more | D | +| 8 | 2D | +| 7 | 4D | +| 6 | 8D | +| 5 | 16D | +| 4 | 32D | +| 3 | 64D | -| 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 | +### Bands and who pays -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. +Three bands share the one curve. Names of nine characters or more are open to anyone at the flat fee D. Names of six to eight characters are the premium band: only a verified person may register there, and they pay the curve for the length, 8D, 4D or 2D. Personhood buys access to the premium band, not a discount inside it. Names of five characters or fewer are reserved to governance; no user registers them, and governance releases them itself at the curve floor into the treasury it already controls. -### Transfer matrix +Every caller pays the same curve for a given length. The two zero-cost paths are both bounded. Each verified person gets one free name through the personhood gateway, which is the unpriced lane: the gateway issues that single grant and charges nothing for it. Governance releasing a reserved name pays itself the floor, which settles in a circle into its own treasury and nets nothing. -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. +### Deposits and protocol fees -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. +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 the depositor, so it travels with the token on every transfer and unlocks only when the current holder releases the name back to escrow. Holding many names ties up capital on the same curve, and the cost rises with scarcity because the price does. -| Sender → Recipient | Friction (to insurance) | Deposit movement | -|---|---|---| -| 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 | +A fee is non-refundable. Two things pay a fee instead of a deposit: a name someone else pays for, and a transfer. Fees flow into one protocol fee pot that only accumulates. Governance can route the pot to one of a fixed pair, a burn or the on-chain treasury, and to nowhere else. A holder's own deposit is their money held in trust and is never swept into fees. -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. +### Transfers re-price at the name's own length + +Moving a name re-prices it from scratch at its own length on every move. Passing a six-character name to a wallet that could never have registered it costs 8D, the name's own price, not the flat D floor. `andrew` and `andrew01` re-derive to the same 8D. The exit price equals what the name was worth to acquire, so there is no cheap way to hand a scarce name to a party who could not have earned it. A move between parties who both clear the name's band costs nothing, and the fee, when one is owed, settles into protocol fees. + +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 deposit. Transferring a funded name hands the locked deposit to the recipient along with the token. + +### What governance controls + +Everything hangs off D. Governance can move D, but only inside a fixed band and by at most a set multiple per vote, so it can neither drop the price to zero, which would free every short name, nor raise it to an extractive level, and any change is legible several votes ahead. Governance sets how long the interval on the free grant runs, and chooses where fees go, a burn or the treasury, from that fixed pair. Nothing in that surface lets governance seize, reassign or destroy a name anyone already holds. ### Refund and cooldown model diff --git a/contracts/escrow/DotnsNameEscrow.sol b/contracts/escrow/DotnsNameEscrow.sol index e7cd5071a..a4c99c04f 100644 --- a/contracts/escrow/DotnsNameEscrow.sol +++ b/contracts/escrow/DotnsNameEscrow.sol @@ -63,11 +63,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 @@ -228,7 +227,7 @@ contract DotnsNameEscrow is } /// @inheritdoc IDotnsNameEscrow - function depositInsurance(InsuranceDepositParams calldata params) + function depositProtocolFee(ProtocolFeeDepositParams calldata params) external payable override @@ -236,7 +235,7 @@ contract DotnsNameEscrow is { require(msg.value > 0, InvalidAmount()); - insuranceFund += msg.value; + protocolFees += msg.value; emit CrossTierFeePaid( params.tokenId, @@ -277,7 +276,7 @@ contract DotnsNameEscrow is } if (fee > 0) { - insuranceFund += fee; + protocolFees += fee; } charged = fee; @@ -359,30 +358,17 @@ contract DotnsNameEscrow is // `position.recipient == msg.sender` was just enforced above, so reuse the local in place // of an extra warm SLOAD. address recipient = msg.sender; - 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]) + ); // Effects: mutate state only after all checks have passed. position.claimed = true; position.amount = 0; - tokenReserved[asset] -= fromRefundable; - if (fromInsurance > 0) { - insuranceFund -= fromInsurance; - emit InsuranceDraw(tokenId, fromInsurance); - } + tokenReserved[asset] -= owed; _pendingWithdrawals[recipient] += owed; diff --git a/contracts/escrow/IDotnsNameEscrow.sol b/contracts/escrow/IDotnsNameEscrow.sol index 77e88af23..cef0f0b83 100644 --- a/contracts/escrow/IDotnsNameEscrow.sol +++ b/contracts/escrow/IDotnsNameEscrow.sol @@ -19,21 +19,20 @@ 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. @@ -130,9 +129,9 @@ interface IDotnsNameEscrow { /// @notice Emitted when the cooldown duration for future releases is updated. event CooldownUpdated(uint256 indexed currentCooldown, uint256 indexed newCooldown); - /// @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, @@ -142,10 +141,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); @@ -161,8 +156,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. @@ -276,11 +271,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. @@ -292,7 +287,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 @@ -301,17 +296,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 @@ -335,12 +330,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. diff --git a/contracts/pop/IPopRules.sol b/contracts/pop/IPopRules.sol index d1d47452c..3f7fb7e96 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. +/// Pricing is a geometric scarcity curve on the base length: price(n) = D * 2^(9 - n) below +/// nine characters and the base fee D at nine and above. Every caller pays the same curve for +/// a given length; personhood buys access to the premium band, not a discount inside it. /// @custom:security-contact admin@parity.io interface IPopRules { /// @notice Proof-of-Personhood eligibility tier. @@ -33,9 +34,9 @@ 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. + /// @notice Emitted when the base fee D is changed. + /// @dev Owner-only setter @custom:function updateStartingPrice; the new value is the base of + /// the scarcity curve consumed by `_priceValidatedName` on the next pricing read. /// @param oldPrice Previous wei value. /// @param newPrice New wei value. event StartingPriceUpdated(uint256 oldPrice, uint256 newPrice); @@ -48,7 +49,7 @@ interface IPopRules { error NotRegistry(); /// @notice Bundle returned from metadata-aware pricing queries. - /// @param price Registration cost; typically non-zero only for NoStatus users. + /// @param price Registration cost on the scarcity curve 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,12 +84,12 @@ interface IPopRules { pure returns (PopStatus requirement, string memory message); - /// @notice Updates the spam-deterrent starting price for NoStatus pricing. + /// @notice Updates the base fee D that anchors the scarcity curve. /// @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. + /// @custom:reverts PopError. The new value anchors the curve in `_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; @@ -186,11 +187,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 gates access to the premium band rather than discounting it. 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. @@ -222,24 +224,16 @@ 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 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 what + /// the + /// name is worth, not the open-band floor. 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. @@ -263,9 +257,10 @@ 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 on the scarcity curve by base length: D * 2^(9 - n) for a base length + /// below nine, and the base fee D at nine and above. 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); diff --git a/contracts/pop/PopRules.sol b/contracts/pop/PopRules.sol index 80de9ab94..f700869e6 100644 --- a/contracts/pop/PopRules.sol +++ b/contracts/pop/PopRules.sol @@ -18,13 +18,15 @@ 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. +/// @notice Implements DotNS classification, scarcity pricing on a geometric curve, and base-name +/// reservations. /// @dev Tier shape: 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 curve for a given base length: price(n) = D * 2^(9 - n) below +/// nine characters, and the base fee D at nine and above, where D is `startingPrice`. +/// Personhood buys access to the premium band, not a discount inside it. /// @custom:security-contact admin@parity.io contract PopRules is Initializable, @@ -35,7 +37,7 @@ contract PopRules is { using StringUtils for *; - /// @notice Wei price for names with 9 characters and up. + /// @notice Base fee D in wei. Base lengths >= 9 pay D; shorter base lengths pay D * 2^(9 - n). uint256 public startingPrice; /// @notice Active reservations keyed by digit-stripped base name. @@ -65,7 +67,7 @@ contract PopRules is /// @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. + /// @param _startingPrice Base fee D in wei anchoring the scarcity curve, paid by every caller. /// @param registry Protocol-level address registry used to resolve sibling contracts. function initialize( uint256 _startingPrice, @@ -95,7 +97,7 @@ contract PopRules is returns (PopStatus requirement, string memory message) { _requireCanonicalLabel(name); - return _classifyValidatedName(name); + (requirement, message,) = _classifyValidatedName(name); } /// @inheritdoc IPopRules @@ -160,28 +162,17 @@ contract PopRules is _requireCanonicalLabel(name); _enforceReservationRules(name, userAddress); - (PopStatus requiredStatus, string memory classification) = _classifyValidatedName(name); + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); PopStatus userStatus = _personhoodTier(userAddress); - metadata.price = - userStatus == PopStatus.NoStatus ? _priceValidatedName(bytes(name).length) : 0; + metadata.price = _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; } @@ -198,11 +189,11 @@ contract PopRules is { _requireCanonicalLabel(name); - (PopStatus requiredStatus, string memory classification) = _classifyValidatedName(name); + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); PopStatus userStatus = _personhoodTier(userAddress); - metadata.price = - userStatus == PopStatus.NoStatus ? _priceValidatedName(bytes(name).length) : 0; + metadata.price = _priceValidatedName(baseLength); metadata.status = requiredStatus; metadata.userStatus = userStatus; metadata.message = classification; @@ -221,25 +212,7 @@ contract PopRules is /// @inheritdoc IPopRules function price(string calldata name) external view override returns (uint256) { _requireCanonicalLabel(name); - return _priceValidatedName(bytes(name).length); - } - - /// @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; + return _priceValidatedName(_validatedBaseLength(name)); } /// @inheritdoc IPopRules @@ -255,15 +228,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; } @@ -284,8 +258,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 @@ -295,11 +269,25 @@ contract PopRules is return userStatus >= required; } - function _priceValidatedName(uint256 namelength) internal view returns (uint256 priceValue) { - if (namelength < 9) { - return 0; - } - return startingPrice; + /// @notice Scarcity price for a base length: `D * 2 ** (9 - n)` below nine, else the base fee + /// D. @dev The multiplier is at most 512 and arithmetic is checked, so an oversized base fee + /// reverts rather than truncating. + function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { + if (baseLength >= 9) return startingPrice; + return startingPrice * (2 ** (9 - baseLength)); + } + + /// @notice Validates the digit suffix and returns the base length, the single source pricing + /// and classification both derive a name's band from. + /// @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. @@ -367,31 +355,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 11b0069e6..ee755cc47 100644 --- a/contracts/registrars/DotnsRegistrar.sol +++ b/contracts/registrars/DotnsRegistrar.sol @@ -358,7 +358,7 @@ 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 + /// 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( diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index d5fade58a..f806cdc4c 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -245,7 +245,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 +264,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 }) ); diff --git a/contracts/registrars/IDotnsRegistrar.sol b/contracts/registrars/IDotnsRegistrar.sol index dab246f0d..ad8089c56 100644 --- a/contracts/registrars/IDotnsRegistrar.sol +++ b/contracts/registrars/IDotnsRegistrar.sol @@ -106,8 +106,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 diff --git a/contracts/registrars/IDotnsRegistrarController.sol b/contracts/registrars/IDotnsRegistrarController.sol index 7bd1121da..a5676e80e 100644 --- a/contracts/registrars/IDotnsRegistrarController.sol +++ b/contracts/registrars/IDotnsRegistrarController.sol @@ -158,7 +158,7 @@ interface IDotnsRegistrarController is IDotnsController { /// 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 + /// charge routes to the escrow protocol fee pot while seeding a zero-amount deposit slot so /// the release lifecycle stays reachable. 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 diff --git a/test/fuzz/pop/PopFuzz.t.sol b/test/fuzz/pop/PopFuzz.t.sol index 52cfdf380..d01330e74 100644 --- a/test/fuzz/pop/PopFuzz.t.sol +++ b/test/fuzz/pop/PopFuzz.t.sol @@ -41,6 +41,14 @@ contract PopRulesFuzzTest is BaseDotns { popRules.priceWithCheck(nameLabel, ed); } + function testFuzz_price_matches_curve(uint256 seed, uint256 length) public view { + length = bound(length, 3, 20); + string memory nameLabel = _makeAlpha(seed, length); + + uint256 expected = length >= 9 ? RENT_PRICE : RENT_PRICE * (2 ** (9 - length)); + assertEq(popRules.price(nameLabel), expected); + } + function testFuzz_governance_names_always_revert( uint256 seed, uint256 length, diff --git a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol index 73da11a48..760a5d525 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -86,12 +86,7 @@ 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)); @@ -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")); } @@ -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); diff --git a/test/intergration/BasicDotns.reverts.t.sol b/test/intergration/BasicDotns.reverts.t.sol index bd940ef0d..5c892d3d7 100644 --- a/test/intergration/BasicDotns.reverts.t.sol +++ b/test/intergration/BasicDotns.reverts.t.sol @@ -38,7 +38,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..943c1a212 100644 --- a/test/intergration/BasicDotns.t.sol +++ b/test/intergration/BasicDotns.t.sol @@ -108,10 +108,7 @@ 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); - } + assertEq(quotedPriceBefore, popRules.price(flow.name)); _commitAndRegister(flow.name, flow.nameOwner, flow.reserved); @@ -186,10 +183,7 @@ contract BasicDotnsIntegration is BaseDotns { uint256 transferRecipientQuotedPrice = popRules.priceWithCheck(flow.transferRecipientNewName, flow.transferTo).price; - - if (_personhoodTierIsAtLeastLite(flow.transferTo)) { - assertEq(transferRecipientQuotedPrice, 0); - } + assertEq(transferRecipientQuotedPrice, popRules.price(flow.transferRecipientNewName)); _commitAndRegister(flow.transferRecipientNewName, flow.transferTo, false); diff --git a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol index 1b2ca3636..04988808e 100644 --- a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol +++ b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol @@ -59,7 +59,7 @@ contract DotnsNameEscrowInvariantTest is BaseDotns { function invariant_solvency() public view { uint256 escrowBalance = address(dotnsNameEscrow).balance; uint256 reservedAmount = dotnsNameEscrow.reserves(address(0)); - uint256 insurance = dotnsNameEscrow.insuranceFund(); + uint256 insurance = dotnsNameEscrow.protocolFees(); uint256 pending = handler.totalPendingWithdrawals(); uint256 refundEntries = handler.totalPendingRefundEntries(); diff --git a/test/invariant/escrow/EscrowHandler.t.sol b/test/invariant/escrow/EscrowHandler.t.sol index b8459fa67..0c7566090 100644 --- a/test/invariant/escrow/EscrowHandler.t.sol +++ b/test/invariant/escrow/EscrowHandler.t.sol @@ -57,14 +57,14 @@ contract EscrowHandler is Test { 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` + /// @dev Increments via cross-tier register (`depositProtocolFee`) 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()`. + /// == escrow.protocolFees()`. uint256 public ghost_insurancePaidOut; /// @notice Cumulative native amount credited to recipients via `withdraw()`. @@ -156,7 +156,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. @@ -214,10 +214,10 @@ 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 the insurance fund 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(); + uint256 priorProtocolFees = escrow.protocolFees(); bytes32 labelhash = keccak256(bytes(label)); bytes32 node = keccak256(abi.encodePacked(DOT_NODE, labelhash)); uint256 tokenId = uint256(node); @@ -239,7 +239,7 @@ contract EscrowHandler is Test { vm.prank(payer); try controller.register{value: charge}(registration) { Vm.Log[] memory logs = vm.getRecordedLogs(); - uint256 newInsurance = escrow.insuranceFund(); + uint256 newInsurance = escrow.protocolFees(); _depositedTokenIds.push(tokenId); labelByTokenId[tokenId] = label; @@ -247,8 +247,8 @@ contract EscrowHandler is Test { // ghost-state mirrors that by leaving `depositAmounts` at zero. depositAmounts[tokenId] = 0; - if (newInsurance > priorInsurance) { - ghost_insurancePaidIn += (newInsurance - priorInsurance); + if (newInsurance > priorProtocolFees) { + ghost_insurancePaidIn += (newInsurance - priorProtocolFees); } // Track InsuranceDraw outflows surfaced by this transaction (defensive; the @@ -514,7 +514,7 @@ contract EscrowHandler is Test { uint256 requiredFee = registrar.quoteTransferFee(tokenId, to); if (requiredFee == 0) return; - uint256 priorInsurance = escrow.insuranceFund(); + uint256 priorProtocolFees = escrow.protocolFees(); vm.recordLogs(); vm.prank(currentOwner); @@ -527,9 +527,9 @@ contract EscrowHandler is Test { // 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); + uint256 newInsurance = escrow.protocolFees(); + if (newInsurance > priorProtocolFees) { + ghost_insurancePaidIn += (newInsurance - priorProtocolFees); } _accountInsuranceDraws(logs); @@ -579,7 +579,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; diff --git a/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol index f4b35ecd4..75e2e7afb 100644 --- a/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol +++ b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol @@ -103,7 +103,7 @@ contract DotnsRegistrarControllerInvariantTest is BaseDotns { function invariant_value_conservation() public view { // Escrow balance equals reserves + insurance + pending withdrawals. uint256 reservedAmount = dotnsNameEscrow.reserves(address(0)); - uint256 insurance = dotnsNameEscrow.insuranceFund(); + uint256 insurance = dotnsNameEscrow.protocolFees(); uint256 pendingTotal; for (uint256 i; i < 5; ++i) { diff --git a/test/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index c6f2740ad..03c7042c2 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -174,7 +174,7 @@ 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 { + function test_self_registration_seeds_funded_position() public { string memory popLabel = "popfullname"; bytes32 node = _register(popLabel, ed, IPopRules.PopStatus.PopFull); uint256 tokenId = uint256(node); @@ -182,14 +182,13 @@ contract DotnsNameEscrowTest is BaseDotns { 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, RENT_PRICE, "self-registration locks a refundable deposit"); assertEq(position.recipient, ed, "position is bound to the registrant"); } @@ -365,7 +364,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); @@ -374,11 +373,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, 2 * RENT_PRICE, "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); @@ -388,9 +387,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, 2 * RENT_PRICE, "deposit travels with the name on rebind"); + assertEq(dotnsNameEscrow.pendingRefundCount(ed), edRefundsBefore, "no refund on rebind"); assertEq( dotnsNameEscrow.pendingRefundCount(leonardo), leonardoRefundsBefore, @@ -407,11 +406,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); @@ -420,21 +419,21 @@ 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" ); } @@ -455,7 +454,7 @@ contract DotnsNameEscrowTest is BaseDotns { 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); @@ -473,7 +472,7 @@ 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" ); @@ -503,7 +502,7 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq(before.amount, RENT_PRICE, "precondition: deposit at RENT_PRICE"); uint256 reservesBefore = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceBefore = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesBefore = dotnsNameEscrow.protocolFees(); uint256 refundCountBefore = dotnsNameEscrow.pendingRefundCount(ed); uint256 fee = popRules.startingPrice(); @@ -525,7 +524,7 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves unchanged when no refund fires" ); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, fee, "fee leg still settles to insurance" ); @@ -617,7 +616,7 @@ contract DotnsNameEscrowTest is BaseDotns { uint256 tokenId = _registerNoStatus(LABEL, ed); uint256 reservesAtStart = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceAtStart = dotnsNameEscrow.insuranceFund(); + uint256 insuranceAtStart = dotnsNameEscrow.protocolFees(); uint256 edRefundsAtStart = dotnsNameEscrow.pendingRefundCount(ed); uint256 leonardoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(leonardo); uint256 tiagoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(tiago); @@ -650,7 +649,7 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves must stay put while the deposit follows the NFT" ); assertEq( - dotnsNameEscrow.insuranceFund(), + dotnsNameEscrow.protocolFees(), insuranceAtStart, "same-tier hops must not credit insurance" ); @@ -722,7 +721,7 @@ contract DotnsNameEscrowTest is BaseDotns { _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; @@ -731,7 +730,7 @@ contract DotnsNameEscrowTest is BaseDotns { // Insurance 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, + dotnsNameEscrow.protocolFees() - priorProtocolFees, RENT_PRICE, "insurance must grow by exactly D on cross-payer NoStatus sponsorship" ); @@ -753,75 +752,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); diff --git a/test/unit/pop/PopRules.t.sol b/test/unit/pop/PopRules.t.sol index 99b119691..478ececb4 100644 --- a/test/unit/pop/PopRules.t.sol +++ b/test/unit/pop/PopRules.t.sol @@ -69,18 +69,37 @@ 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"); - - assertEq(minLengthPrice, midLengthPrice); - assertEq(midLengthPrice, longLengthPrice); - assertGt(minLengthPrice, 0); + function test_price_follows_scarcity_curve() public view { + assertEq(popRules.price("cat"), 64 * RENT_PRICE); + assertEq(popRules.price("hello"), 16 * RENT_PRICE); + assertEq(popRules.price("lights"), 8 * RENT_PRICE); + assertEq(popRules.price("abcdefg"), 4 * RENT_PRICE); + assertEq(popRules.price("alicebob"), 2 * RENT_PRICE); + } + + function test_open_band_is_flat_at_base_fee() public view { + assertEq(popRules.price("ninechars"), RENT_PRICE); + assertEq(popRules.price("longnamehere"), RENT_PRICE); + assertEq(popRules.price("thisisaverylongname"), RENT_PRICE); + } + + function test_trailing_digits_do_not_change_price() public view { + assertEq(popRules.price("andrew"), 8 * RENT_PRICE); + assertEq(popRules.price("andrew01"), 8 * RENT_PRICE); + } + + function test_verified_person_pays_the_curve_for_premium() public { + _grantPopFull(ed); + + assertEq(popRules.priceWithCheck("alicebob", ed).price, 2 * RENT_PRICE); + assertEq(popRules.priceWithCheck("lights", ed).price, 8 * RENT_PRICE); + } + + function test_transfer_reprices_at_own_length() public { + _grantPopFull(leonardo); + + assertEq(popRules.transferFloor("lights", leonardo, tiago), 8 * RENT_PRICE); + assertEq(popRules.transferFloor("lights", leonardo, leonardo), 0); } function test_price_with_check_revert_governance() public { @@ -93,7 +112,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 +125,7 @@ contract PopRulesTests is BaseDotns { assertEq(uint256(priceMetadata.status), uint256(IPopRules.PopStatus.PopLite)); assertEq(uint256(priceMetadata.userStatus), uint256(IPopRules.PopStatus.PopFull)); + assertEq(priceMetadata.price, 8 * RENT_PRICE); } function test_poplite_user_can_access_nostatus_name() public { @@ -115,6 +135,7 @@ contract PopRulesTests is BaseDotns { assertEq(uint256(priceMetadata.status), uint256(IPopRules.PopStatus.NoStatus)); assertEq(uint256(priceMetadata.userStatus), uint256(IPopRules.PopStatus.PopLite)); + assertEq(priceMetadata.price, RENT_PRICE); } function test_base_reservation_blocks_others() public { diff --git a/test/unit/registrar/DotnsRegistrarController.t.sol b/test/unit/registrar/DotnsRegistrarController.t.sol index f7635e482..b65b9112e 100644 --- a/test/unit/registrar/DotnsRegistrarController.t.sol +++ b/test/unit/registrar/DotnsRegistrarController.t.sol @@ -181,7 +181,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)); @@ -214,7 +215,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"); diff --git a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol index 73154de7e..0b1af0633 100644 --- a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol +++ b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol @@ -54,8 +54,9 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { 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"); @@ -91,7 +92,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: expectedCharge}(registration); @@ -100,7 +101,7 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { // never their sum. The whole charge routes to the insurance fund; 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" ); @@ -163,7 +164,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); @@ -174,13 +175,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" ); } - 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); @@ -197,14 +198,15 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { 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"); @@ -215,7 +217,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"); } From 954eab5ccb99f7fa7a15cfea5ea71e5390313873 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Fri, 7 Aug 2026 16:52:45 +0200 Subject: [PATCH 02/13] test(invariant): make protocol-fee conservation independent of on-chain state The protocol-fee conservation invariant mirrored the on-chain balance delta into its own tracker, so it compared the escrow balance against an echo of itself and could never catch a miscredit. The handler now accumulates the independently computed charge and quoted transfer fee, and a new invariant asserts those tracked inflows equal the on-chain protocol fees. Dead insurance-draw scaffolding, its recorded-log parsing, and stale insurance wording are removed to match the protocol-fee model. --- README.md | 4 +- contracts/escrow/DotnsNameEscrow.sol | 8 +- contracts/pop/PopRules.sol | 4 +- .../DotnsRegistrarControllerFuzz.t.sol | 7 +- test/intergration/BasicDotns.t.sol | 16 +--- .../escrow/DotnsNameEscrowInvariant.t.sol | 19 ++++- test/invariant/escrow/EscrowHandler.t.sol | 83 ++++--------------- .../DotnsRegistrarControllerInvariant.t.sol | 8 +- test/unit/escrow/DotnsNameEscrow.t.sol | 20 ++--- .../DotnsRegistrarControllerLifecycle.t.sol | 8 +- 10 files changed, 67 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 4410764b0..51074c5d8 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ The price doubles for each character below nine and flattens to D from nine upwa ### Bands and who pays -Three bands share the one curve. Names of nine characters or more are open to anyone at the flat fee D. Names of six to eight characters are the premium band: only a verified person may register there, and they pay the curve for the length, 8D, 4D or 2D. Personhood buys access to the premium band, not a discount inside it. Names of five characters or fewer are reserved to governance; no user registers them, and governance releases them itself at the curve floor into the treasury it already controls. +Three bands share the one curve. Names of nine characters or more are open to anyone at the flat fee D. Names of six to eight characters are the premium band: only a verified person may register there, and they pay the curve for the length, 8D, 4D or 2D. Personhood buys access to the premium band, not a discount inside it. Names of five characters or fewer are reserved to governance; no user registers them, and governance releases them itself at the price their length sets, into the treasury it already controls. -Every caller pays the same curve for a given length. The two zero-cost paths are both bounded. Each verified person gets one free name through the personhood gateway, which is the unpriced lane: the gateway issues that single grant and charges nothing for it. Governance releasing a reserved name pays itself the floor, which settles in a circle into its own treasury and nets nothing. +Every caller pays the same curve for a given length. The two zero-cost paths are both bounded. Each verified person gets one free name through the personhood gateway, which is the unpriced lane: the gateway issues that single grant and charges nothing for it. Governance releasing a reserved name charges itself that length's price, which settles in a circle into its own treasury and nets nothing. ### Deposits and protocol fees diff --git a/contracts/escrow/DotnsNameEscrow.sol b/contracts/escrow/DotnsNameEscrow.sol index a4c99c04f..7c36b2175 100644 --- a/contracts/escrow/DotnsNameEscrow.sol +++ b/contracts/escrow/DotnsNameEscrow.sol @@ -204,8 +204,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)); @@ -304,8 +304,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)); diff --git a/contracts/pop/PopRules.sol b/contracts/pop/PopRules.sol index f700869e6..7d9d42a87 100644 --- a/contracts/pop/PopRules.sol +++ b/contracts/pop/PopRules.sol @@ -269,8 +269,8 @@ contract PopRules is return userStatus >= required; } - /// @notice Scarcity price for a base length: `D * 2 ** (9 - n)` below nine, else the base fee - /// D. @dev The multiplier is at most 512 and arithmetic is checked, so an oversized base fee + /// @notice Scarcity price for a base length: D * 2 ** (9 - n) below nine, else the base fee D. + /// @dev The multiplier is at most 512 and arithmetic is checked, so an oversized base fee /// reverts rather than truncating. function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { if (baseLength >= 9) return startingPrice; diff --git a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol index 760a5d525..6bc0f06b0 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -88,7 +88,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { 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); @@ -402,8 +402,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { return string(abi.encodePacked("nostatus", _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.t.sol b/test/intergration/BasicDotns.t.sol index 943c1a212..25ed24409 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,6 +96,8 @@ contract BasicDotnsIntegration is BaseDotns { /// bag. function _flowEndToEnd(FlowParams memory flow) internal { uint256 quotedPriceBefore = popRules.priceWithCheck(flow.name, flow.nameOwner).price; + // 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); @@ -183,6 +173,8 @@ contract BasicDotnsIntegration is BaseDotns { uint256 transferRecipientQuotedPrice = popRules.priceWithCheck(flow.transferRecipientNewName, flow.transferTo).price; + // 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); diff --git a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol index 04988808e..c7022207b 100644 --- a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol +++ b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol @@ -52,21 +52,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.protocolFees(); + 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" ); } diff --git a/test/invariant/escrow/EscrowHandler.t.sol b/test/invariant/escrow/EscrowHandler.t.sol index 0c7566090..5d7c63146 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 @@ -56,16 +56,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. + /// @notice Cumulative native amount credited into protocol fees by handler-driven flows. /// @dev Increments via cross-tier register (`depositProtocolFee`) 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.protocolFees()`. - uint256 public ghost_insurancePaidOut; + /// (`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; @@ -214,10 +209,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 `depositProtocolFee`. 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 priorProtocolFees = escrow.protocolFees(); + // 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); @@ -231,29 +225,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.protocolFees(); - _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 > priorProtocolFees) { - ghost_insurancePaidIn += (newInsurance - priorProtocolFees); - } - - // 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 +294,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,16 +312,13 @@ contract EscrowHandler is Test { uint256 owed = position.amount; - vm.recordLogs(); vm.prank(recipient); escrow.withdraw(tokenId); - Vm.Log[] memory logs = vm.getRecordedLogs(); // Update ghost state after the inner call so a revert leaves accounting intact. _withdrawnTokenIds.push(tokenId); _removeReleased(index); ghost_pendingCredits += owed; - _accountInsuranceDraws(logs); } /// @notice Pulls the caller's accumulated pending refund balance. @@ -514,24 +497,13 @@ contract EscrowHandler is Test { uint256 requiredFee = registrar.quoteTransferFee(tokenId, to); if (requiredFee == 0) return; - uint256 priorProtocolFees = escrow.protocolFees(); - - 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.protocolFees(); - if (newInsurance > priorProtocolFees) { - ghost_insurancePaidIn += (newInsurance - priorProtocolFees); - } - _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 @@ -667,25 +639,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/registrar/DotnsRegistrarControllerInvariant.t.sol b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol index 75e2e7afb..85ac79f14 100644 --- a/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol +++ b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol @@ -101,9 +101,9 @@ contract DotnsRegistrarControllerInvariantTest is BaseDotns { } function invariant_value_conservation() public view { - // Escrow balance equals reserves + insurance + pending withdrawals. + // Escrow balance equals reserves + protocol fees + pending withdrawals. uint256 reservedAmount = dotnsNameEscrow.reserves(address(0)); - uint256 insurance = dotnsNameEscrow.protocolFees(); + uint256 protocolFees = dotnsNameEscrow.protocolFees(); uint256 pendingTotal; for (uint256 i; i < 5; ++i) { @@ -117,8 +117,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/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index 03c7042c2..d60f8984a 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -439,7 +439,7 @@ contract DotnsNameEscrowTest is BaseDotns { 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 @@ -474,7 +474,7 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq( 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), @@ -526,7 +526,7 @@ contract DotnsNameEscrowTest is BaseDotns { assertEq( dotnsNameEscrow.protocolFees() - protocolFeesBefore, fee, - "fee leg still settles to insurance" + "fee leg still settles to protocol fees" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -616,7 +616,7 @@ contract DotnsNameEscrowTest is BaseDotns { uint256 tokenId = _registerNoStatus(LABEL, ed); uint256 reservesAtStart = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceAtStart = dotnsNameEscrow.protocolFees(); + uint256 protocolFeesAtStart = dotnsNameEscrow.protocolFees(); uint256 edRefundsAtStart = dotnsNameEscrow.pendingRefundCount(ed); uint256 leonardoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(leonardo); uint256 tiagoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(tiago); @@ -650,8 +650,8 @@ contract DotnsNameEscrowTest is BaseDotns { ); assertEq( dotnsNameEscrow.protocolFees(), - insuranceAtStart, - "same-tier hops must not credit insurance" + protocolFeesAtStart, + "same-tier hops must not credit protocol fees" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -713,7 +713,7 @@ 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"; @@ -727,17 +727,17 @@ contract DotnsNameEscrowTest is BaseDotns { uint256 tokenId = _crossPayerRegister(label, leonardo, ed, RENT_PRICE); - // 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.protocolFees() - priorProtocolFees, RENT_PRICE, - "insurance must grow by exactly D on cross-payer NoStatus sponsorship" + "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, diff --git a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol index 0b1af0633..a4b8ac420 100644 --- a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol +++ b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol @@ -98,12 +98,12 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { 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 + // never their sum. The whole charge routes to protocol fees; the refundable // deposit branch is reserved for direct registrants. assertEq( dotnsNameEscrow.protocolFees() - protocolFeesBefore, expectedCharge, - "cross-payer must credit max(ownerPrice, reachFloor) to insurance" + "cross-payer must credit max(ownerPrice, reachFloor) to protocol fees" ); } @@ -140,7 +140,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; @@ -177,7 +177,7 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { assertEq( dotnsNameEscrow.protocolFees() - protocolFeesBefore, ownerPrice, - "cross-payer price must accrue to insurance" + "cross-payer price must accrue to protocol fees" ); } From 25c397d6b00d5e133faf49c77fa02c3d931944d9 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Sat, 8 Aug 2026 03:17:20 +0200 Subject: [PATCH 03/13] fix(pricing): bound the base fee and align transfer-fee accounting The base-fee setter rejects a value large enough to overflow the scarcity multiplier, so short-name pricing and transfers cannot be knocked offline by an oversized fee. Cross-payer registration charges the name's own curve price directly; the floor comparison that could never change the amount is removed. The transfer-fee event fires only when a fee is taken, so a zero-fee position rebind emits nothing. The escrow transfer-fee parameter and its registrar call sites read transferFee to match the value they carry, and the transfer-floor and fee-parameter documentation is completed. --- contracts/escrow/DotnsNameEscrow.sol | 23 +++++++++---------- contracts/escrow/IDotnsNameEscrow.sol | 5 ++-- contracts/pop/IPopRules.sol | 8 +++---- contracts/pop/PopRules.sol | 17 ++++++-------- contracts/registrars/DotnsRegistrar.sol | 19 +++++++-------- .../registrars/DotnsRegistrarController.sol | 4 +--- contracts/registrars/IDotnsRegistrar.sol | 8 +++---- test/invariant/escrow/EscrowHandler.t.sol | 6 ++--- test/unit/escrow/DotnsNameEscrow.t.sol | 2 +- .../DotnsRegistrarControllerLifecycle.t.sol | 7 +++--- 10 files changed, 47 insertions(+), 52 deletions(-) diff --git a/contracts/escrow/DotnsNameEscrow.sol b/contracts/escrow/DotnsNameEscrow.sol index 7c36b2175..e56d04fa6 100644 --- a/contracts/escrow/DotnsNameEscrow.sol +++ b/contracts/escrow/DotnsNameEscrow.sol @@ -264,7 +264,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 @@ -275,21 +275,20 @@ contract DotnsNameEscrow is position.recipient = params.to; } + charged = fee; + if (fee > 0) { protocolFees += fee; + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.to, + fee, + /* isRegistration */ + false + ); } - charged = fee; - - emit CrossTierFeePaid( - params.tokenId, - params.payer, - params.to, - fee, - /* isRegistration */ - false - ); - uint256 overpayment = msg.value - fee; if (overpayment > 0) { _creditRefund(params.payer, overpayment, params.tokenId); diff --git a/contracts/escrow/IDotnsNameEscrow.sol b/contracts/escrow/IDotnsNameEscrow.sol index cef0f0b83..44990fd7e 100644 --- a/contracts/escrow/IDotnsNameEscrow.sol +++ b/contracts/escrow/IDotnsNameEscrow.sol @@ -36,12 +36,13 @@ interface IDotnsNameEscrow { /// 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; } diff --git a/contracts/pop/IPopRules.sol b/contracts/pop/IPopRules.sol index 3f7fb7e96..6986c5ac4 100644 --- a/contracts/pop/IPopRules.sol +++ b/contracts/pop/IPopRules.sol @@ -229,16 +229,16 @@ interface IPopRules { /// @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 what - /// the - /// name is worth, not the open-band floor. The two components overlap on pure tier - /// mismatches, so the function takes their maximum rather than their sum to avoid + /// holds. Passing a name to a wallet that could never have registered it therefore costs + /// what the name is worth, not the open-band floor. 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, diff --git a/contracts/pop/PopRules.sol b/contracts/pop/PopRules.sol index 7d9d42a87..84e7d340c 100644 --- a/contracts/pop/PopRules.sol +++ b/contracts/pop/PopRules.sol @@ -85,6 +85,10 @@ contract PopRules is /// @inheritdoc IPopRules function updateStartingPrice(uint256 newStartingPrice) public override onlyOwner { require(newStartingPrice > 0, PopError("Price must be greater than 0")); + require( + newStartingPrice <= type(uint256).max / 512, + PopError("Price exceeds the scarcity-curve ceiling") + ); emit StartingPriceUpdated(startingPrice, newStartingPrice); startingPrice = newStartingPrice; } @@ -270,8 +274,8 @@ contract PopRules is } /// @notice Scarcity price for a base length: D * 2 ** (9 - n) below nine, else the base fee D. - /// @dev The multiplier is at most 512 and arithmetic is checked, so an oversized base fee - /// reverts rather than truncating. + /// @dev The multiplier is at most 512; @custom:function updateStartingPrice caps the base fee + /// at `type(uint256).max / 512` so the checked multiplication never overflows. function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { if (baseLength >= 9) return startingPrice; return startingPrice * (2 ** (9 - baseLength)); @@ -332,14 +336,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; diff --git a/contracts/registrars/DotnsRegistrar.sol b/contracts/registrars/DotnsRegistrar.sol index ee755cc47..d6428de64 100644 --- a/contracts/registrars/DotnsRegistrar.sol +++ b/contracts/registrars/DotnsRegistrar.sol @@ -251,7 +251,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)); @@ -274,7 +274,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 }) ); @@ -356,8 +356,9 @@ 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 + /// @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. @@ -368,7 +369,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); @@ -378,7 +379,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); } @@ -396,7 +397,7 @@ contract DotnsRegistrar is ) private view - returns (uint256 reachFloor, uint256 requiredFee) + returns (uint256 transferFee, uint256 requiredFee) { if (isEscrowTouching) return (0, 0); @@ -407,9 +408,9 @@ contract DotnsRegistrar is string memory label = LabelUtils.stripDotTld(fullName); if (bytes(label).length == 0) return (0, 0); - 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 f806cdc4c..baa4c0066 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -195,9 +195,7 @@ contract DotnsRegistrarController is ); } - 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(msg.value >= totalCharged, InsufficientValue()); IDotnsReverseResolver reverse; diff --git a/contracts/registrars/IDotnsRegistrar.sol b/contracts/registrars/IDotnsRegistrar.sol index ad8089c56..74f526133 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 @@ -138,7 +138,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. @@ -154,13 +154,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/test/invariant/escrow/EscrowHandler.t.sol b/test/invariant/escrow/EscrowHandler.t.sol index 5d7c63146..6416b6fe0 100644 --- a/test/invariant/escrow/EscrowHandler.t.sol +++ b/test/invariant/escrow/EscrowHandler.t.sol @@ -438,7 +438,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. @@ -489,7 +489,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 @@ -563,7 +563,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. diff --git a/test/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index d60f8984a..2f9b00028 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -510,7 +510,7 @@ contract DotnsNameEscrowTest is BaseDotns { 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 }) ); diff --git a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol index a4b8ac420..2a2e66610 100644 --- a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol +++ b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol @@ -97,13 +97,12 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { 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 protocol fees; 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.protocolFees() - protocolFeesBefore, expectedCharge, - "cross-payer must credit max(ownerPrice, reachFloor) to protocol fees" + "cross-payer must credit the name's own curve price to protocol fees" ); } From 56222cc1fd5e002c87f7d5a33c7f00bdc4ad4df1 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Sun, 9 Aug 2026 23:37:11 +0200 Subject: [PATCH 04/13] docs(pricing): key the free grant to the wallet, any length The free name is claimed per wallet through the personhood gateway, which waives the price. The gateway lane accepts a name of any length and applies no public band gate; it only refuses the governance-reserved stems of five characters or fewer. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51074c5d8..25ffe1d44 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The price doubles for each character below nine and flattens to D from nine upwa Three bands share the one curve. Names of nine characters or more are open to anyone at the flat fee D. Names of six to eight characters are the premium band: only a verified person may register there, and they pay the curve for the length, 8D, 4D or 2D. Personhood buys access to the premium band, not a discount inside it. Names of five characters or fewer are reserved to governance; no user registers them, and governance releases them itself at the price their length sets, into the treasury it already controls. -Every caller pays the same curve for a given length. The two zero-cost paths are both bounded. Each verified person gets one free name through the personhood gateway, which is the unpriced lane: the gateway issues that single grant and charges nothing for it. Governance releasing a reserved name charges itself that length's price, which settles in a circle into its own treasury and nets nothing. +Every caller pays the same curve for a given length. Each wallet gets one free name through the personhood gateway, the unpriced lane: the gateway waives the price and issues that grant per wallet, and the name can be any length. The bands above are the public curve; the gateway lane does not apply them and only refuses the governance-reserved stems of five characters or fewer. Governance releasing a reserved name charges itself that length's price, which settles in a circle into its own treasury and nets nothing. ### Deposits and protocol fees From 3585208b64d584489d7cf9ce6f649f0476bad338 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Sat, 22 Aug 2026 21:02:34 +0200 Subject: [PATCH 05/13] chore: ensure D and F are configurable --- README.md | 30 ++-- contracts/pop/IPopRules.sol | 45 ++++- contracts/pop/PopRules.sol | 70 ++++++-- contracts/utils/DotnsConstants.sol | 19 ++- scripts/deploy/DeployRecords.s.sol | 6 +- scripts/deploy/DotnsDeployer.s.sol | 6 +- test/base/BaseDotns.t.sol | 19 ++- test/fuzz/pop/PopFuzz.t.sol | 13 +- .../DotnsRegistrarControllerFuzz.t.sol | 5 +- .../NoStatusDepositLifecycle.t.sol | 2 +- .../pop/PopRulesGovernanceHandler.t.sol | 34 ++++ .../pop/PopRulesGovernanceInvariant.t.sol | 48 ++++++ test/unit/escrow/DotnsNameEscrow.t.sol | 6 +- test/unit/pop/PopRules.t.sol | 159 ++++++++++++++++-- .../registrar/DotnsRegistrarController.t.sol | 26 +++ 15 files changed, 419 insertions(+), 69 deletions(-) create mode 100644 test/invariant/pop/PopRulesGovernanceHandler.t.sol create mode 100644 test/invariant/pop/PopRulesGovernanceInvariant.t.sol diff --git a/README.md b/README.md index f0e71716a..0dc6678bf 100644 --- a/README.md +++ b/README.md @@ -34,25 +34,31 @@ If a run fails partway, re-run it from the Actions tab; the draft is updated rat Every price comes from one number. The base fee D is set in the native token and equals ten DOT at launch. A name's price depends only on its base length, the character count once a trailing number is set aside: -price(n) = D · 2^(9 − n) for a base length n below nine, and D for n of nine or more. +$$ +\text{price}(n) = \max\!\left(F,\; D \cdot 2^{\,9 - n}\right) +$$ -The price doubles for each character below nine and flattens to D from nine upward. Short names are scarce, so they cost more, and the curve follows how the count of available names falls as they shorten. A trailing number never changes the price, because it comes off before the length is measured, so `andrew` and `andrew01` both cost 8D. A name carries no digits or exactly two; any other trailing-digit count is rejected. +The price doubles for each character below nine and halves for each character above it, down to the floor F. Short names are scarce, so they cost more; long names are abundant, so each extra character costs less, until the floor F stops the price reaching zero. Governance configures both values: D anchors the curve at nine, F is the least a name can cost. A trailing number never changes the price, because it comes off before the length is measured, so `andrew` and `andrew01` both price as `andrew`, at 8D. A name carries no digits or exactly two; any other trailing-digit count is rejected. | Base length | Price | |---|---| -| 9 or more | D | -| 8 | 2D | -| 7 | 4D | | 6 | 8D | -| 5 | 16D | -| 4 | 32D | -| 3 | 64D | +| 7 | 4D | +| 8 | 2D | +| 9 | D | +| 10 | D/2 | +| 11 | D/4 | +| n above 9 | D / 2^(n − 9), down to the floor F | ### Bands and who pays -Three bands share the one curve. Names of nine characters or more are open to anyone at the flat fee D. Names of six to eight characters are the premium band: only a verified person may register there, and they pay the curve for the length, 8D, 4D or 2D. Personhood buys access to the premium band, not a discount inside it. Names of five characters or fewer are reserved to governance; no user registers them, and governance releases them itself at the price their length sets, into the treasury it already controls. +Three bands share the one curve. Names of nine characters or more are open to anyone at the curve price for the length, D at nine and less for each character above it. Names of six to eight characters are the premium band: only a verified person may register there, and they pay the curve for the length, 8D, 4D or 2D. Personhood only unlocks that band; the price there is the same curve everyone pays. Names of five characters or fewer are reserved to governance; no user registers them, and governance releases them itself at the price their length sets, into the treasury it already controls. + +Names shorter than nine are closed on the public curve by default. A paid registration of a base length below nine reverts until governance opens the short-name market with a single switch; while it is off no caller buys a name shorter than nine, and while it is on the two short bands above apply, six to eight to verified persons and five or fewer to governance alone. The switch defaults off, so at launch only names of nine characters or more are for sale. It gates the public paid path alone: the personhood gateway's free grant is unpriced and issues names of any length either way, and governance's own reserved releases and the operator whitelist do not pass through it. + +The switch gates primary purchase, not transfer. A transferable name still moves whatever the switch reads, re-priced at its own length through the transfer floor below, so a short name registered on the public curve still transfers at its scarcity price after the market closes. -Every caller pays the same curve for a given length. Each wallet gets one free name through the personhood gateway, the unpriced lane: the gateway waives the price and issues that grant per wallet, and the name can be any length. The bands above are the public curve; the gateway lane does not apply them and only refuses the governance-reserved stems of five characters or fewer. Governance releasing a reserved name charges itself that length's price, which settles in a circle into its own treasury and nets nothing. +Every caller pays the same curve for a given length. Each wallet gets one free name through the personhood gateway, the unpriced lane: the gateway waives the price and issues that grant per wallet, and the name can be any length. The bands above are the public curve; the gateway lane does not apply them and only refuses the governance-reserved stems of five characters or fewer. Governance releasing a reserved name charges itself that length's price, which returns to the treasury it controls and nets nothing. ### Deposits and protocol fees @@ -62,13 +68,13 @@ A fee is non-refundable. Two things pay a fee instead of a deposit: a name someo ### Transfers re-price at the name's own length -Moving a name re-prices it from scratch at its own length on every move. Passing a six-character name to a wallet that could never have registered it costs 8D, the name's own price, not the flat D floor. `andrew` and `andrew01` re-derive to the same 8D. The exit price equals what the name was worth to acquire, so there is no cheap way to hand a scarce name to a party who could not have earned it. A move between parties who both clear the name's band costs nothing, and the fee, when one is owed, settles into protocol fees. +Moving a name re-prices it from scratch at its own length on every move. Passing a six-character name to a wallet that could never have registered it costs 8D, the name's own price. `andrew` and `andrew01` re-derive to the same 8D. The exit price equals what the name was worth to acquire, so there is no cheap way to hand a scarce name to a party who could not have earned it. A move between parties who both clear the name's band costs nothing, and the fee, when one is owed, settles into protocol fees. 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 deposit. Transferring a funded name hands the locked deposit to the recipient along with the token. ### What governance controls -Everything hangs off D. Governance can move D, but only inside a fixed band and by at most a set multiple per vote, so it can neither drop the price to zero, which would free every short name, nor raise it to an extractive level, and any change is legible several votes ahead. Governance sets how long the interval on the free grant runs, and chooses where fees go, a burn or the treasury, from that fixed pair. Nothing in that surface lets governance seize, reassign or destroy a name anyone already holds. +Everything hangs off D. Governance sets D and the floor F to whatever values it chooses; the contracts hold them coherent and nothing more, requiring D above zero and F in the range one to D, so the curve can never invert and no name ever prices at zero. They put no ceiling on how high or low D goes, nor any limit on how fast it moves, so the economic policy is the owner's to set and any rate limit or advance notice comes from the governance process, not from these contracts. Governance also sets the floor F, opens or closes the short-name market with the switch above (while it is closed no name shorter than nine is for sale), sets how long the interval on the free grant runs, and chooses where fees go, a burn or the treasury, from that fixed pair. None of these controls lets governance seize, reassign or destroy a name anyone already holds. ### Refund and cooldown model diff --git a/contracts/pop/IPopRules.sol b/contracts/pop/IPopRules.sol index 6986c5ac4..f7131db0f 100644 --- a/contracts/pop/IPopRules.sol +++ b/contracts/pop/IPopRules.sol @@ -12,9 +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 geometric scarcity curve on the base length: price(n) = D * 2^(9 - n) below -/// nine characters and the base fee D at nine and above. Every caller pays the same curve for -/// a given length; personhood buys access to the premium band, not a discount inside it. +/// Pricing is a geometric scarcity curve on the base length: price(n) = D * 2^(9 - n), +/// halved for each character above nine and floored at `minPrice`. Every caller pays the same +/// curve for a given length; personhood only unlocks the premium band. /// @custom:security-contact admin@parity.io interface IPopRules { /// @notice Proof-of-Personhood eligibility tier. @@ -41,6 +41,18 @@ interface IPopRules { /// @param newPrice New wei value. event StartingPriceUpdated(uint256 oldPrice, uint256 newPrice); + /// @notice Emitted when the price floor F is changed. + /// @dev Owner-only setter @custom:function updateMinPrice. + /// @param oldMinPrice Previous wei value. + /// @param newMinPrice New wei value. + event MinPriceUpdated(uint256 oldMinPrice, uint256 newMinPrice); + + /// @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. error PopError(string reason); @@ -93,6 +105,24 @@ interface IPopRules { /// @param newStartingPrice New base price in wei. function updateStartingPrice(uint256 newStartingPrice) external; + /// @notice Sets the price floor F, the least any name can cost on the scarcity curve. + /// @dev Owner-only; unauthorised callers trigger @custom:reverts OwnableUnauthorizedAccount. + /// `newMinPrice` must be strictly positive and no greater than the base fee D, otherwise + /// @custom:reverts PopError. A base length below nine always prices above D, so the floor + /// only binds from nine characters upward. Emits @custom:emits MinPriceUpdated. + /// @param newMinPrice New price floor in wei. + function updateMinPrice(uint256 newMinPrice) external; + + /// @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 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 @@ -189,7 +219,7 @@ interface IPopRules { /// @notice Calculates price with PoP classification and reservation enforcement. /// @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 gates access to the premium band rather than discounting it. Non-canonical + /// 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. @@ -230,7 +260,7 @@ interface IPopRules { /// 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 - /// what the name is worth, not the open-band floor. The two components overlap on pure + /// 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 @@ -257,8 +287,9 @@ interface IPopRules { function isBaseName(string calldata name) external pure returns (bool isBase); /// @notice Calculates registration cost for a label. - /// @dev Prices the label on the scarcity curve by base length: D * 2^(9 - n) for a base length - /// below nine, and the base fee D at nine and above. Ignores the caller's personhood + /// @dev Prices the label on the scarcity curve by base length: D * 2^(9 - n) below nine, + /// halved for each character from nine upward and floored at `minPrice`. 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. diff --git a/contracts/pop/PopRules.sol b/contracts/pop/PopRules.sol index 84e7d340c..b44784b35 100644 --- a/contracts/pop/PopRules.sol +++ b/contracts/pop/PopRules.sol @@ -20,13 +20,15 @@ import {IPersonhood} from "../external/personhood/IPersonhood.sol"; /// @title PopRules /// @notice Implements DotNS classification, scarcity pricing on a geometric curve, and base-name /// reservations. -/// @dev Tier shape: base lengths <= 5 are governance-reserved, base lengths 6-8 require PopFull +/// @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. /// Every caller pays the same curve for a given base length: price(n) = D * 2^(9 - n) below -/// nine characters, and the base fee D at nine and above, where D is `startingPrice`. -/// Personhood buys access to the premium band, not a discount inside it. +/// nine characters, and D halved for each character from nine upward, never below the floor +/// `minPrice`, where D is `startingPrice`. 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, @@ -37,7 +39,7 @@ contract PopRules is { using StringUtils for *; - /// @notice Base fee D in wei. Base lengths >= 9 pay D; shorter base lengths pay D * 2^(9 - n). + /// @notice Base fee D in wei: the scarcity curve's value at nine characters. uint256 public startingPrice; /// @notice Active reservations keyed by digit-stripped base name. @@ -49,6 +51,14 @@ contract PopRules is /// @notice Protocol-level address registry for all DotNS contracts. IDotnsProtocolRegistry public protocolRegistry; + /// @notice Price floor F in wei: the least any name can cost, so a long base length decays + /// towards it rather than to zero. Never above `startingPrice`. + uint256 public minPrice; + + /// @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; @@ -68,9 +78,11 @@ contract PopRules is /// InvalidInitialization via the `initializer` modifier. Seeds `startingPrice` through /// @custom:function updateStartingPrice. /// @param _startingPrice Base fee D in wei anchoring the scarcity curve, paid by every caller. + /// @param _minPrice Price floor F in wei; the least any name can cost. Must not exceed D. /// @param registry Protocol-level address registry used to resolve sibling contracts. function initialize( uint256 _startingPrice, + uint256 _minPrice, IDotnsProtocolRegistry registry ) public @@ -78,7 +90,10 @@ contract PopRules is { __Ownable_init(msg.sender); __ERC165_init(); + // Seed values only; governance sets the live curve through @custom:function + // updateStartingPrice and @custom:function updateMinPrice after deployment. updateStartingPrice(_startingPrice); + updateMinPrice(_minPrice); protocolRegistry = registry; } @@ -89,10 +104,25 @@ contract PopRules is newStartingPrice <= type(uint256).max / 512, PopError("Price exceeds the scarcity-curve ceiling") ); + require(newStartingPrice >= minPrice, PopError("Base fee cannot fall below the floor")); emit StartingPriceUpdated(startingPrice, newStartingPrice); startingPrice = newStartingPrice; } + /// @inheritdoc IPopRules + function updateMinPrice(uint256 newMinPrice) public override onlyOwner { + require(newMinPrice > 0, PopError("Floor must be greater than 0")); + require(newMinPrice <= startingPrice, PopError("Floor cannot exceed the base fee")); + emit MinPriceUpdated(minPrice, newMinPrice); + minPrice = newMinPrice; + } + + /// @inheritdoc IPopRules + function setShortNamesEnabled(bool enabled) external override onlyOwner { + shortNamesEnabled = enabled; + emit ShortNamesEnabledUpdated(enabled); + } + /// @inheritdoc IPopRules function classifyName(string calldata name) external @@ -168,6 +198,7 @@ contract PopRules is (PopStatus requiredStatus, string memory classification, uint256 baseLength) = _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); PopStatus userStatus = _personhoodTier(userAddress); metadata.price = _priceValidatedName(baseLength); @@ -195,6 +226,7 @@ contract PopRules is (PopStatus requiredStatus, string memory classification, uint256 baseLength) = _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); PopStatus userStatus = _personhoodTier(userAddress); metadata.price = _priceValidatedName(baseLength); @@ -273,16 +305,32 @@ contract PopRules is return userStatus >= required; } - /// @notice Scarcity price for a base length: D * 2 ** (9 - n) below nine, else the base fee D. - /// @dev The multiplier is at most 512; @custom:function updateStartingPrice caps the base fee - /// at `type(uint256).max / 512` so the checked multiplication never overflows. + /// @notice Scarcity price for a base length, floored at `minPrice`. + /// @dev Below nine the multiplier is at most 512, and @custom:function updateStartingPrice caps + /// D at `type(uint256).max / 512` so the checked multiplication cannot overflow. From nine + /// upward the price is D right-shifted by `n - 9`, so it only ever decreases; base lengths + /// cap at 63 characters, so the shift is at most 54 and never reaches the word width. The + /// floor stops a long base length costing nothing and is `updateMinPrice`-bounded to D, so + /// it only binds from nine upward, never in the doubling range below nine. function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { - if (baseLength >= 9) return startingPrice; - return startingPrice * (2 ** (9 - baseLength)); + uint256 curve = baseLength < 9 + ? startingPrice * (2 ** (9 - baseLength)) + : startingPrice >> (baseLength - 9); + return curve < minPrice ? minPrice : curve; + } + + /// @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, the single source pricing - /// and classification both derive a name's band from. + /// @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) { diff --git a/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index d535936d0..8260d21f8 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -30,15 +30,20 @@ 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. + /// @notice Initial-deployment base fee D passed into `PopRules.initialize` as `_startingPrice`. + /// @dev Purely the value the contract is seeded with at deployment: 10 DOT under revive's + /// 18-decimal Asset Hub convention. Governance sets the live value through + /// `PopRules.updateStartingPrice`, so this is never read after deployment and consumers + /// are not rebuilt against a new constant. Single source of truth for deploy scripts and + /// tests so the seed cannot drift between call sites. uint256 internal constant RENT_PRICE = 10 ether; + /// @notice Initial-deployment price floor F passed into `PopRules.initialize` as `_minPrice`. + /// @dev Purely the seed value: below `RENT_PRICE` so the curve falls above nine characters. + /// Governance sets the live floor through `PopRules.updateMinPrice`; never read after + /// deployment. + uint256 internal constant MIN_PRICE = 0.1 ether; + /// @notice Operational role allowed to manage the public controller whitelist. /// @dev Holders can grant or revoke whitelist entries, but cannot upgrade contracts /// or change protocol configuration. diff --git a/scripts/deploy/DeployRecords.s.sol b/scripts/deploy/DeployRecords.s.sol index cc5dd2632..94ec141b7 100644 --- a/scripts/deploy/DeployRecords.s.sol +++ b/scripts/deploy/DeployRecords.s.sol @@ -78,7 +78,11 @@ contract DeployRecords is BaseDeployer { "PopRules.sol:PopRules", abi.encodeCall( PopRules.initialize, - (DotnsConstants.RENT_PRICE, IDotnsProtocolRegistry(protocolRegistry)) + ( + DotnsConstants.RENT_PRICE, + DotnsConstants.MIN_PRICE, + IDotnsProtocolRegistry(protocolRegistry) + ) ), "PopRules" ); diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index 7cc9442df..ec4c8b896 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -271,7 +271,11 @@ contract DotnsDeployer is BaseDeployer { "PopRules.sol:PopRules", abi.encodeCall( PopRules.initialize, - (DotnsConstants.RENT_PRICE, IDotnsProtocolRegistry(protocolRegistryProxy)) + ( + DotnsConstants.RENT_PRICE, + DotnsConstants.MIN_PRICE, + IDotnsProtocolRegistry(protocolRegistryProxy) + ) ), "PopRules" ); diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index 105ebfa9b..e50c5247f 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -142,6 +142,11 @@ abstract contract BaseDotns is Test { /// directly. uint256 public constant RENT_PRICE = DotnsConstants.RENT_PRICE; + /// @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. uint256 public constant ESCROW_COOLDOWN = 15 minutes; @@ -171,10 +176,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 @@ -252,10 +257,14 @@ abstract contract BaseDotns is Test { vm.label(dotnsContentResolverAddress, "DotnsContentResolver"); address popRulesAddress = Upgrades.deployUUPSProxy( - "PopRules.sol:PopRules", abi.encodeCall(PopRules.initialize, (RENT_PRICE, registry)) + "PopRules.sol:PopRules", + abi.encodeCall(PopRules.initialize, (RENT_PRICE, MIN_PRICE, 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)) diff --git a/test/fuzz/pop/PopFuzz.t.sol b/test/fuzz/pop/PopFuzz.t.sol index d01330e74..b07733666 100644 --- a/test/fuzz/pop/PopFuzz.t.sol +++ b/test/fuzz/pop/PopFuzz.t.sol @@ -42,13 +42,22 @@ contract PopRulesFuzzTest is BaseDotns { } function testFuzz_price_matches_curve(uint256 seed, uint256 length) public view { - length = bound(length, 3, 20); + length = bound(length, 3, 63); string memory nameLabel = _makeAlpha(seed, length); - uint256 expected = length >= 9 ? RENT_PRICE : RENT_PRICE * (2 ** (9 - length)); + uint256 curve = length >= 9 ? RENT_PRICE >> (length - 9) : RENT_PRICE * (2 ** (9 - length)); + uint256 expected = curve < MIN_PRICE ? MIN_PRICE : curve; assertEq(popRules.price(nameLabel), expected); } + 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/DotnsRegistrarControllerFuzz.t.sol b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol index 6bc0f06b0..d3b9a003a 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -397,9 +397,10 @@ 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 an 8-char PopLite-tier label (base length 6) that prices at 8D on the diff --git a/test/intergration/NoStatusDepositLifecycle.t.sol b/test/intergration/NoStatusDepositLifecycle.t.sol index cca852c63..9b65aee08 100644 --- a/test/intergration/NoStatusDepositLifecycle.t.sol +++ b/test/intergration/NoStatusDepositLifecycle.t.sol @@ -16,7 +16,7 @@ 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; diff --git a/test/invariant/pop/PopRulesGovernanceHandler.t.sol b/test/invariant/pop/PopRulesGovernanceHandler.t.sol new file mode 100644 index 000000000..932588815 --- /dev/null +++ b/test/invariant/pop/PopRulesGovernanceHandler.t.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {PopRules} from "../../../contracts/pop/PopRules.sol"; + +/// @title PopRules Governance Handler +/// @notice Drives bounded random governance updates against PopRules for the coherence invariant. +/// @dev Owns PopRules for the campaign so the onlyOwner setters accept its calls. Every input is +/// bound into the setters' valid range, so the handler rejects almost nothing. +contract PopRulesGovernanceHandler is Test { + PopRules private popRules; + + constructor(PopRules popRules_) { + popRules = popRules_; + } + + /// @notice Sets D within the guarded range: at least the current floor, at most the ceiling. + function setStartingPrice(uint256 seed) external { + uint256 value = bound(seed, popRules.minPrice(), type(uint256).max / 512); + popRules.updateStartingPrice(value); + } + + /// @notice Sets F within the guarded range: at least one wei, at most the current base fee. + function setMinPrice(uint256 seed) external { + uint256 value = bound(seed, 1, popRules.startingPrice()); + popRules.updateMinPrice(value); + } + + /// @notice Opens or closes the short-name market. + function setShortNames(bool enabled) external { + popRules.setShortNamesEnabled(enabled); + } +} diff --git a/test/invariant/pop/PopRulesGovernanceInvariant.t.sol b/test/invariant/pop/PopRulesGovernanceInvariant.t.sol new file mode 100644 index 000000000..62de2fb47 --- /dev/null +++ b/test/invariant/pop/PopRulesGovernanceInvariant.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {BaseDotns} from "../../base/BaseDotns.t.sol"; +import {PopRulesGovernanceHandler} from "./PopRulesGovernanceHandler.t.sol"; + +/// @title PopRules Governance Invariant Suite +/// @notice Asserts the curve stays coherent after any reachable sequence of governance updates: +/// the floor stays within the base fee, no name prices at zero, and price never increases +/// with length. This is the on-chain proof that governance cannot reach a broken state. +contract PopRulesGovernanceInvariantTest is BaseDotns { + /// @notice Handler driving randomised governance updates. Owns PopRules for the campaign. + PopRulesGovernanceHandler public handler; + + function setUp() public override { + super.setUp(); + handler = new PopRulesGovernanceHandler(popRules); + vm.prank(owner); + popRules.transferOwnership(address(handler)); + targetContract(address(handler)); + + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = handler.setStartingPrice.selector; + selectors[1] = handler.setMinPrice.selector; + selectors[2] = handler.setShortNames.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + /// @notice The floor is always positive and never exceeds the base fee. + function invariant_floor_within_base_fee() public view { + assertGt(popRules.minPrice(), 0); + assertLe(popRules.minPrice(), popRules.startingPrice()); + } + + /// @notice Price never reaches zero and never increases as the base length grows. + function invariant_price_stays_coherent() public view { + uint256 p3 = popRules.price("cat"); + uint256 p8 = popRules.price("alicebob"); + uint256 p9 = popRules.price("abcdefghi"); + uint256 p12 = popRules.price("longnamehere"); + uint256 p19 = popRules.price("thisisaverylongname"); + assertGt(p19, 0); + assertGe(p3, p8); + assertGe(p8, p9); + assertGe(p9, p12); + assertGe(p12, p19); + } +} diff --git a/test/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index 2f9b00028..cd91049a5 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -34,7 +34,7 @@ contract GhostNft is ERC721 { 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"; + string internal constant LABEL = "labelnine01"; /// @notice Register `label` for `nameOwner` under the NoStatus PoP tier and return its tokenId. function _registerNoStatus( @@ -175,7 +175,7 @@ contract DotnsNameEscrowTest is BaseDotns { } function test_self_registration_seeds_funded_position() public { - string memory popLabel = "popfullname"; + string memory popLabel = "selfregis"; bytes32 node = _register(popLabel, ed, IPopRules.PopStatus.PopFull); uint256 tokenId = uint256(node); @@ -716,7 +716,7 @@ contract DotnsNameEscrowTest is BaseDotns { /// 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. diff --git a/test/unit/pop/PopRules.t.sol b/test/unit/pop/PopRules.t.sol index 478ececb4..4204e8bf9 100644 --- a/test/unit/pop/PopRules.t.sol +++ b/test/unit/pop/PopRules.t.sol @@ -4,6 +4,9 @@ pragma solidity ^0.8.34; import {BaseDotns} from "../../base/BaseDotns.t.sol"; import {IPopRules} from "../../../contracts/pop/IPopRules.sol"; import {IDotnsController} from "../../../contracts/registrars/IDotnsController.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,23 +72,8 @@ contract PopRulesTests is BaseDotns { popRules.classifyName("andrew123"); } - function test_price_follows_scarcity_curve() public view { - assertEq(popRules.price("cat"), 64 * RENT_PRICE); - assertEq(popRules.price("hello"), 16 * RENT_PRICE); - assertEq(popRules.price("lights"), 8 * RENT_PRICE); - assertEq(popRules.price("abcdefg"), 4 * RENT_PRICE); - assertEq(popRules.price("alicebob"), 2 * RENT_PRICE); - } - - function test_open_band_is_flat_at_base_fee() public view { - assertEq(popRules.price("ninechars"), RENT_PRICE); - assertEq(popRules.price("longnamehere"), RENT_PRICE); - assertEq(popRules.price("thisisaverylongname"), RENT_PRICE); - } - function test_trailing_digits_do_not_change_price() public view { - assertEq(popRules.price("andrew"), 8 * RENT_PRICE); - assertEq(popRules.price("andrew01"), 8 * RENT_PRICE); + assertEq(popRules.price("andrew01"), popRules.price("andrew")); } function test_verified_person_pays_the_curve_for_premium() public { @@ -135,7 +123,7 @@ contract PopRulesTests is BaseDotns { assertEq(uint256(priceMetadata.status), uint256(IPopRules.PopStatus.NoStatus)); assertEq(uint256(priceMetadata.userStatus), uint256(IPopRules.PopStatus.PopLite)); - assertEq(priceMetadata.price, RENT_PRICE); + assertEq(priceMetadata.price, RENT_PRICE / 8); // longnamehere is 12 characters } function test_base_reservation_blocks_others() public { @@ -172,6 +160,143 @@ 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, RENT_PRICE / 8); + } + + 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, 2 * RENT_PRICE); + } + + 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_updateMinPrice_sets_floor_and_emits() public { + vm.expectEmit(address(popRules)); + emit IPopRules.MinPriceUpdated(MIN_PRICE, 1 ether); + vm.prank(owner); + popRules.updateMinPrice(1 ether); + assertEq(popRules.minPrice(), 1 ether); + } + + function test_updateMinPrice_reverts_for_zero() public { + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Floor must be greater than 0") + ); + popRules.updateMinPrice(0); + } + + function test_updateMinPrice_reverts_above_base_fee() public { + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Floor cannot exceed the base fee") + ); + popRules.updateMinPrice(RENT_PRICE + 1); + } + + function test_updateMinPrice_only_owner() public { + vm.prank(ed); + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, ed) + ); + popRules.updateMinPrice(1 ether); + } + + function test_updateStartingPrice_cannot_fall_below_floor() public { + vm.prank(owner); + popRules.updateMinPrice(1 ether); + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector( + IPopRules.PopError.selector, "Base fee cannot fall below the floor" + ) + ); + popRules.updateStartingPrice(0.5 ether); + } + + function test_lowering_floor_extends_the_decay() public { + // Governance drops the floor to one wei, so the long-name price falls below the seeded + // floor. + vm.prank(owner); + popRules.updateMinPrice(1); + assertEq(popRules.price("thisisaverylongname"), RENT_PRICE >> 10); + } + + function test_updateStartingPrice_sets_and_emits() public { + vm.expectEmit(address(popRules)); + emit IPopRules.StartingPriceUpdated(RENT_PRICE, 20 ether); + vm.prank(owner); + popRules.updateStartingPrice(20 ether); + assertEq(popRules.startingPrice(), 20 ether); + } + + function test_updateStartingPrice_reverts_for_zero() public { + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector(IPopRules.PopError.selector, "Price must be greater than 0") + ); + popRules.updateStartingPrice(0); + } + + function test_updateStartingPrice_reverts_above_ceiling() public { + vm.prank(owner); + vm.expectRevert( + abi.encodeWithSelector( + IPopRules.PopError.selector, "Price exceeds the scarcity-curve ceiling" + ) + ); + popRules.updateStartingPrice(type(uint256).max / 512 + 1); + } + + function test_updateStartingPrice_only_owner() public { + vm.prank(ed); + vm.expectRevert( + abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, ed) + ); + popRules.updateStartingPrice(20 ether); + } + function test_reserveBaseNameForPop_reverts_for_non_controller() public { vm.prank(ed); vm.expectRevert(IPopRules.NotRegistry.selector); diff --git a/test/unit/registrar/DotnsRegistrarController.t.sol b/test/unit/registrar/DotnsRegistrarController.t.sol index b65b9112e..770b6cc6a 100644 --- a/test/unit/registrar/DotnsRegistrarController.t.sol +++ b/test/unit/registrar/DotnsRegistrarController.t.sol @@ -415,6 +415,32 @@ 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 + }); + 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; From b2276bd81716f8597a5fe4eceadb64931b109bba Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 26 Aug 2026 21:54:56 +0200 Subject: [PATCH 06/13] fix: model update --- CONTRIBUTING.md | 10 +- contracts/pop/DotnsCostModelRegistry.sol | 74 +++++ contracts/pop/DotnsScarcityPricing.sol | 73 +++++ contracts/pop/IDotnsCostModelRegistry.sol | 80 ++++++ contracts/pop/IDotnsPricing.sol | 28 ++ contracts/pop/IPopRules.sol | 93 +++--- contracts/pop/PopRules.sol | 186 +++++++----- .../registrars/DotnsRegistrarController.sol | 19 +- .../registrars/IDotnsRegistrarController.sol | 29 +- contracts/utils/DotnsConstants.sol | 26 +- scripts/deploy/DeployRecords.s.sol | 36 ++- scripts/deploy/DotnsDeployer.s.sol | 47 ++- scripts/deploy/WireDeployments.s.sol | 6 + scripts/shell/pre-commit.sh | 24 ++ test/base/BaseDotns.t.sol | 26 +- test/fuzz/escrow/DotnsNameEscrowFuzz.t.sol | 7 +- .../registrar/DotnsPopControllerFuzz.t.sol | 7 +- .../DotnsRegistrarControllerFuzz.t.sol | 7 +- test/intergration/BasicDotns.reverts.t.sol | 7 +- test/intergration/BasicDotns.t.sol | 7 +- test/intergration/WhitelistOperatorFlow.t.sol | 7 +- test/invariant/escrow/EscrowHandler.t.sol | 21 +- .../pop/CostModelVersionHandler.t.sol | 147 ++++++++++ .../pop/CostModelVersionInvariant.t.sol | 69 +++++ .../pop/PopRulesGovernanceHandler.t.sol | 34 --- .../pop/PopRulesGovernanceInvariant.t.sol | 48 ---- .../RegistrarControllerHandler.t.sol | 21 +- test/invariant/registry/RegistryHandler.t.sol | 7 +- test/unit/escrow/DotnsNameEscrow.t.sol | 13 +- test/unit/pop/DotnsCostModelRegistry.t.sol | 150 ++++++++++ test/unit/pop/DotnsScarcityPricing.t.sol | 97 +++++++ test/unit/pop/PopRules.t.sol | 109 +++---- test/unit/registrar/DotnsPopController.t.sol | 14 +- .../registrar/DotnsRegistrarController.t.sol | 267 +++++++++++++++++- .../DotnsRegistrarControllerLifecycle.t.sol | 69 ++++- 35 files changed, 1516 insertions(+), 349 deletions(-) create mode 100644 contracts/pop/DotnsCostModelRegistry.sol create mode 100644 contracts/pop/DotnsScarcityPricing.sol create mode 100644 contracts/pop/IDotnsCostModelRegistry.sol create mode 100644 contracts/pop/IDotnsPricing.sol create mode 100644 test/invariant/pop/CostModelVersionHandler.t.sol create mode 100644 test/invariant/pop/CostModelVersionInvariant.t.sol delete mode 100644 test/invariant/pop/PopRulesGovernanceHandler.t.sol delete mode 100644 test/invariant/pop/PopRulesGovernanceInvariant.t.sol create mode 100644 test/unit/pop/DotnsCostModelRegistry.t.sol create mode 100644 test/unit/pop/DotnsScarcityPricing.t.sol diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47bfc8e42..0da54335c 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/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/DotnsScarcityPricing.sol b/contracts/pop/DotnsScarcityPricing.sol new file mode 100644 index 000000000..63f3a8634 --- /dev/null +++ b/contracts/pop/DotnsScarcityPricing.sol @@ -0,0 +1,73 @@ +// 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`. +/// @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 Thrown when a constructor parameter breaks a curve invariant. + /// @param reason Human-readable explanation of the failed invariant. + error PricingError(string reason); + + /// @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..fc9eebfa2 --- /dev/null +++ b/contracts/pop/IDotnsCostModelRegistry.sol @@ -0,0 +1,80 @@ +// 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 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..63a4edb61 --- /dev/null +++ b/contracts/pop/IDotnsPricing.sol @@ -0,0 +1,28 @@ +// 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 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 f7131db0f..dcc14d6c2 100644 --- a/contracts/pop/IPopRules.sol +++ b/contracts/pop/IPopRules.sol @@ -12,9 +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 geometric scarcity curve on the base length: price(n) = D * 2^(9 - n), -/// halved for each character above nine and floored at `minPrice`. Every caller pays the same -/// curve for a given length; personhood only unlocks the premium band. +/// 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. @@ -34,19 +34,6 @@ interface IPopRules { /// @param expires UNIX timestamp when the reservation expires. event BaseNameReserved(string indexed baseName, address indexed owner, uint64 expires); - /// @notice Emitted when the base fee D is changed. - /// @dev Owner-only setter @custom:function updateStartingPrice; the new value is the base of - /// the scarcity curve 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 price floor F is changed. - /// @dev Owner-only setter @custom:function updateMinPrice. - /// @param oldMinPrice Previous wei value. - /// @param newMinPrice New wei value. - event MinPriceUpdated(uint256 oldMinPrice, uint256 newMinPrice); - /// @notice Emitted when the public market for names shorter than nine characters is opened or /// closed. /// @dev Owner-only setter @custom:function setShortNamesEnabled. @@ -96,23 +83,6 @@ interface IPopRules { pure returns (PopStatus requirement, string memory message); - /// @notice Updates the base fee D that anchors the scarcity curve. - /// @dev Owner-only; unauthorised callers trigger @custom:reverts - /// OwnableUnauthorizedAccount. `newStartingPrice` must be strictly positive, otherwise - /// @custom:reverts PopError. The new value anchors the curve in `_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 Sets the price floor F, the least any name can cost on the scarcity curve. - /// @dev Owner-only; unauthorised callers trigger @custom:reverts OwnableUnauthorizedAccount. - /// `newMinPrice` must be strictly positive and no greater than the base fee D, otherwise - /// @custom:reverts PopError. A base length below nine always prices above D, so the floor - /// only binds from nine characters upward. Emits @custom:emits MinPriceUpdated. - /// @param newMinPrice New price floor in wei. - function updateMinPrice(uint256 newMinPrice) external; - /// @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 @@ -234,6 +204,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 @@ -254,6 +245,25 @@ interface IPopRules { view returns (PriceWithMeta memory metadata); + /// @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 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 @@ -287,12 +297,19 @@ interface IPopRules { function isBaseName(string calldata name) external pure returns (bool isBase); /// @notice Calculates registration cost for a label. - /// @dev Prices the label on the scarcity curve by base length: D * 2^(9 - n) below nine, - /// halved for each character from nine upward and floored at `minPrice`. 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. + /// @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 b44784b35..5caf15233 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,17 +19,17 @@ import {DotnsConstants} from "../utils/DotnsConstants.sol"; import {IPersonhood} from "../external/personhood/IPersonhood.sol"; /// @title PopRules -/// @notice Implements DotNS classification, scarcity pricing on a geometric curve, and base-name -/// reservations. +/// @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. -/// Every caller pays the same curve for a given base length: price(n) = D * 2^(9 - n) below -/// nine characters, and D halved for each character from nine upward, never below the floor -/// `minPrice`, where D is `startingPrice`. 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. +/// 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, @@ -39,9 +40,6 @@ contract PopRules is { using StringUtils for *; - /// @notice Base fee D in wei: the scarcity curve's value at nine characters. - uint256 public startingPrice; - /// @notice Active reservations keyed by digit-stripped base name. mapping(string baseName => Reservation reservation) public reservations; @@ -51,10 +49,6 @@ contract PopRules is /// @notice Protocol-level address registry for all DotNS contracts. IDotnsProtocolRegistry public protocolRegistry; - /// @notice Price floor F in wei: the least any name can cost, so a long base length decays - /// towards it rather than to zero. Never above `startingPrice`. - uint256 public minPrice; - /// @notice Whether the public paid path may register names shorter than nine characters. /// Closed by default; only governance opens it. bool public shortNamesEnabled; @@ -75,48 +69,15 @@ 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 fee D in wei anchoring the scarcity curve, paid by every caller. - /// @param _minPrice Price floor F in wei; the least any name can cost. Must not exceed D. + /// 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, - uint256 _minPrice, - IDotnsProtocolRegistry registry - ) - public - initializer - { + function initialize(IDotnsProtocolRegistry registry) public initializer { __Ownable_init(msg.sender); __ERC165_init(); - // Seed values only; governance sets the live curve through @custom:function - // updateStartingPrice and @custom:function updateMinPrice after deployment. - updateStartingPrice(_startingPrice); - updateMinPrice(_minPrice); protocolRegistry = registry; } - /// @inheritdoc IPopRules - function updateStartingPrice(uint256 newStartingPrice) public override onlyOwner { - require(newStartingPrice > 0, PopError("Price must be greater than 0")); - require( - newStartingPrice <= type(uint256).max / 512, - PopError("Price exceeds the scarcity-curve ceiling") - ); - require(newStartingPrice >= minPrice, PopError("Base fee cannot fall below the floor")); - emit StartingPriceUpdated(startingPrice, newStartingPrice); - startingPrice = newStartingPrice; - } - - /// @inheritdoc IPopRules - function updateMinPrice(uint256 newMinPrice) public override onlyOwner { - require(newMinPrice > 0, PopError("Floor must be greater than 0")); - require(newMinPrice <= startingPrice, PopError("Floor cannot exceed the base fee")); - emit MinPriceUpdated(minPrice, newMinPrice); - minPrice = newMinPrice; - } - /// @inheritdoc IPopRules function setShortNamesEnabled(bool enabled) external override onlyOwner { shortNamesEnabled = enabled; @@ -192,6 +153,64 @@ 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); @@ -201,7 +220,9 @@ contract PopRules is _requireShortNamesOpen(baseLength); PopStatus userStatus = _personhoodTier(userAddress); - metadata.price = _priceValidatedName(baseLength); + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); metadata.status = requiredStatus; metadata.userStatus = userStatus; metadata.message = classification; @@ -212,14 +233,18 @@ contract PopRules is 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); @@ -229,7 +254,9 @@ contract PopRules is _requireShortNamesOpen(baseLength); PopStatus userStatus = _personhoodTier(userAddress); - metadata.price = _priceValidatedName(baseLength); + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); metadata.status = requiredStatus; metadata.userStatus = userStatus; metadata.message = classification; @@ -251,6 +278,11 @@ contract PopRules is return _priceValidatedName(_validatedBaseLength(name)); } + /// @inheritdoc IPopRules + function pricingVersion() external view override returns (uint256 modelVersion) { + return _costModelRegistry().currentVersion(); + } + /// @inheritdoc IPopRules function transferFloor( string calldata name, @@ -305,18 +337,36 @@ contract PopRules is return userStatus >= required; } - /// @notice Scarcity price for a base length, floored at `minPrice`. - /// @dev Below nine the multiplier is at most 512, and @custom:function updateStartingPrice caps - /// D at `type(uint256).max / 512` so the checked multiplication cannot overflow. From nine - /// upward the price is D right-shifted by `n - 9`, so it only ever decreases; base lengths - /// cap at 63 characters, so the shift is at most 54 and never reaches the word width. The - /// floor stops a long base length costing nothing and is `updateMinPrice`-bounded to D, so - /// it only binds from nine upward, never in the doubling range below nine. + /// @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) { - uint256 curve = baseLength < 9 - ? startingPrice * (2 ** (9 - baseLength)) - : startingPrice >> (baseLength - 9); - return curve < minPrice ? minPrice : curve; + 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 diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index 382fee858..cb45fa3c0 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -130,7 +130,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 ) ); } @@ -179,9 +184,13 @@ 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) { @@ -196,6 +205,10 @@ contract DotnsRegistrarController is } uint256 totalCharged = priced.price; + require( + totalCharged <= registration.maxPrice, + PriceExceedsMax(registration.label, totalCharged, registration.maxPrice) + ); require(msg.value >= totalCharged, InsufficientValue()); IDotnsReverseResolver reverse; diff --git a/contracts/registrars/IDotnsRegistrarController.sol b/contracts/registrars/IDotnsRegistrarController.sol index 76ed7a3e6..eb0f84bc4 100644 --- a/contracts/registrars/IDotnsRegistrarController.sol +++ b/contracts/registrars/IDotnsRegistrarController.sol @@ -21,11 +21,17 @@ 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. struct Registration { string label; address owner; bytes32 secret; bool reserved; + uint256 maxPrice; + uint256 pricingVersion; } /// @notice Emitted when a commitment is submitted. @@ -100,6 +106,12 @@ interface IDotnsRegistrarController is IDotnsController { /// @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 +133,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 @@ -154,12 +168,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 + /// 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 caller must supply at least the charge + /// 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 6d3ef4ea1..3c7c7580a 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -30,20 +30,26 @@ library DotnsConstants { /// forge-lint: disable-next-line(unsafe-typecast) bytes32 internal constant PERSONHOOD_CONTEXT = bytes32("dotns"); - /// @notice Initial-deployment base fee D passed into `PopRules.initialize` as `_startingPrice`. - /// @dev Purely the value the contract is seeded with at deployment: 10 DOT under revive's - /// 18-decimal Asset Hub convention. Governance sets the live value through - /// `PopRules.updateStartingPrice`, so this is never read after deployment and consumers - /// are not rebuilt against a new constant. Single source of truth for deploy scripts and - /// tests so the seed cannot drift between call sites. + /// @notice Base fee D passed into the `DotnsScarcityPricing` constructor. + /// @dev The curve's value at nine characters: 10 DOT under revive's 18-decimal Asset Hub + /// convention. A new curve 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 RENT_PRICE = 10 ether; - /// @notice Initial-deployment price floor F passed into `PopRules.initialize` as `_minPrice`. - /// @dev Purely the seed value: below `RENT_PRICE` so the curve falls above nine characters. - /// Governance sets the live floor through `PopRules.updateMinPrice`; never read after - /// deployment. + /// @notice Price floor F passed into the `DotnsScarcityPricing` constructor. + /// @dev Below `RENT_PRICE` so the curve falls above nine characters. Seeds the model + /// 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 curve 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 Operational role allowed to manage the public controller whitelist. /// @dev Holders can grant or revoke whitelist entries, but cannot upgrade contracts /// or change protocol configuration. diff --git a/scripts/deploy/DeployRecords.s.sol b/scripts/deploy/DeployRecords.s.sol index 94ec141b7..ca1f1471e 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 {DotnsScarcityPricing} from "../../contracts/pop/DotnsScarcityPricing.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,29 @@ contract DeployRecords is BaseDeployer { ); } + /// @notice Deploys the scarcity 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. + function _deployCostModelStack(address owner) internal returns (address registry) { + address model = _broadcastDeployCreate3( + owner, + "DotnsScarcityPricing.sol:DotnsScarcityPricing", + abi.encode(DotnsConstants.RENT_PRICE, DotnsConstants.MIN_PRICE), + "DotnsScarcityPricing" + ); + registry = _broadcastDeployCreate3( + owner, + "DotnsCostModelRegistry.sol:DotnsCostModelRegistry", + abi.encode(owner), + "DotnsCostModelRegistry" + ); + + vm.startBroadcast(owner); + DotnsCostModelRegistry(registry).register(IDotnsPricing(model)); + vm.stopBroadcast(); + } + function _deployPopRules( address owner, address protocolRegistry @@ -76,14 +103,7 @@ contract DeployRecords is BaseDeployer { proxy = _broadcastDeployUups( owner, "PopRules.sol:PopRules", - abi.encodeCall( - PopRules.initialize, - ( - DotnsConstants.RENT_PRICE, - DotnsConstants.MIN_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 26a3cde98..1a081cf16 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 {DotnsScarcityPricing} from "../../contracts/pop/DotnsScarcityPricing.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"; @@ -77,6 +81,8 @@ contract DotnsDeployer is BaseDeployer { address contentResolver; address resolver; address popRules; + address costModel; + address costModelRegistry; address registrarController; address protocolRegistry; address nameEscrow; @@ -122,6 +128,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 = @@ -263,6 +270,28 @@ contract DotnsDeployer is BaseDeployer { dotnsResolver = DotnsResolver(proxy); } + function _deployCostModelStack(address owner) + internal + returns (address model, address registry) + { + model = _broadcastDeployCreate3( + owner, + "DotnsScarcityPricing.sol:DotnsScarcityPricing", + abi.encode(DotnsConstants.RENT_PRICE, DotnsConstants.MIN_PRICE), + "DotnsScarcityPricing" + ); + registry = _broadcastDeployCreate3( + owner, + "DotnsCostModelRegistry.sol:DotnsCostModelRegistry", + abi.encode(owner), + "DotnsCostModelRegistry" + ); + + vm.startBroadcast(owner); + DotnsCostModelRegistry(registry).register(IDotnsPricing(model)); + vm.stopBroadcast(); + } + function _deployPopRules( address owner, address protocolRegistryProxy @@ -273,14 +302,7 @@ contract DotnsDeployer is BaseDeployer { proxy = _broadcastDeployUups( owner, "PopRules.sol:PopRules", - abi.encodeCall( - PopRules.initialize, - ( - DotnsConstants.RENT_PRICE, - DotnsConstants.MIN_PRICE, - IDotnsProtocolRegistry(protocolRegistryProxy) - ) - ), + abi.encodeCall(PopRules.initialize, (IDotnsProtocolRegistry(protocolRegistryProxy))), "PopRules" ); popRules = PopRules(proxy); @@ -394,6 +416,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); @@ -433,6 +458,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.RENT_PRICE, + "CostModel: pivot price mismatch" + ); console.log("=== Deployment verification complete ==="); } @@ -516,6 +546,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 59c5f1d5f..0c29f880f 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; @@ -81,6 +82,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"); @@ -109,6 +111,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); @@ -200,6 +205,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/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 292aa206b..b391ca267 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -4,6 +4,9 @@ pragma solidity ^0.8.34; import {Test} from "forge-std/Test.sol"; import {PopRules, IPopRules} from "../../contracts/pop/PopRules.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, @@ -61,6 +64,12 @@ abstract contract BaseDotns is Test { /// @notice Deployed PoP oracle instance. PopRules public popRules; + /// @notice Deployed scarcity pricing model seeding the cost-model registry. + 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; @@ -256,9 +265,14 @@ abstract contract BaseDotns is Test { dotnsContentResolver = DotnsContentResolver(dotnsContentResolverAddress); vm.label(dotnsContentResolverAddress, "DotnsContentResolver"); + scarcityPricing = new DotnsScarcityPricing(RENT_PRICE, MIN_PRICE); + vm.label(address(scarcityPricing), "DotnsScarcityPricing"); + costModelRegistry = new DotnsCostModelRegistry(owner); + vm.label(address(costModelRegistry), "DotnsCostModelRegistry"); + costModelRegistry.register(IDotnsPricing(address(scarcityPricing))); + address popRulesAddress = Upgrades.deployUUPSProxy( - "PopRules.sol:PopRules", - abi.encodeCall(PopRules.initialize, (RENT_PRICE, MIN_PRICE, registry)) + "PopRules.sol:PopRules", abi.encodeCall(PopRules.initialize, (registry)) ); popRules = PopRules(popRulesAddress); vm.label(popRulesAddress, "PopRules"); @@ -315,6 +329,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); @@ -689,7 +704,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/registrar/DotnsPopControllerFuzz.t.sol b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol index 558017451..c633fcbad 100644 --- a/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsPopControllerFuzz.t.sol @@ -77,7 +77,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 d3b9a003a..852c11c28 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -380,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); diff --git a/test/intergration/BasicDotns.reverts.t.sol b/test/intergration/BasicDotns.reverts.t.sol index 5c892d3d7..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); diff --git a/test/intergration/BasicDotns.t.sol b/test/intergration/BasicDotns.t.sol index 25ed24409..8cdfa755a 100644 --- a/test/intergration/BasicDotns.t.sol +++ b/test/intergration/BasicDotns.t.sol @@ -233,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/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/EscrowHandler.t.sol b/test/invariant/escrow/EscrowHandler.t.sol index 6416b6fe0..9a53f63bb 100644 --- a/test/invariant/escrow/EscrowHandler.t.sol +++ b/test/invariant/escrow/EscrowHandler.t.sol @@ -115,7 +115,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); @@ -187,7 +192,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); @@ -399,7 +409,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); 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/pop/PopRulesGovernanceHandler.t.sol b/test/invariant/pop/PopRulesGovernanceHandler.t.sol deleted file mode 100644 index 932588815..000000000 --- a/test/invariant/pop/PopRulesGovernanceHandler.t.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {Test} from "forge-std/Test.sol"; -import {PopRules} from "../../../contracts/pop/PopRules.sol"; - -/// @title PopRules Governance Handler -/// @notice Drives bounded random governance updates against PopRules for the coherence invariant. -/// @dev Owns PopRules for the campaign so the onlyOwner setters accept its calls. Every input is -/// bound into the setters' valid range, so the handler rejects almost nothing. -contract PopRulesGovernanceHandler is Test { - PopRules private popRules; - - constructor(PopRules popRules_) { - popRules = popRules_; - } - - /// @notice Sets D within the guarded range: at least the current floor, at most the ceiling. - function setStartingPrice(uint256 seed) external { - uint256 value = bound(seed, popRules.minPrice(), type(uint256).max / 512); - popRules.updateStartingPrice(value); - } - - /// @notice Sets F within the guarded range: at least one wei, at most the current base fee. - function setMinPrice(uint256 seed) external { - uint256 value = bound(seed, 1, popRules.startingPrice()); - popRules.updateMinPrice(value); - } - - /// @notice Opens or closes the short-name market. - function setShortNames(bool enabled) external { - popRules.setShortNamesEnabled(enabled); - } -} diff --git a/test/invariant/pop/PopRulesGovernanceInvariant.t.sol b/test/invariant/pop/PopRulesGovernanceInvariant.t.sol deleted file mode 100644 index 62de2fb47..000000000 --- a/test/invariant/pop/PopRulesGovernanceInvariant.t.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.34; - -import {BaseDotns} from "../../base/BaseDotns.t.sol"; -import {PopRulesGovernanceHandler} from "./PopRulesGovernanceHandler.t.sol"; - -/// @title PopRules Governance Invariant Suite -/// @notice Asserts the curve stays coherent after any reachable sequence of governance updates: -/// the floor stays within the base fee, no name prices at zero, and price never increases -/// with length. This is the on-chain proof that governance cannot reach a broken state. -contract PopRulesGovernanceInvariantTest is BaseDotns { - /// @notice Handler driving randomised governance updates. Owns PopRules for the campaign. - PopRulesGovernanceHandler public handler; - - function setUp() public override { - super.setUp(); - handler = new PopRulesGovernanceHandler(popRules); - vm.prank(owner); - popRules.transferOwnership(address(handler)); - targetContract(address(handler)); - - bytes4[] memory selectors = new bytes4[](3); - selectors[0] = handler.setStartingPrice.selector; - selectors[1] = handler.setMinPrice.selector; - selectors[2] = handler.setShortNames.selector; - targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); - } - - /// @notice The floor is always positive and never exceeds the base fee. - function invariant_floor_within_base_fee() public view { - assertGt(popRules.minPrice(), 0); - assertLe(popRules.minPrice(), popRules.startingPrice()); - } - - /// @notice Price never reaches zero and never increases as the base length grows. - function invariant_price_stays_coherent() public view { - uint256 p3 = popRules.price("cat"); - uint256 p8 = popRules.price("alicebob"); - uint256 p9 = popRules.price("abcdefghi"); - uint256 p12 = popRules.price("longnamehere"); - uint256 p19 = popRules.price("thisisaverylongname"); - assertGt(p19, 0); - assertGe(p3, p8); - assertGe(p8, p9); - assertGe(p9, p12); - assertGe(p12, p19); - } -} diff --git a/test/invariant/registrar/RegistrarControllerHandler.t.sol b/test/invariant/registrar/RegistrarControllerHandler.t.sol index 3f5822d2c..205c2c1c9 100644 --- a/test/invariant/registrar/RegistrarControllerHandler.t.sol +++ b/test/invariant/registrar/RegistrarControllerHandler.t.sol @@ -168,7 +168,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); @@ -214,7 +219,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); @@ -247,7 +257,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); 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 cd91049a5..c09856568 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -442,14 +442,14 @@ contract DotnsNameEscrowTest is BaseDotns { // 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 + // transfer forces `PopRules.transferFloor` to return the base fee D while the position // still carries the original `RENT_PRICE` 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"); @@ -505,7 +505,7 @@ contract DotnsNameEscrowTest is BaseDotns { 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}( @@ -692,7 +692,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); 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/DotnsScarcityPricing.t.sol b/test/unit/pop/DotnsScarcityPricing.t.sol new file mode 100644 index 000000000..fd092433f --- /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"; + +/// @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( + DotnsScarcityPricing.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( + DotnsScarcityPricing.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( + DotnsScarcityPricing.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( + DotnsScarcityPricing.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 4204e8bf9..3cb5468e9 100644 --- a/test/unit/pop/PopRules.t.sol +++ b/test/unit/pop/PopRules.t.sol @@ -2,8 +2,13 @@ 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"; @@ -211,90 +216,50 @@ contract PopRulesTests is BaseDotns { popRules.setShortNamesEnabled(false); } - function test_updateMinPrice_sets_floor_and_emits() public { - vm.expectEmit(address(popRules)); - emit IPopRules.MinPriceUpdated(MIN_PRICE, 1 ether); - vm.prank(owner); - popRules.updateMinPrice(1 ether); - assertEq(popRules.minPrice(), 1 ether); - } - - function test_updateMinPrice_reverts_for_zero() public { - vm.prank(owner); - vm.expectRevert( - abi.encodeWithSelector(IPopRules.PopError.selector, "Floor must be greater than 0") - ); - popRules.updateMinPrice(0); - } - - function test_updateMinPrice_reverts_above_base_fee() public { - vm.prank(owner); - vm.expectRevert( - abi.encodeWithSelector(IPopRules.PopError.selector, "Floor cannot exceed the base fee") - ); - popRules.updateMinPrice(RENT_PRICE + 1); - } - - function test_updateMinPrice_only_owner() public { - vm.prank(ed); - vm.expectRevert( - abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, ed) - ); - popRules.updateMinPrice(1 ether); + function test_price_matches_model() public view { + string memory label = "thisisaverylongname"; + uint256 baseLength = bytes(label).length; + assertEq(popRules.price(label), scarcityPricing.priceForBaseLength(baseLength)); + assertEq(popRules.price(label), costModelRegistry.priceForBaseLength(baseLength)); } - function test_updateStartingPrice_cannot_fall_below_floor() public { - vm.prank(owner); - popRules.updateMinPrice(1 ether); - vm.prank(owner); - vm.expectRevert( - abi.encodeWithSelector( - IPopRules.PopError.selector, "Base fee cannot fall below the floor" - ) - ); - popRules.updateStartingPrice(0.5 ether); + function test_priceWithCheck_matches_model() public view { + string memory label = "longnamehere"; + IPopRules.PriceWithMeta memory metadata = popRules.priceWithCheck(label, ed); + assertEq(metadata.price, scarcityPricing.priceForBaseLength(bytes(label).length)); } - function test_lowering_floor_extends_the_decay() public { - // Governance drops the floor to one wei, so the long-name price falls below the seeded - // floor. - vm.prank(owner); - popRules.updateMinPrice(1); - assertEq(popRules.price("thisisaverylongname"), RENT_PRICE >> 10); + function test_transferFloor_matches_model() public { + // A PopFull sender handing a NoStatus name to a NoStatus recipient pays the name's own + // curve price, which is the model amount for its base length. + string memory label = "longnamehere"; + _grantPopFull(ed); + uint256 floor = popRules.transferFloor(label, ed, leonardo); + assertEq(floor, scarcityPricing.priceForBaseLength(bytes(label).length)); } - function test_updateStartingPrice_sets_and_emits() public { - vm.expectEmit(address(popRules)); - emit IPopRules.StartingPriceUpdated(RENT_PRICE, 20 ether); - vm.prank(owner); - popRules.updateStartingPrice(20 ether); - assertEq(popRules.startingPrice(), 20 ether); + function test_pricingVersion_matches_registry() public view { + assertEq(popRules.pricingVersion(), costModelRegistry.currentVersion()); + assertEq(popRules.pricingVersion(), scarcityPricing.version()); } - function test_updateStartingPrice_reverts_for_zero() public { - vm.prank(owner); - vm.expectRevert( - abi.encodeWithSelector(IPopRules.PopError.selector, "Price must be greater than 0") + 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)) ); - popRules.updateStartingPrice(0); - } - - function test_updateStartingPrice_reverts_above_ceiling() public { - vm.prank(owner); - vm.expectRevert( - abi.encodeWithSelector( - IPopRules.PopError.selector, "Price exceeds the scarcity-curve ceiling" - ) + address freshPopRules = Upgrades.deployUUPSProxy( + "PopRules.sol:PopRules", + abi.encodeCall(PopRules.initialize, (IDotnsProtocolRegistry(freshRegistry))) ); - popRules.updateStartingPrice(type(uint256).max / 512 + 1); - } + vm.stopPrank(); - function test_updateStartingPrice_only_owner() public { - vm.prank(ed); vm.expectRevert( - abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, ed) + abi.encodeWithSelector(IPopRules.PopError.selector, "Cost model not configured") ); - popRules.updateStartingPrice(20 ether); + PopRules(freshPopRules).price("longnamehere"); } function test_reserveBaseNameForPop_reverts_for_non_controller() public { diff --git a/test/unit/registrar/DotnsPopController.t.sol b/test/unit/registrar/DotnsPopController.t.sol index 48817ca8e..ff3ae988a 100644 --- a/test/unit/registrar/DotnsPopController.t.sol +++ b/test/unit/registrar/DotnsPopController.t.sol @@ -411,7 +411,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); @@ -467,7 +472,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 770b6cc6a..90a135ed2 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); @@ -207,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); @@ -234,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); @@ -262,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); @@ -292,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); @@ -397,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); @@ -429,7 +482,12 @@ contract DotnsRegistrarControllerTest is BaseDotns { bytes32 secret = keccak256(abi.encodePacked(nameLabel, nameOwner, "gate-closed")); 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); dotnsRegistrarController.commit(commitment); @@ -457,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); @@ -481,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); @@ -600,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); @@ -775,4 +848,170 @@ 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(RENT_PRICE * 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_for_unregistered_committed_version() public { + string memory label = "unknownver01"; + address nameOwner = ed; + uint256 unknownVersion = uint256(keccak256("never registered version")); + + IDotnsRegistrarController.Registration memory registration = + _nostatusRegistration(label, nameOwner, type(uint256).max, unknownVersion); + + _commitRegistrationAndWaitMinimumAge(registration); + + vm.prank(nameOwner); + vm.expectRevert( + abi.encodeWithSelector(IDotnsCostModelRegistry.UnknownVersion.selector, unknownVersion) + ); + dotnsRegistrarController.register{value: RENT_PRICE}(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(RENT_PRICE * 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 version. + DotnsScarcityPricing modelV3 = new DotnsScarcityPricing(RENT_PRICE * 4, MIN_PRICE); + uint256 firstVersion = scarcityPricing.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 2a2e66610..4a5761251 100644 --- a/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol +++ b/test/unit/registrar/DotnsRegistrarControllerLifecycle.t.sol @@ -47,7 +47,12 @@ 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); @@ -84,7 +89,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); @@ -126,7 +133,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); @@ -155,7 +164,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); @@ -190,7 +201,9 @@ 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); @@ -243,7 +256,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)); @@ -277,7 +292,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); @@ -288,7 +308,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); @@ -331,7 +356,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); @@ -374,7 +401,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)); @@ -422,7 +451,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)); @@ -464,7 +495,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); @@ -473,7 +506,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)); @@ -523,7 +558,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)); @@ -535,7 +572,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)); From e93b4294a6915240ca2cb46a7c61482a176c490f Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Wed, 26 Aug 2026 22:00:01 +0200 Subject: [PATCH 07/13] chore: update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0dc6678bf..93af7906c 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Every caller pays the same curve for a given length. Each wallet gets one free n 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 the depositor, so it travels with the token on every transfer and unlocks only when the current holder releases the name back to escrow. Holding many names ties up capital on the same curve, and the cost rises with scarcity because the price does. -A fee is non-refundable. Two things pay a fee instead of a deposit: a name someone else pays for, and a transfer. Fees flow into one protocol fee pot that only accumulates. Governance can route the pot to one of a fixed pair, a burn or the on-chain treasury, and to nowhere else. A holder's own deposit is their money held in trust and is never swept into fees. +A fee is non-refundable. Two things pay a fee instead of a deposit: a name someone else pays for, and a transfer. Fees flow into one protocol fee pot in the escrow that only accumulates. A holder's own deposit is their money held in trust and is never swept into fees. ### Transfers re-price at the name's own length @@ -74,7 +74,7 @@ The deposit, when present, is bound to the name and rides with it on every trans ### What governance controls -Everything hangs off D. Governance sets D and the floor F to whatever values it chooses; the contracts hold them coherent and nothing more, requiring D above zero and F in the range one to D, so the curve can never invert and no name ever prices at zero. They put no ceiling on how high or low D goes, nor any limit on how fast it moves, so the economic policy is the owner's to set and any rate limit or advance notice comes from the governance process, not from these contracts. Governance also sets the floor F, opens or closes the short-name market with the switch above (while it is closed no name shorter than nine is for sale), sets how long the interval on the free grant runs, and chooses where fees go, a burn or the treasury, from that fixed pair. None of these controls lets governance seize, reassign or destroy a name anyone already holds. +Everything hangs off D. Governance sets D and the floor F to whatever values it chooses; the contracts hold them coherent and nothing more, requiring D above zero and F in the range one to D, so the curve can never invert and no name ever prices at zero. They put no ceiling on how high or low D goes, nor any limit on how fast it moves, so the economic policy is the owner's to set and any rate limit or advance notice comes from the governance process, not from these contracts. Governance also sets the floor F, opens or closes the short-name market with the switch above (while it is closed no name shorter than nine is for sale), and sets how long the interval on the free grant runs. None of these controls lets governance seize, reassign or destroy a name anyone already holds. ### Refund and cooldown model From 745fffe4bb27cb1d486a86eb310b6a3e520936b3 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 27 Aug 2026 03:16:17 +0200 Subject: [PATCH 08/13] fix: manifest --- deployments/paseo-assethub/420420417.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index 86d7e43c9..65adf44b2 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","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0xfe5A45f7fD58D1A6FE09455DB799405b1dcE9411","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","DotnsScarcityPricing":"0xC4138a4048B099c86CeDE583C0150c01D7A1496f","LabelStoreBeacon":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","RootGatewayDispatcher":"0xa889CCA3Fb4B07b98a11cc54C10f13dDA20bc3db","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} From 4d9f342e7b2c6e9c3787ec6a34f5d0d131da59b7 Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Thu, 27 Aug 2026 15:52:17 +0200 Subject: [PATCH 09/13] fix: deployment script --- scripts/deploy/DeployRecords.s.sol | 12 +++++++++--- scripts/deploy/DotnsDeployer.s.sol | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/deploy/DeployRecords.s.sol b/scripts/deploy/DeployRecords.s.sol index ca1f1471e..da383019b 100644 --- a/scripts/deploy/DeployRecords.s.sol +++ b/scripts/deploy/DeployRecords.s.sol @@ -88,9 +88,15 @@ contract DeployRecords is BaseDeployer { "DotnsCostModelRegistry" ); - vm.startBroadcast(owner); - DotnsCostModelRegistry(registry).register(IDotnsPricing(model)); - vm.stopBroadcast(); + // 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( diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index 382583b43..e19ad665d 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -297,9 +297,15 @@ contract DotnsDeployer is BaseDeployer { "DotnsCostModelRegistry" ); - vm.startBroadcast(owner); - DotnsCostModelRegistry(registry).register(IDotnsPricing(model)); - vm.stopBroadcast(); + // 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( From a136f09a5166204c980b6554201abe8970dbe10d Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 31 Aug 2026 14:58:21 +0200 Subject: [PATCH 10/13] chore: address PR comments --- README.md | 82 ++++++++----------- contracts/pop/DotnsCostModelRegistry.sol | 24 ++++-- contracts/pop/DotnsFlatPricing.sol | 45 ++++++++++ contracts/pop/DotnsScarcityPricing.sol | 8 +- contracts/pop/IDotnsCostModelRegistry.sol | 21 +++-- contracts/pop/IDotnsPricing.sol | 4 + contracts/pop/IPopRules.sol | 22 ++++- .../registrars/DotnsRegistrarController.sol | 35 +++++++- .../registrars/IDotnsRegistrarController.sol | 32 ++------ contracts/utils/DotnsConstants.sol | 18 ++-- foundry.toml | 5 ++ scripts/deploy/DeployRecords.s.sol | 13 +-- scripts/deploy/DotnsDeployer.s.sol | 10 +-- test/base/BaseDotns.t.sol | 14 +++- test/fuzz/pop/PopFuzz.t.sol | 7 +- .../RegistrarControllerHandler.t.sol | 48 ++++++++++- test/unit/escrow/DotnsNameEscrow.t.sol | 4 +- test/unit/escrow/DotnsNameEscrowRedeem.t.sol | 4 +- test/unit/pop/DotnsCostModelRegistry.t.sol | 38 ++++++++- test/unit/pop/DotnsFlatPricing.t.sol | 57 +++++++++++++ test/unit/pop/DotnsScarcityPricing.t.sol | 10 +-- test/unit/pop/PopRules.t.sol | 26 +++--- .../registrar/DotnsRegistrarController.t.sol | 22 +++-- 23 files changed, 395 insertions(+), 154 deletions(-) create mode 100644 contracts/pop/DotnsFlatPricing.sol create mode 100644 test/unit/pop/DotnsFlatPricing.t.sol diff --git a/README.md b/README.md index e1ece43cd..743197934 100644 --- a/README.md +++ b/README.md @@ -32,77 +32,65 @@ If a run fails partway, re-run it from the Actions tab; the draft is updated rat ## Economics -Every price comes from one number: a name's base length, the character count once a trailing number is set aside. One geometric curve turns that length into a price. The base fee D anchors the curve at nine characters and equals 10 DOT at launch; the floor F is the least a name can cost and equals 0.1 DOT. - -$$ -\text{price}(n) = \max\!\left(F,\; D \cdot 2^{\,9 - n}\right) -$$ - -The price doubles for each character below nine and halves for each character above it, down to the floor. Short names are scarce, so they cost more; long names are plentiful, so each extra character costs less, until the floor stops the price reaching 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. ### Base length and the digit rule -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 price as a six-character name, at 80 DOT. An all-letter ten-character name such as `andrewsays` is base length 10 and prices at 5 DOT, and `andrewsays01` strips to the same ten-character stem and also prices at 5 DOT. +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. ### What a name costs -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, so the nine-and-above rows are the live ones. With D at 10 DOT the curve gives: - -| Base length | Curve | Price | On public sale by default | -|---|---|---|---| -| 6 | 8D | 80 DOT | No, held behind the short-name switch | -| 7 | 4D | 40 DOT | No, held behind the short-name switch | -| 8 | 2D | 20 DOT | No, held behind the short-name switch | -| 9 | D | 10 DOT | Yes | -| 10 | D / 2 | 5 DOT | Yes | -| 11 | D / 4 | 2.5 DOT | Yes | -| 12 | D / 8 | 1.25 DOT | Yes | -| 13 | D / 16 | 0.625 DOT | Yes | -| 14 | D / 32 | 0.3125 DOT | Yes | -| 15 | D / 64 | 0.15625 DOT | Yes | -| 16 and above | floor F | 0.1 DOT | Yes | +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: -Each character above nine halves the price. At base length 15 the curve is D/64 = 0.15625 DOT, still above the floor; at 16 it would be D/128 = 0.078 DOT, below the floor, so every name of 16 characters or more settles at F = 0.1 DOT. +| 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 | ### Who can register a name -Three bands share the one curve. +Three bands share the one deposit. | Base length | Who may register on the public paid path | Price | |---|---|---| -| 9 or more | anyone, as NoStatus | 10 DOT at nine, halving each character above | -| 6 to 8 | a verified person, and only while the short-name switch is on | 80, 40 or 20 DOT | -| 5 or fewer | nobody on the curve | not sold; issued at zero base cost through the reserved path | +| 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. The price is the same curve everyone pays, not a premium on top of it. +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. -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 on the curve. +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. -Names of five characters or fewer are never sold on the curve. The public path 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. +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 -Every amount below assumes D at 10 DOT. The path decides whether the amount is a refundable deposit, a non-refundable fee, or nothing at all. +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 | 5 DOT | refundable deposit | -| `andrew` | 6 | public, own key (switch on, full person) | 80 DOT | refundable deposit | -| `alicebob42` | 8 | public, own key (switch on, lite person) | 20 DOT | refundable deposit | +| `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 | 80 DOT | protocol fee | +| `andrew`, moved to a wallet that cannot clear its band | 6 | transfer | 10 DOT | protocol fee | ### Deposits and protocol fees -Registering a name under your own key locks a refundable deposit equal to the name's curve 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. Holding many names ties up capital, and the cost rises with scarcity because the price does. +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. -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 curve 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. +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. ### Transfers re-price at the name's own length -A transfer charges the name's own curve 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 80 DOT, the name's own price, so there is no cheap way to hand a scarce 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. +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. The cost-model registry admits Root or the owner, so the economics track that governs pricing drives it without the registrar's own key. ### Release lifecycle @@ -182,21 +170,21 @@ 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 at the scarcity-curve price for the length, 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. 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 curve price for a registrable example at D = 10 DOT; a rejected or reserved name has no curve price. +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 on curve; 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 | 80 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 | 20 DOT | Eight-character stem plus two digits; total length is ten. Gateway grant is free. | -| andrew | andrew | 0 | PopFull | PopFull user | 80 DOT | Canonical full-person base name; priced only while the short-name switch is on. | +| 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 | 5 DOT | Base length 10, so the curve gives D/2; the amount is a refundable deposit. | -| andrewsays01 | andrewsays | 2 | NoStatus | Anyone | 5 DOT | Long stem remains NoStatus even with a two-digit suffix, and prices at the same D/2. | +| 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. | diff --git a/contracts/pop/DotnsCostModelRegistry.sol b/contracts/pop/DotnsCostModelRegistry.sol index 86842da0d..442b4bb4d 100644 --- a/contracts/pop/DotnsCostModelRegistry.sol +++ b/contracts/pop/DotnsCostModelRegistry.sol @@ -5,12 +5,14 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IDotnsCostModelRegistry} from "./IDotnsCostModelRegistry.sol"; import {IDotnsPricing} from "./IDotnsPricing.sol"; +import {SystemUtils} from "../utils/SystemUtils.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. +/// @dev Holds only pointers. `PopRules` resolves it once through `DotnsConstants.COST_MODEL` and +/// prices the current version for fresh reads and a specific version for in-flight +/// registrations. Governance moves the model set: the mutating calls admit Root or the owner, +/// so the economics track that governs pricing can drive them without the owner key. /// @custom:security-contact admin@parity.io contract DotnsCostModelRegistry is Ownable, IDotnsCostModelRegistry { /// @inheritdoc IDotnsCostModelRegistry @@ -19,12 +21,22 @@ contract DotnsCostModelRegistry is Ownable, IDotnsCostModelRegistry { /// @inheritdoc IDotnsCostModelRegistry uint256 public override currentVersion; - /// @notice Sets the owner permitted to register models. + /// @notice Sets the owner permitted to register models alongside Root. /// @param owner_ Address that governs the model set. constructor(address owner_) Ownable(owner_) {} + /// @notice Restricts a call to Root or the owner. + /// @dev Checks Root first so `msg.sender`, which traps under a Root origin, is read only for a + /// signed caller. + modifier onlyGovernance() { + if (!SystemUtils.originIsRoot()) { + _checkOwner(); + } + _; + } + /// @inheritdoc IDotnsCostModelRegistry - function register(IDotnsPricing model) external override onlyOwner { + function register(IDotnsPricing model) external override onlyGovernance { uint256 version = model.version(); require(version != 0, ZeroVersion()); require(address(modelOf[version]) == address(0), AlreadyRegistered(version)); @@ -34,7 +46,7 @@ contract DotnsCostModelRegistry is Ownable, IDotnsCostModelRegistry { } /// @inheritdoc IDotnsCostModelRegistry - function setCurrentVersion(uint256 version) external override onlyOwner { + function setCurrentVersion(uint256 version) external override onlyGovernance { require(address(modelOf[version]) != address(0), UnknownVersion(version)); currentVersion = version; emit CurrentModelSet(version); 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 index 63f3a8634..de4b02d08 100644 --- a/contracts/pop/DotnsScarcityPricing.sol +++ b/contracts/pop/DotnsScarcityPricing.sol @@ -8,7 +8,9 @@ import {IDotnsPricing} from "./IDotnsPricing.sol"; /// @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`. +/// 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`. @@ -22,10 +24,6 @@ contract DotnsScarcityPricing is IDotnsPricing { /// @notice Price floor F in wei: the least any name can cost. Never above the base fee. uint256 public immutable minPrice; - /// @notice Thrown when a constructor parameter breaks a curve invariant. - /// @param reason Human-readable explanation of the failed invariant. - error PricingError(string reason); - /// @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 diff --git a/contracts/pop/IDotnsCostModelRegistry.sol b/contracts/pop/IDotnsCostModelRegistry.sol index fc9eebfa2..e793ffbcf 100644 --- a/contracts/pop/IDotnsCostModelRegistry.sol +++ b/contracts/pop/IDotnsCostModelRegistry.sol @@ -32,18 +32,25 @@ interface IDotnsCostModelRegistry { /// 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. + /// @dev Callable by Root or the owner. 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. + /// @dev Callable by Root or the owner. 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; diff --git a/contracts/pop/IDotnsPricing.sol b/contracts/pop/IDotnsPricing.sol index 63a4edb61..6429bbba9 100644 --- a/contracts/pop/IDotnsPricing.sol +++ b/contracts/pop/IDotnsPricing.sol @@ -12,6 +12,10 @@ pragma solidity ^0.8.34; /// 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 diff --git a/contracts/pop/IPopRules.sol b/contracts/pop/IPopRules.sol index a59c3fbe7..6fd22c4c9 100644 --- a/contracts/pop/IPopRules.sol +++ b/contracts/pop/IPopRules.sol @@ -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 on the scarcity curve for the label's base length. + /// @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. diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index cb45fa3c0..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 @@ -149,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) = @@ -194,13 +212,15 @@ contract DotnsRegistrarController is 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 + ) ); } @@ -367,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/IDotnsRegistrarController.sol b/contracts/registrars/IDotnsRegistrarController.sol index eb0f84bc4..59739f421 100644 --- a/contracts/registrars/IDotnsRegistrarController.sol +++ b/contracts/registrars/IDotnsRegistrarController.sol @@ -24,7 +24,10 @@ interface IDotnsRegistrarController is IDotnsController { /// @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. + /// 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; @@ -78,28 +81,6 @@ 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(); @@ -150,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. diff --git a/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index ee3c076e7..985584213 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -30,22 +30,22 @@ library DotnsConstants { /// forge-lint: disable-next-line(unsafe-typecast) bytes32 internal constant PERSONHOOD_CONTEXT = bytes32("dotns"); - /// @notice Base fee D passed into the `DotnsScarcityPricing` constructor. - /// @dev The curve's value at nine characters: 10 DOT under revive's 18-decimal Asset Hub - /// convention. A new curve 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. + /// @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. Also the base fee of the + /// `DotnsScarcityPricing` candidate. uint256 internal constant RENT_PRICE = 10 ether; - /// @notice Price floor F passed into the `DotnsScarcityPricing` constructor. - /// @dev Below `RENT_PRICE` so the curve falls above nine characters. Seeds the model + /// @notice Price floor F passed into the `DotnsScarcityPricing` candidate's constructor. + /// @dev Below `RENT_PRICE` 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 curve is one `set` on the protocol + /// 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"); 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 da383019b..3eeb2c7f7 100644 --- a/scripts/deploy/DeployRecords.s.sol +++ b/scripts/deploy/DeployRecords.s.sol @@ -7,7 +7,7 @@ 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 {DotnsScarcityPricing} from "../../contracts/pop/DotnsScarcityPricing.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"; @@ -70,16 +70,17 @@ contract DeployRecords is BaseDeployer { ); } - /// @notice Deploys the scarcity model and the cost-model registry, then registers the model + /// @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. + /// stage sets that key. The flat model prices every admitted name at `RENT_PRICE`; + /// `DotnsScarcityPricing` is held as a later candidate and is not registered here. function _deployCostModelStack(address owner) internal returns (address registry) { address model = _broadcastDeployCreate3( owner, - "DotnsScarcityPricing.sol:DotnsScarcityPricing", - abi.encode(DotnsConstants.RENT_PRICE, DotnsConstants.MIN_PRICE), - "DotnsScarcityPricing" + "DotnsFlatPricing.sol:DotnsFlatPricing", + abi.encode(DotnsConstants.RENT_PRICE), + "DotnsFlatPricing" ); registry = _broadcastDeployCreate3( owner, diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index e19ad665d..fb50616cf 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -5,7 +5,7 @@ import {console} from "forge-std/Script.sol"; import {BaseDeployer} from "./BaseDeployer.s.sol"; import {PopRules} from "../../contracts/pop/PopRules.sol"; -import {DotnsScarcityPricing} from "../../contracts/pop/DotnsScarcityPricing.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"; @@ -286,9 +286,9 @@ contract DotnsDeployer is BaseDeployer { { model = _broadcastDeployCreate3( owner, - "DotnsScarcityPricing.sol:DotnsScarcityPricing", - abi.encode(DotnsConstants.RENT_PRICE, DotnsConstants.MIN_PRICE), - "DotnsScarcityPricing" + "DotnsFlatPricing.sol:DotnsFlatPricing", + abi.encode(DotnsConstants.RENT_PRICE), + "DotnsFlatPricing" ); registry = _broadcastDeployCreate3( owner, @@ -497,7 +497,7 @@ contract DotnsDeployer is BaseDeployer { require( IDotnsCostModelRegistry(deployment.costModelRegistry).priceForBaseLength(9) == DotnsConstants.RENT_PRICE, - "CostModel: pivot price mismatch" + "CostModel: launch price mismatch" ); console.log("=== Deployment verification complete ==="); } diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index f3c9f5c7b..ff8e546e2 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -4,6 +4,7 @@ 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"; @@ -66,7 +67,11 @@ abstract contract BaseDotns is Test { /// @notice Deployed PoP oracle instance. PopRules public popRules; - /// @notice Deployed scarcity pricing model seeding the cost-model registry. + /// @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`. @@ -274,11 +279,16 @@ abstract contract BaseDotns is Test { dotnsContentResolver = DotnsContentResolver(dotnsContentResolverAddress); vm.label(dotnsContentResolverAddress, "DotnsContentResolver"); + flatPricing = new DotnsFlatPricing(RENT_PRICE); + vm.label(address(flatPricing), "DotnsFlatPricing"); scarcityPricing = new DotnsScarcityPricing(RENT_PRICE, MIN_PRICE); vm.label(address(scarcityPricing), "DotnsScarcityPricing"); costModelRegistry = new DotnsCostModelRegistry(owner); vm.label(address(costModelRegistry), "DotnsCostModelRegistry"); - costModelRegistry.register(IDotnsPricing(address(scarcityPricing))); + // Governance registers models; the harness runs the seed under the owner prank opened + // above, so mock the Root check false to fall through to the owner path. + _mockOriginIsRoot(false); + costModelRegistry.register(IDotnsPricing(address(flatPricing))); address popRulesAddress = Upgrades.deployUUPSProxy( "PopRules.sol:PopRules", abi.encodeCall(PopRules.initialize, (registry)) diff --git a/test/fuzz/pop/PopFuzz.t.sol b/test/fuzz/pop/PopFuzz.t.sol index b07733666..424e41bec 100644 --- a/test/fuzz/pop/PopFuzz.t.sol +++ b/test/fuzz/pop/PopFuzz.t.sol @@ -41,13 +41,12 @@ contract PopRulesFuzzTest is BaseDotns { popRules.priceWithCheck(nameLabel, ed); } - function testFuzz_price_matches_curve(uint256 seed, uint256 length) public view { + function testFuzz_price_matches_flat_model(uint256 seed, uint256 length) public view { length = bound(length, 3, 63); string memory nameLabel = _makeAlpha(seed, length); - uint256 curve = length >= 9 ? RENT_PRICE >> (length - 9) : RENT_PRICE * (2 ** (9 - length)); - uint256 expected = curve < MIN_PRICE ? MIN_PRICE : curve; - assertEq(popRules.price(nameLabel), expected); + // The launch model prices every length at the flat deposit. + assertEq(popRules.price(nameLabel), RENT_PRICE); } function testFuzz_price_is_monotonic_and_floored(uint256 seed, uint256 length) public view { diff --git a/test/invariant/registrar/RegistrarControllerHandler.t.sol b/test/invariant/registrar/RegistrarControllerHandler.t.sol index 821222aac..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; @@ -206,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); @@ -217,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; } } @@ -292,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); @@ -306,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; } } @@ -364,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); @@ -394,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)); @@ -453,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/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index 9a2e582e0..de51c16db 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -378,7 +378,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory before = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(before.recipient, ed, "funded position starts with registrant"); - assertEq(before.amount, 2 * RENT_PRICE, "self-registration locks a refundable deposit"); + assertEq(before.amount, RENT_PRICE, "self-registration locks a refundable deposit"); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); assertEq(quotedFee, 0, "same-tier transfer costs nothing"); @@ -392,7 +392,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory afterTransfer = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(afterTransfer.recipient, leonardo, "position must follow the current holder"); - assertEq(afterTransfer.amount, 2 * RENT_PRICE, "deposit travels with the name on rebind"); + assertEq(afterTransfer.amount, RENT_PRICE, "deposit travels with the name on rebind"); assertEq(dotnsNameEscrow.pendingRefundCount(ed), edRefundsBefore, "no refund on rebind"); assertEq( dotnsNameEscrow.pendingRefundCount(leonardo), diff --git a/test/unit/escrow/DotnsNameEscrowRedeem.t.sol b/test/unit/escrow/DotnsNameEscrowRedeem.t.sol index 0a0bace8c..4f182c3c2 100644 --- a/test/unit/escrow/DotnsNameEscrowRedeem.t.sol +++ b/test/unit/escrow/DotnsNameEscrowRedeem.t.sol @@ -41,8 +41,8 @@ contract DotnsNameEscrowRedeemTest is BaseDotns { /// 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 under scarcity pricing. `nameOwner` must carry - /// `status`, because the controller prices the owner's tier on this path. + /// 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, diff --git a/test/unit/pop/DotnsCostModelRegistry.t.sol b/test/unit/pop/DotnsCostModelRegistry.t.sol index 3b206bf4b..f4257ebec 100644 --- a/test/unit/pop/DotnsCostModelRegistry.t.sol +++ b/test/unit/pop/DotnsCostModelRegistry.t.sol @@ -8,6 +8,8 @@ import {DotnsCostModelRegistry} from "../../../contracts/pop/DotnsCostModelRegis import {IDotnsCostModelRegistry} from "../../../contracts/pop/IDotnsCostModelRegistry.sol"; import {DotnsScarcityPricing} from "../../../contracts/pop/DotnsScarcityPricing.sol"; import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; +import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; +import {ISystem} from "../../../contracts/external/revive/ISystem.sol"; /// @title ZeroVersionModel /// @notice Minimal cost model whose version identifier is zero, used to exercise the registry's @@ -39,23 +41,45 @@ contract DotnsCostModelRegistryTests is Test { modelA = new DotnsScarcityPricing(BASE_FEE, FLOOR); modelB = new DotnsScarcityPricing(BASE_FEE * 2, FLOOR); + // Governance gate reads the Root origin first; keep it false so the tests exercise the + // owner path unless a case mocks it true. + _mockOriginIsRoot(false); + vm.prank(owner); registry.register(IDotnsPricing(address(modelA))); } + /// @notice Mocks revive's System precompile originIsRoot result. + /// @param returnValue Value to return from `originIsRoot`. + function _mockOriginIsRoot(bool returnValue) internal { + vm.mockCall( + DotnsConstants.REVIVE_SYSTEM, + abi.encodeWithSelector(ISystem.originIsRoot.selector), + abi.encode(returnValue) + ); + } + 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 { + function test_register_reverts_for_non_governance() public { vm.expectRevert( abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)) ); registry.register(IDotnsPricing(address(modelB))); } + function test_register_allows_root() public { + _mockOriginIsRoot(true); + // A non-owner caller under a Root origin registers without the owner key. + vm.prank(makeAddr("notOwner")); + registry.register(IDotnsPricing(address(modelB))); + assertEq(registry.currentVersion(), modelB.version()); + } + function test_register_reverts_on_duplicate_version() public { uint256 versionA = modelA.version(); vm.prank(owner); @@ -113,7 +137,7 @@ contract DotnsCostModelRegistryTests is Test { registry.setCurrentVersion(unknown); } - function test_setCurrentVersion_only_owner() public { + function test_setCurrentVersion_reverts_for_non_governance() public { uint256 versionA = modelA.version(); vm.expectRevert( abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)) @@ -121,6 +145,16 @@ contract DotnsCostModelRegistryTests is Test { registry.setCurrentVersion(versionA); } + function test_setCurrentVersion_allows_root() public { + vm.prank(owner); + registry.register(IDotnsPricing(address(modelB))); + + _mockOriginIsRoot(true); + vm.prank(makeAddr("notOwner")); + registry.setCurrentVersion(modelA.version()); + assertEq(registry.currentVersion(), modelA.version()); + } + function test_register_reverts_for_zero_version() public { ZeroVersionModel zeroModel = new ZeroVersionModel(); vm.prank(owner); 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 index fd092433f..93ca56c25 100644 --- a/test/unit/pop/DotnsScarcityPricing.t.sol +++ b/test/unit/pop/DotnsScarcityPricing.t.sol @@ -4,6 +4,7 @@ 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. @@ -41,7 +42,7 @@ contract DotnsScarcityPricingTests is Test { function test_constructor_reverts_for_zero_base_fee() public { vm.expectRevert( abi.encodeWithSelector( - DotnsScarcityPricing.PricingError.selector, "Base fee must be greater than 0" + IDotnsPricing.PricingError.selector, "Base fee must be greater than 0" ) ); new DotnsScarcityPricing(0, FLOOR); @@ -50,7 +51,7 @@ contract DotnsScarcityPricingTests is Test { function test_constructor_reverts_for_zero_floor() public { vm.expectRevert( abi.encodeWithSelector( - DotnsScarcityPricing.PricingError.selector, "Floor must be greater than 0" + IDotnsPricing.PricingError.selector, "Floor must be greater than 0" ) ); new DotnsScarcityPricing(BASE_FEE, 0); @@ -59,7 +60,7 @@ contract DotnsScarcityPricingTests is Test { function test_constructor_reverts_when_floor_exceeds_base_fee() public { vm.expectRevert( abi.encodeWithSelector( - DotnsScarcityPricing.PricingError.selector, "Floor cannot exceed the base fee" + IDotnsPricing.PricingError.selector, "Floor cannot exceed the base fee" ) ); new DotnsScarcityPricing(BASE_FEE, BASE_FEE + 1); @@ -69,8 +70,7 @@ contract DotnsScarcityPricingTests is Test { uint256 tooHigh = type(uint256).max / 512 + 1; vm.expectRevert( abi.encodeWithSelector( - DotnsScarcityPricing.PricingError.selector, - "Base fee exceeds the scarcity-curve ceiling" + IDotnsPricing.PricingError.selector, "Base fee exceeds the scarcity-curve ceiling" ) ); new DotnsScarcityPricing(tooHigh, FLOOR); diff --git a/test/unit/pop/PopRules.t.sol b/test/unit/pop/PopRules.t.sol index 3cb5468e9..8b507b501 100644 --- a/test/unit/pop/PopRules.t.sol +++ b/test/unit/pop/PopRules.t.sol @@ -81,17 +81,17 @@ contract PopRulesTests is BaseDotns { assertEq(popRules.price("andrew01"), popRules.price("andrew")); } - function test_verified_person_pays_the_curve_for_premium() public { + function test_verified_person_pays_the_deposit_for_premium() public { _grantPopFull(ed); - assertEq(popRules.priceWithCheck("alicebob", ed).price, 2 * RENT_PRICE); - assertEq(popRules.priceWithCheck("lights", ed).price, 8 * RENT_PRICE); + assertEq(popRules.priceWithCheck("alicebob", ed).price, RENT_PRICE); + assertEq(popRules.priceWithCheck("lights", ed).price, RENT_PRICE); } function test_transfer_reprices_at_own_length() public { _grantPopFull(leonardo); - assertEq(popRules.transferFloor("lights", leonardo, tiago), 8 * RENT_PRICE); + assertEq(popRules.transferFloor("lights", leonardo, tiago), RENT_PRICE); assertEq(popRules.transferFloor("lights", leonardo, leonardo), 0); } @@ -118,7 +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, 8 * RENT_PRICE); + assertEq(priceMetadata.price, RENT_PRICE); } function test_poplite_user_can_access_nostatus_name() public { @@ -128,7 +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, RENT_PRICE / 8); // longnamehere is 12 characters + assertEq(priceMetadata.price, RENT_PRICE); } function test_base_reservation_blocks_others() public { @@ -189,7 +189,7 @@ contract PopRulesTests is BaseDotns { popRules.setShortNamesEnabled(false); _grantPopLite(ed); // longnamehere is 12 characters, so the switch never gates it. - assertEq(popRules.priceWithCheck("longnamehere", ed).price, RENT_PRICE / 8); + assertEq(popRules.priceWithCheck("longnamehere", ed).price, RENT_PRICE); } function test_enabling_short_names_opens_the_market() public { @@ -198,7 +198,7 @@ contract PopRulesTests is BaseDotns { _grantPopFull(ed); vm.prank(owner); popRules.setShortNamesEnabled(true); - assertEq(popRules.priceWithCheck("alicebob", ed).price, 2 * RENT_PRICE); + assertEq(popRules.priceWithCheck("alicebob", ed).price, RENT_PRICE); } function test_setShortNamesEnabled_emits() public { @@ -219,28 +219,28 @@ contract PopRulesTests is BaseDotns { function test_price_matches_model() public view { string memory label = "thisisaverylongname"; uint256 baseLength = bytes(label).length; - assertEq(popRules.price(label), scarcityPricing.priceForBaseLength(baseLength)); + 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, scarcityPricing.priceForBaseLength(bytes(label).length)); + 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 - // curve price, which is the model amount for its base length. + // 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, scarcityPricing.priceForBaseLength(bytes(label).length)); + assertEq(floor, costModelRegistry.priceForBaseLength(bytes(label).length)); } function test_pricingVersion_matches_registry() public view { assertEq(popRules.pricingVersion(), costModelRegistry.currentVersion()); - assertEq(popRules.pricingVersion(), scarcityPricing.version()); + assertEq(popRules.pricingVersion(), flatPricing.version()); } function test_price_reverts_when_cost_model_unconfigured() public { diff --git a/test/unit/registrar/DotnsRegistrarController.t.sol b/test/unit/registrar/DotnsRegistrarController.t.sol index 90a135ed2..6bb642e21 100644 --- a/test/unit/registrar/DotnsRegistrarController.t.sol +++ b/test/unit/registrar/DotnsRegistrarController.t.sol @@ -690,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 @@ -968,19 +968,26 @@ contract DotnsRegistrarControllerTest is BaseDotns { assertEq(IERC721(address(dotnsRegistrar)).ownerOf(_tokenIdForLabel(label)), nameOwner); } - function test_reveal_reverts_for_unregistered_committed_version() public { + function test_reveal_reverts_when_committed_version_not_current() public { string memory label = "unknownver01"; address nameOwner = ed; - uint256 unknownVersion = uint256(keccak256("never registered version")); + // 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, unknownVersion); + _nostatusRegistration(label, nameOwner, type(uint256).max, wrongVersion); _commitRegistrationAndWaitMinimumAge(registration); vm.prank(nameOwner); vm.expectRevert( - abi.encodeWithSelector(IDotnsCostModelRegistry.UnknownVersion.selector, unknownVersion) + abi.encodeWithSelector( + IDotnsCostModelRegistry.PricingVersionMismatch.selector, + stampedVersion, + wrongVersion + ) ); dotnsRegistrarController.register{value: RENT_PRICE}(registration); } @@ -1000,9 +1007,10 @@ contract DotnsRegistrarControllerTest is BaseDotns { _nostatusRegistration(label, nameOwner, committedPrice, committedVersion); _commitRegistrationAndWaitMinimumAge(registration); - // Move current forward to a third model, then revert it back to the first version. + // 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(RENT_PRICE * 4, MIN_PRICE); - uint256 firstVersion = scarcityPricing.version(); + uint256 firstVersion = flatPricing.version(); vm.prank(owner); costModelRegistry.register(IDotnsPricing(address(modelV3))); vm.prank(owner); From 3a8945cae57b4fdfcb57597106ff69d1d313b78c Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 31 Aug 2026 16:52:16 +0200 Subject: [PATCH 11/13] fix: remove root checking and remove decorative seperators --- README.md | 2 +- contracts/pop/DotnsCostModelRegistry.sol | 24 ++++---------- contracts/pop/IDotnsCostModelRegistry.sol | 14 ++++---- scripts/genesis/build-genesis.sh | 2 -- scripts/genesis/extract-genesis.mjs | 6 ---- scripts/genesis/extract-genesis.test.mjs | 10 ------ test/base/BaseDotns.t.sol | 3 -- test/unit/pop/DotnsCostModelRegistry.t.sol | 38 ++-------------------- 8 files changed, 16 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 743197934..6f8f3e7dc 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ A transfer charges the name's own price, but only in two cases: the recipient ca ### 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. The cost-model registry admits Root or the owner, so the economics track that governs pricing drives it without the registrar's own key. +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 diff --git a/contracts/pop/DotnsCostModelRegistry.sol b/contracts/pop/DotnsCostModelRegistry.sol index 442b4bb4d..86842da0d 100644 --- a/contracts/pop/DotnsCostModelRegistry.sol +++ b/contracts/pop/DotnsCostModelRegistry.sol @@ -5,14 +5,12 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IDotnsCostModelRegistry} from "./IDotnsCostModelRegistry.sol"; import {IDotnsPricing} from "./IDotnsPricing.sol"; -import {SystemUtils} from "../utils/SystemUtils.sol"; /// @title DotNS Cost Model Registry /// @notice Keeps every registered cost model addressable by version and tracks the current one. -/// @dev Holds only pointers. `PopRules` resolves it once through `DotnsConstants.COST_MODEL` and -/// prices the current version for fresh reads and a specific version for in-flight -/// registrations. Governance moves the model set: the mutating calls admit Root or the owner, -/// so the economics track that governs pricing can drive them without the owner key. +/// @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 @@ -21,22 +19,12 @@ contract DotnsCostModelRegistry is Ownable, IDotnsCostModelRegistry { /// @inheritdoc IDotnsCostModelRegistry uint256 public override currentVersion; - /// @notice Sets the owner permitted to register models alongside Root. + /// @notice Sets the owner permitted to register models. /// @param owner_ Address that governs the model set. constructor(address owner_) Ownable(owner_) {} - /// @notice Restricts a call to Root or the owner. - /// @dev Checks Root first so `msg.sender`, which traps under a Root origin, is read only for a - /// signed caller. - modifier onlyGovernance() { - if (!SystemUtils.originIsRoot()) { - _checkOwner(); - } - _; - } - /// @inheritdoc IDotnsCostModelRegistry - function register(IDotnsPricing model) external override onlyGovernance { + function register(IDotnsPricing model) external override onlyOwner { uint256 version = model.version(); require(version != 0, ZeroVersion()); require(address(modelOf[version]) == address(0), AlreadyRegistered(version)); @@ -46,7 +34,7 @@ contract DotnsCostModelRegistry is Ownable, IDotnsCostModelRegistry { } /// @inheritdoc IDotnsCostModelRegistry - function setCurrentVersion(uint256 version) external override onlyGovernance { + function setCurrentVersion(uint256 version) external override onlyOwner { require(address(modelOf[version]) != address(0), UnknownVersion(version)); currentVersion = version; emit CurrentModelSet(version); diff --git a/contracts/pop/IDotnsCostModelRegistry.sol b/contracts/pop/IDotnsCostModelRegistry.sol index e793ffbcf..f2630160d 100644 --- a/contracts/pop/IDotnsCostModelRegistry.sol +++ b/contracts/pop/IDotnsCostModelRegistry.sol @@ -40,17 +40,17 @@ interface IDotnsCostModelRegistry { error PricingVersionMismatch(uint256 committed, uint256 revealed); /// @notice Registers a model and makes it current. - /// @dev Callable by Root or the owner. 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. + /// @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 Callable by Root or the owner. 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. + /// @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; 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/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index ff8e546e2..c20ed0a32 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -285,9 +285,6 @@ abstract contract BaseDotns is Test { vm.label(address(scarcityPricing), "DotnsScarcityPricing"); costModelRegistry = new DotnsCostModelRegistry(owner); vm.label(address(costModelRegistry), "DotnsCostModelRegistry"); - // Governance registers models; the harness runs the seed under the owner prank opened - // above, so mock the Root check false to fall through to the owner path. - _mockOriginIsRoot(false); costModelRegistry.register(IDotnsPricing(address(flatPricing))); address popRulesAddress = Upgrades.deployUUPSProxy( diff --git a/test/unit/pop/DotnsCostModelRegistry.t.sol b/test/unit/pop/DotnsCostModelRegistry.t.sol index f4257ebec..3b206bf4b 100644 --- a/test/unit/pop/DotnsCostModelRegistry.t.sol +++ b/test/unit/pop/DotnsCostModelRegistry.t.sol @@ -8,8 +8,6 @@ import {DotnsCostModelRegistry} from "../../../contracts/pop/DotnsCostModelRegis import {IDotnsCostModelRegistry} from "../../../contracts/pop/IDotnsCostModelRegistry.sol"; import {DotnsScarcityPricing} from "../../../contracts/pop/DotnsScarcityPricing.sol"; import {IDotnsPricing} from "../../../contracts/pop/IDotnsPricing.sol"; -import {DotnsConstants} from "../../../contracts/utils/DotnsConstants.sol"; -import {ISystem} from "../../../contracts/external/revive/ISystem.sol"; /// @title ZeroVersionModel /// @notice Minimal cost model whose version identifier is zero, used to exercise the registry's @@ -41,45 +39,23 @@ contract DotnsCostModelRegistryTests is Test { modelA = new DotnsScarcityPricing(BASE_FEE, FLOOR); modelB = new DotnsScarcityPricing(BASE_FEE * 2, FLOOR); - // Governance gate reads the Root origin first; keep it false so the tests exercise the - // owner path unless a case mocks it true. - _mockOriginIsRoot(false); - vm.prank(owner); registry.register(IDotnsPricing(address(modelA))); } - /// @notice Mocks revive's System precompile originIsRoot result. - /// @param returnValue Value to return from `originIsRoot`. - function _mockOriginIsRoot(bool returnValue) internal { - vm.mockCall( - DotnsConstants.REVIVE_SYSTEM, - abi.encodeWithSelector(ISystem.originIsRoot.selector), - abi.encode(returnValue) - ); - } - 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_reverts_for_non_governance() public { + function test_register_only_owner() public { vm.expectRevert( abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)) ); registry.register(IDotnsPricing(address(modelB))); } - function test_register_allows_root() public { - _mockOriginIsRoot(true); - // A non-owner caller under a Root origin registers without the owner key. - vm.prank(makeAddr("notOwner")); - registry.register(IDotnsPricing(address(modelB))); - assertEq(registry.currentVersion(), modelB.version()); - } - function test_register_reverts_on_duplicate_version() public { uint256 versionA = modelA.version(); vm.prank(owner); @@ -137,7 +113,7 @@ contract DotnsCostModelRegistryTests is Test { registry.setCurrentVersion(unknown); } - function test_setCurrentVersion_reverts_for_non_governance() public { + function test_setCurrentVersion_only_owner() public { uint256 versionA = modelA.version(); vm.expectRevert( abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this)) @@ -145,16 +121,6 @@ contract DotnsCostModelRegistryTests is Test { registry.setCurrentVersion(versionA); } - function test_setCurrentVersion_allows_root() public { - vm.prank(owner); - registry.register(IDotnsPricing(address(modelB))); - - _mockOriginIsRoot(true); - vm.prank(makeAddr("notOwner")); - registry.setCurrentVersion(modelA.version()); - assertEq(registry.currentVersion(), modelA.version()); - } - function test_register_reverts_for_zero_version() public { ZeroVersionModel zeroModel = new ZeroVersionModel(); vm.prank(owner); From 8c801fc931f2822a5c82f4a32714d4a3bc84829e Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 31 Aug 2026 17:31:03 +0200 Subject: [PATCH 12/13] chore: address PR comments --- .github/abi-contracts.txt | 4 ++ contracts/utils/DotnsConstants.sol | 7 +-- scripts/deploy/DeployRecords.s.sol | 4 +- scripts/deploy/DotnsDeployer.s.sol | 4 +- test/base/BaseDotns.t.sol | 13 ++--- test/fuzz/pop/PopFuzz.t.sol | 2 +- .../DotnsRegistrarControllerFuzz.t.sol | 6 +- .../NoStatusDepositLifecycle.t.sol | 18 +++--- test/unit/escrow/DotnsNameEscrow.t.sol | 58 ++++++++++--------- test/unit/pop/PopRules.t.sol | 14 ++--- .../registrar/DotnsRegistrarController.t.sol | 16 ++--- 11 files changed, 76 insertions(+), 70 deletions(-) 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/contracts/utils/DotnsConstants.sol b/contracts/utils/DotnsConstants.sol index 985584213..1a902655c 100644 --- a/contracts/utils/DotnsConstants.sol +++ b/contracts/utils/DotnsConstants.sol @@ -34,12 +34,11 @@ library DotnsConstants { /// @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. Also the base fee of the - /// `DotnsScarcityPricing` candidate. - uint256 internal constant RENT_PRICE = 10 ether; + /// 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 `RENT_PRICE` so that curve falls above nine characters. Seeds the candidate + /// @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; diff --git a/scripts/deploy/DeployRecords.s.sol b/scripts/deploy/DeployRecords.s.sol index 3eeb2c7f7..87bbd5f4f 100644 --- a/scripts/deploy/DeployRecords.s.sol +++ b/scripts/deploy/DeployRecords.s.sol @@ -73,13 +73,13 @@ 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 `RENT_PRICE`; + /// 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.RENT_PRICE), + abi.encode(DotnsConstants.BASE_DEPOSIT), "DotnsFlatPricing" ); registry = _broadcastDeployCreate3( diff --git a/scripts/deploy/DotnsDeployer.s.sol b/scripts/deploy/DotnsDeployer.s.sol index fb50616cf..b9097b0fa 100644 --- a/scripts/deploy/DotnsDeployer.s.sol +++ b/scripts/deploy/DotnsDeployer.s.sol @@ -287,7 +287,7 @@ contract DotnsDeployer is BaseDeployer { model = _broadcastDeployCreate3( owner, "DotnsFlatPricing.sol:DotnsFlatPricing", - abi.encode(DotnsConstants.RENT_PRICE), + abi.encode(DotnsConstants.BASE_DEPOSIT), "DotnsFlatPricing" ); registry = _broadcastDeployCreate3( @@ -496,7 +496,7 @@ contract DotnsDeployer is BaseDeployer { require(DotnsRegistry(deployment.registry).recordExists(bytes32(0)), "Root record missing"); require( IDotnsCostModelRegistry(deployment.costModelRegistry).priceForBaseLength(9) - == DotnsConstants.RENT_PRICE, + == DotnsConstants.BASE_DEPOSIT, "CostModel: launch price mismatch" ); console.log("=== Deployment verification complete ==="); diff --git a/test/base/BaseDotns.t.sol b/test/base/BaseDotns.t.sol index c20ed0a32..256517093 100644 --- a/test/base/BaseDotns.t.sol +++ b/test/base/BaseDotns.t.sol @@ -154,12 +154,11 @@ 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 @@ -279,9 +278,9 @@ abstract contract BaseDotns is Test { dotnsContentResolver = DotnsContentResolver(dotnsContentResolverAddress); vm.label(dotnsContentResolverAddress, "DotnsContentResolver"); - flatPricing = new DotnsFlatPricing(RENT_PRICE); + flatPricing = new DotnsFlatPricing(BASE_DEPOSIT); vm.label(address(flatPricing), "DotnsFlatPricing"); - scarcityPricing = new DotnsScarcityPricing(RENT_PRICE, MIN_PRICE); + scarcityPricing = new DotnsScarcityPricing(BASE_DEPOSIT, MIN_PRICE); vm.label(address(scarcityPricing), "DotnsScarcityPricing"); costModelRegistry = new DotnsCostModelRegistry(owner); vm.label(address(costModelRegistry), "DotnsCostModelRegistry"); diff --git a/test/fuzz/pop/PopFuzz.t.sol b/test/fuzz/pop/PopFuzz.t.sol index 424e41bec..2e1e55d77 100644 --- a/test/fuzz/pop/PopFuzz.t.sol +++ b/test/fuzz/pop/PopFuzz.t.sol @@ -46,7 +46,7 @@ contract PopRulesFuzzTest is BaseDotns { string memory nameLabel = _makeAlpha(seed, length); // The launch model prices every length at the flat deposit. - assertEq(popRules.price(nameLabel), RENT_PRICE); + assertEq(popRules.price(nameLabel), BASE_DEPOSIT); } function testFuzz_price_is_monotonic_and_floored(uint256 seed, uint256 length) public view { diff --git a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol index 852c11c28..9ff47f3a8 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -275,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); @@ -285,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)); @@ -301,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, diff --git a/test/intergration/NoStatusDepositLifecycle.t.sol b/test/intergration/NoStatusDepositLifecycle.t.sol index 9b65aee08..d80397d19 100644 --- a/test/intergration/NoStatusDepositLifecycle.t.sol +++ b/test/intergration/NoStatusDepositLifecycle.t.sol @@ -23,18 +23,18 @@ contract NoStatusDepositLifecycle is BaseDotns { 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/unit/escrow/DotnsNameEscrow.t.sol b/test/unit/escrow/DotnsNameEscrow.t.sol index de51c16db..20d2cc355 100644 --- a/test/unit/escrow/DotnsNameEscrow.t.sol +++ b/test/unit/escrow/DotnsNameEscrow.t.sol @@ -33,7 +33,7 @@ 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. + /// @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. @@ -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 { @@ -192,7 +192,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory position = dotnsNameEscrow.getReleasePosition(tokenId); assertTrue(position.released, "self-registered name must be releasable"); - assertEq(position.amount, RENT_PRICE, "self-registration locks a refundable deposit"); + assertEq(position.amount, BASE_DEPOSIT, "self-registration locks a refundable deposit"); assertEq(position.recipient, ed, "position is bound to the registrant"); } @@ -267,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 }) ); } @@ -326,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"); @@ -349,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)), @@ -378,7 +378,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory before = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(before.recipient, ed, "funded position starts with registrant"); - assertEq(before.amount, RENT_PRICE, "self-registration locks a refundable deposit"); + assertEq(before.amount, BASE_DEPOSIT, "self-registration locks a refundable deposit"); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); assertEq(quotedFee, 0, "same-tier transfer costs nothing"); @@ -392,7 +392,7 @@ contract DotnsNameEscrowTest is BaseDotns { IDotnsNameEscrow.ReleasePosition memory afterTransfer = dotnsNameEscrow.getReleasePosition(tokenId); assertEq(afterTransfer.recipient, leonardo, "position must follow the current holder"); - assertEq(afterTransfer.amount, RENT_PRICE, "deposit travels with the name on rebind"); + 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), @@ -447,7 +447,7 @@ contract DotnsNameEscrowTest is BaseDotns { // `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 the base fee D while the position - // still carries the original `RENT_PRICE` deposit, so both legs of `chargeTransferFee` + // still carries the original `BASE_DEPOSIT` deposit, so both legs of `chargeTransferFee` // run in one call. uint256 tokenId = _registerNoStatus(LABEL, ed); @@ -468,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)), @@ -503,7 +503,7 @@ 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 protocolFeesBefore = dotnsNameEscrow.protocolFees(); @@ -520,7 +520,7 @@ contract DotnsNameEscrowTest is BaseDotns { 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)), @@ -551,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, @@ -594,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" ); @@ -602,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, @@ -631,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); @@ -645,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)), @@ -734,13 +736,13 @@ contract DotnsNameEscrowTest is BaseDotns { 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); // 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.protocolFees() - priorProtocolFees, - RENT_PRICE, + BASE_DEPOSIT, "protocol fees must grow by exactly D on cross-payer NoStatus sponsorship" ); assertEq( @@ -750,7 +752,7 @@ contract DotnsNameEscrowTest is BaseDotns { ); assertEq( priorBalance - leonardo.balance, - RENT_PRICE, + BASE_DEPOSIT, "payer must be debited exactly D, never above" ); @@ -863,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/pop/PopRules.t.sol b/test/unit/pop/PopRules.t.sol index 8b507b501..25130feb9 100644 --- a/test/unit/pop/PopRules.t.sol +++ b/test/unit/pop/PopRules.t.sol @@ -84,14 +84,14 @@ contract PopRulesTests is BaseDotns { function test_verified_person_pays_the_deposit_for_premium() public { _grantPopFull(ed); - assertEq(popRules.priceWithCheck("alicebob", ed).price, RENT_PRICE); - assertEq(popRules.priceWithCheck("lights", ed).price, RENT_PRICE); + 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), RENT_PRICE); + assertEq(popRules.transferFloor("lights", leonardo, tiago), BASE_DEPOSIT); assertEq(popRules.transferFloor("lights", leonardo, leonardo), 0); } @@ -118,7 +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, RENT_PRICE); + assertEq(priceMetadata.price, BASE_DEPOSIT); } function test_poplite_user_can_access_nostatus_name() public { @@ -128,7 +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, RENT_PRICE); + assertEq(priceMetadata.price, BASE_DEPOSIT); } function test_base_reservation_blocks_others() public { @@ -189,7 +189,7 @@ contract PopRulesTests is BaseDotns { popRules.setShortNamesEnabled(false); _grantPopLite(ed); // longnamehere is 12 characters, so the switch never gates it. - assertEq(popRules.priceWithCheck("longnamehere", ed).price, RENT_PRICE); + assertEq(popRules.priceWithCheck("longnamehere", ed).price, BASE_DEPOSIT); } function test_enabling_short_names_opens_the_market() public { @@ -198,7 +198,7 @@ contract PopRulesTests is BaseDotns { _grantPopFull(ed); vm.prank(owner); popRules.setShortNamesEnabled(true); - assertEq(popRules.priceWithCheck("alicebob", ed).price, RENT_PRICE); + assertEq(popRules.priceWithCheck("alicebob", ed).price, BASE_DEPOSIT); } function test_setShortNamesEnabled_emits() public { diff --git a/test/unit/registrar/DotnsRegistrarController.t.sol b/test/unit/registrar/DotnsRegistrarController.t.sol index 6bb642e21..d64a0ec0a 100644 --- a/test/unit/registrar/DotnsRegistrarController.t.sol +++ b/test/unit/registrar/DotnsRegistrarController.t.sol @@ -760,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); @@ -774,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" ); @@ -821,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. @@ -829,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( @@ -956,7 +956,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { _commitRegistrationAndWaitMinimumAge(registration); // Governance registers a pricier model mid-window, moving the current version. - DotnsScarcityPricing pricierModel = new DotnsScarcityPricing(RENT_PRICE * 2, MIN_PRICE); + DotnsScarcityPricing pricierModel = new DotnsScarcityPricing(BASE_DEPOSIT * 2, MIN_PRICE); vm.prank(owner); costModelRegistry.register(IDotnsPricing(address(pricierModel))); assertTrue(popRules.pricingVersion() != committedVersion); @@ -989,7 +989,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { wrongVersion ) ); - dotnsRegistrarController.register{value: RENT_PRICE}(registration); + dotnsRegistrarController.register{value: BASE_DEPOSIT}(registration); } function test_reveal_prices_at_committed_version_across_current_moves() public { @@ -997,7 +997,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { address nameOwner = ed; // Register a second model and commit under it as the current version. - DotnsScarcityPricing modelV2 = new DotnsScarcityPricing(RENT_PRICE * 2, MIN_PRICE); + DotnsScarcityPricing modelV2 = new DotnsScarcityPricing(BASE_DEPOSIT * 2, MIN_PRICE); vm.prank(owner); costModelRegistry.register(IDotnsPricing(address(modelV2))); uint256 committedVersion = costModelRegistry.currentVersion(); @@ -1009,7 +1009,7 @@ contract DotnsRegistrarControllerTest is BaseDotns { // 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(RENT_PRICE * 4, MIN_PRICE); + DotnsScarcityPricing modelV3 = new DotnsScarcityPricing(BASE_DEPOSIT * 4, MIN_PRICE); uint256 firstVersion = flatPricing.version(); vm.prank(owner); costModelRegistry.register(IDotnsPricing(address(modelV3))); From 59c9a587d3fda2dbedffb41e14ab3bfe314d496a Mon Sep 17 00:00:00 2001 From: Siphamandla Mjoli Date: Mon, 31 Aug 2026 18:04:39 +0200 Subject: [PATCH 13/13] fix: stale manifest --- deployments/paseo-assethub/420420417.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index 65adf44b2..36d39e61c 100644 --- a/deployments/paseo-assethub/420420417.json +++ b/deployments/paseo-assethub/420420417.json @@ -1 +1 @@ -{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsCostModelRegistry":"0x8bfd1f0957e73716732e725802f13830B5682da4","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0xfe5A45f7fD58D1A6FE09455DB799405b1dcE9411","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","DotnsScarcityPricing":"0xC4138a4048B099c86CeDE583C0150c01D7A1496f","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"}