diff --git a/README.md b/README.md index dbc770b2..f0e71716 100644 --- a/README.md +++ b/README.md @@ -32,41 +32,43 @@ If a run fails partway, re-run it from the Actions tab; the draft is updated rat ## Economics -dotNS uses a single tunable constant, written **D** throughout the protocol. D is the starting price used by PopRules and equals ten DOT at launch; governance can adjust it under the same gate as the upgrade authority. D is the only money quantity the protocol charges; everything else is a composition of D with zero. +Every 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 price their length sets, into the treasury it already controls. -### Transfer matrix +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. -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 cc9e5102..b86721e6 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 @@ -205,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)); @@ -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, @@ -265,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 @@ -276,20 +275,19 @@ contract DotnsNameEscrow is position.recipient = params.to; } - if (fee > 0) { - insuranceFund += fee; - } - charged = fee; - emit CrossTierFeePaid( - params.tokenId, - params.payer, - params.to, - fee, - /* isRegistration */ - false - ); + if (fee > 0) { + protocolFees += fee; + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.to, + fee, + /* isRegistration */ + false + ); + } uint256 overpayment = msg.value - fee; if (overpayment > 0) { @@ -305,8 +303,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)); @@ -359,30 +357,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 69623dc2..b02b1038 100644 --- a/contracts/escrow/IDotnsNameEscrow.sol +++ b/contracts/escrow/IDotnsNameEscrow.sol @@ -19,30 +19,30 @@ interface IDotnsNameEscrow { address recipient; } - /// @notice Parameters for recording a cross-tier registration fee into the insurance fund. - /// @dev Funds the shared insurance pool used by `withdraw` to top up refunds whose per-asset - /// reserve is short; `payer` is preserved purely for event accounting since the deposit - /// itself is non-refundable. + /// @notice Parameters for recording a cross-paid registration fee into the protocol fee pot. + /// @dev The pot is non-refundable and only accumulates; it never backs a refund. `payer` is + /// preserved purely for event accounting since the fee itself is non-refundable. /// @param payer Original `msg.sender` of the controller's `register` call. /// @param recipient The NFT registrant the fee was paid on behalf of. - struct InsuranceDepositParams { + struct ProtocolFeeDepositParams { uint256 tokenId; address payer; address recipient; } /// @notice Inputs for charging transfer friction and rebinding the escrow position. - /// @dev The fee charged is the flat reach floor returned by @custom:function - /// PopRules.transferFloor, settled to the insurance fund. The deposit, when present, + /// @dev The fee charged is the name's own price returned by @custom:function + /// PopRules.transferFloor, settled to the protocol fee pot. The deposit, when present, /// travels with the NFT: the position is rebound to the recipient so the new holder is /// the only address that can later release into escrow and unlock the locked value. /// There is no transfer-time refund path. - /// @param reachFloor Required fee paid by the sender on a downward or cross-reach transfer. + /// @param tokenId Token whose escrow position is charged and rebound to the recipient. + /// @param transferFee The name's own curve price on a downward or cross-reach transfer. /// @param payer Original sender of the registrar transfer entrypoint. /// @param to NFT recipient. Becomes the new position recipient whenever a position exists. struct ChargeTransferFeeParams { uint256 tokenId; - uint256 reachFloor; + uint256 transferFee; address payer; address to; } @@ -130,9 +130,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 +142,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 +157,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 +272,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 +288,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 +297,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 +331,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 d1d47452..6986c5ac 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,29 +224,21 @@ 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. /// @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, @@ -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 80de9ab9..84e7d340 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, @@ -83,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; } @@ -95,7 +101,7 @@ contract PopRules is returns (PopStatus requirement, string memory message) { _requireCanonicalLabel(name); - return _classifyValidatedName(name); + (requirement, message,) = _classifyValidatedName(name); } /// @inheritdoc IPopRules @@ -160,28 +166,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 +193,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 +216,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 +232,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 +262,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 +273,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; @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)); + } + + /// @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. @@ -344,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; @@ -367,31 +352,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 98a80791..75a695af 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,9 +356,10 @@ contract DotnsRegistrar is } /// @notice Quotes the friction fee required for a transfer. - /// @dev Required fee is the reach floor returned by @custom:function PopRules.transferFloor. - /// It is paid by the sender on every downward or cross-reach transfer and settles to the - /// insurance fund. Any prior deposit travels with the NFT: the escrow rebinds the position to + /// @dev Required fee is the name's own price returned by @custom:function + /// PopRules.transferFloor. It is paid by the sender on every downward or cross-reach transfer + /// and settles to the + /// protocol fee pot. Any prior deposit travels with the NFT: the escrow rebinds the position to /// the new holder rather than refunding the sender, so transferring a funded name forfeits the /// locked deposit to the recipient. Self-transfers and escrow-touching transfers return zero. function _quoteTransferFee( @@ -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); @@ -410,9 +411,9 @@ contract DotnsRegistrar is string memory label = LabelUtils.stripTld(registry.tld(), fullName); require(bytes(label).length != 0, InvalidLabel()); - reachFloor = + transferFee = IPopRules(registry.get(DotnsConstants.POP_RULES)).transferFloor(label, from, to); - requiredFee = reachFloor; + requiredFee = transferFee; } /// @inheritdoc UUPSUpgradeable diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index 9a56f6e0..382fee85 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; @@ -245,7 +243,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 +262,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 dab246f0..74f52613 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 @@ -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 @@ -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/contracts/registrars/IDotnsRegistrarController.sol b/contracts/registrars/IDotnsRegistrarController.sol index dacaf6d8..76ed7a3e 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 52cfdf38..d01330e7 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 73da11a4..6bc0f06b 100644 --- a/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol +++ b/test/fuzz/registrar/DotnsRegistrarControllerFuzz.t.sol @@ -86,14 +86,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { assertEq(dotnsRegistrar.ownerOf(tokenId), registrant); } - function testFuzz_register_refunds_overpayment_inline_when_price_is_zero( - uint256 extra, - uint256 salt - ) - public - { + function testFuzz_register_refunds_overpayment_inline(uint256 extra, uint256 salt) public { address registrant = tiago; - string memory nameLabel = _labelPriceZero(bound(salt, 0, 64)); + string memory nameLabel = _labelPopLitePriced(bound(salt, 0, 64)); _grantPopLite(registrant); @@ -101,27 +96,26 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, registrant, false); uint256 requiredPrice = popRules.priceWithCheck(nameLabel, registrant).price; - assertEq(requiredPrice, 0); + assertGt(requiredPrice, 0); extra = bound(extra, 0, 5 ether); uint256 balanceBefore = registrant.balance; vm.startPrank(registrant); - dotnsRegistrarController.register{value: extra}(registration); + dotnsRegistrarController.register{value: requiredPrice + extra}(registration); vm.stopPrank(); - // Zero-priced mint with overpayment: the EOA payer receives the full - // `extra` back inline, leaving the pull ledger untouched. + // Overpayment is refunded inline to the EOA payer, leaving the pull ledger untouched. assertEq( registrant.balance, - balanceBefore, - "zero-priced EOA mint must net out balances when overpaid" + balanceBefore - requiredPrice, + "payer nets out to exactly the price when overpaid" ); assertEq( dotnsNameEscrow.pendingWithdrawal(registrant), 0, - "zero-priced EOA mint must not credit the pull ledger" + "EOA mint must not credit the pull ledger" ); bytes32 labelhash = keccak256(bytes(nameLabel)); @@ -148,7 +142,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, nameOwner, true, payer); uint256 requiredPrice = popRules.priceWithCheck(nameLabel, nameOwner).price; - assertEq(requiredPrice, 0); + assertGt(requiredPrice, 0); extra = bound(extra, 0, 5 ether); @@ -159,9 +153,11 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { dotnsRegistrarController.register{value: requiredPrice + extra}(registration); vm.stopPrank(); - // EOA payers receive the refund inline. Owner's wallet stays untouched + // EOA payers receive the overpayment refund inline. Owner's wallet stays untouched // and the pull ledger is bypassed for both parties. - assertEq(payer.balance, payerBalanceBefore, "EOA payer must net to zero on a free mint"); + assertEq( + payer.balance, payerBalanceBefore - requiredPrice, "EOA payer pays exactly the price" + ); assertEq( dotnsNameEscrow.pendingWithdrawal(payer), 0, @@ -192,7 +188,7 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { _commitFor(nameLabel, sender, true); vm.startPrank(sender); - dotnsRegistrarController.register{value: 0}(registration); + dotnsRegistrarController.register{value: popRules.price(nameLabel)}(registration); vm.stopPrank(); bytes32 labelhash = keccak256(bytes(nameLabel)); @@ -225,8 +221,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { IDotnsRegistrarController.Registration memory primaryRegistration = _commitFor(primaryName, nameOwner, true); + uint256 primaryPrice = popRules.price(primaryName); vm.prank(nameOwner); - dotnsRegistrarController.register{value: 0}(primaryRegistration); + dotnsRegistrarController.register{value: primaryPrice}(primaryRegistration); assertEq(dotnsReverseResolver.nameOf(nameOwner), string.concat(primaryName, ".dot")); @@ -237,8 +234,9 @@ contract DotnsRegistrarControllerFuzzTest is BaseDotns { IDotnsRegistrarController.Registration memory giftedRegistration = _commitFor(giftedName, nameOwner, true, payer); + uint256 giftedPrice = popRules.price(giftedName); vm.prank(payer); - dotnsRegistrarController.register{value: 0}(giftedRegistration); + dotnsRegistrarController.register{value: giftedPrice}(giftedRegistration); assertEq(dotnsReverseResolver.nameOf(nameOwner), string.concat(primaryName, ".dot")); } @@ -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); @@ -403,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.reverts.t.sol b/test/intergration/BasicDotns.reverts.t.sol index bd940ef0..5c892d3d 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 b75d0583..25ed2440 100644 --- a/test/intergration/BasicDotns.t.sol +++ b/test/intergration/BasicDotns.t.sol @@ -5,8 +5,6 @@ import {BaseDotns} from "../base/BaseDotns.t.sol"; import {IDotnsRegistry} from "../../contracts/registry/IDotnsRegistry.sol"; import {IDotnsRegistrarController} from "../../contracts/registrars/IDotnsRegistrarController.sol"; import {ILabelStore} from "../../contracts/store/ILabelStore.sol"; -import {IPersonhood} from "../../contracts/external/personhood/IPersonhood.sol"; -import {DotnsConstants} from "../../contracts/utils/DotnsConstants.sol"; /// @title BasicDotnsIntegration /// @notice End-to-end happy-path integration coverage for registration, @@ -91,16 +89,6 @@ contract BasicDotnsIntegration is BaseDotns { ); } - /// @notice Returns true when the personhood precompile reports `account` at - /// tier `Lite` (1) or `Full` (2) under the dotns context. - /// @dev Reads the precompile mock installed by @custom:contract BaseDotns so the integration - /// flow gates pricing assertions on the same tier the controller sees. - function _personhoodTierIsAtLeastLite(address account) internal view returns (bool) { - IPersonhood.PersonhoodInfo memory info = IPersonhood(DotnsConstants.PERSONHOOD) - .personhoodStatus(account, DotnsConstants.PERSONHOOD_CONTEXT); - return info.status >= 1; - } - /// @notice Drives a full end-to-end registration, records, subname, and /// transfer flow under the configuration described by `flow`. /// @dev Aggregates the assertions that every PoP-tier-specific test case @@ -108,10 +96,9 @@ contract BasicDotnsIntegration is BaseDotns { /// bag. function _flowEndToEnd(FlowParams memory flow) internal { uint256 quotedPriceBefore = popRules.priceWithCheck(flow.name, flow.nameOwner).price; - - if (_personhoodTierIsAtLeastLite(flow.nameOwner)) { - assertEq(quotedPriceBefore, 0); - } + // Cross-entry-point consistency: the concrete curve amounts are covered by the PopRules + // unit tests; this only asserts that priceWithCheck and price agree. + assertEq(quotedPriceBefore, popRules.price(flow.name)); _commitAndRegister(flow.name, flow.nameOwner, flow.reserved); @@ -186,10 +173,9 @@ contract BasicDotnsIntegration is BaseDotns { uint256 transferRecipientQuotedPrice = popRules.priceWithCheck(flow.transferRecipientNewName, flow.transferTo).price; - - if (_personhoodTierIsAtLeastLite(flow.transferTo)) { - assertEq(transferRecipientQuotedPrice, 0); - } + // Cross-entry-point consistency: the concrete curve amounts are covered by the PopRules + // unit tests; this only asserts that priceWithCheck and price agree. + assertEq(transferRecipientQuotedPrice, popRules.price(flow.transferRecipientNewName)); _commitAndRegister(flow.transferRecipientNewName, flow.transferTo, false); diff --git a/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol b/test/invariant/escrow/DotnsNameEscrowInvariant.t.sol index 1b2ca363..c7022207 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.insuranceFund(); + uint256 protocolFees = dotnsNameEscrow.protocolFees(); uint256 pending = handler.totalPendingWithdrawals(); uint256 refundEntries = handler.totalPendingRefundEntries(); assertGe( escrowBalance, - reservedAmount + insurance + pending + refundEntries, - "Escrow balance must cover reserves + insurance + pending withdrawals + refund entries" + reservedAmount + protocolFees + pending + refundEntries, + "Escrow balance must cover reserves + protocol fees + pending withdrawals + refund entries" + ); + } + + /// @notice The handler's mirror of protocol-fee inflows must equal the escrow's on-chain + /// protocol-fee balance. Protocol fees only ever accrue, so every inflow the handler + /// tracks (cross-tier register and payable transfer) must sum to exactly the balance. + function invariant_protocol_fees_match_tracked_inflows() public view { + assertEq( + handler.ghost_protocolFeesPaidIn(), + dotnsNameEscrow.protocolFees(), + "Tracked protocol-fee inflows must equal on-chain protocol fees" ); } diff --git a/test/invariant/escrow/EscrowHandler.t.sol b/test/invariant/escrow/EscrowHandler.t.sol index b8459fa6..6416b6fe 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. - /// @dev Increments via cross-tier register (`depositInsurance`) and payable `transferFrom` - /// (`chargeTransferFee`). Counterpart to `ghost_insurancePaidOut`. - uint256 public ghost_insurancePaidIn; - - /// @notice Cumulative native amount drawn out of the insurance fund. - /// @dev Updated by parsing `InsuranceDraw` events emitted from `withdraw()`. The - /// conservation invariant asserts `ghost_insurancePaidIn - ghost_insurancePaidOut - /// == escrow.insuranceFund()`. - uint256 public ghost_insurancePaidOut; + /// @notice Cumulative native amount credited into protocol fees by handler-driven flows. + /// @dev Increments via cross-tier register (`depositProtocolFee`) and payable `transferFrom` + /// (`chargeTransferFee`). Protocol fees only ever accrue, so a conservation invariant + /// asserts this equals `escrow.protocolFees()`. + uint256 public ghost_protocolFeesPaidIn; /// @notice Cumulative native amount credited to recipients via `withdraw()`. uint256 public ghost_pendingCredits; @@ -156,7 +151,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 +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 `depositInsurance`. The + // whole charge into protocol fees via `depositProtocolFee`. The // refundable deposit position is seeded at zero amount, so the only - // mutation invariant tracking has to mirror here is the insurance leg. - uint256 priorInsurance = escrow.insuranceFund(); + // mutation invariant tracking has to mirror here is the protocol-fee leg. bytes32 labelhash = keccak256(bytes(label)); bytes32 node = keccak256(abi.encodePacked(DOT_NODE, labelhash)); uint256 tokenId = uint256(node); @@ -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.insuranceFund(); - _depositedTokenIds.push(tokenId); labelByTokenId[tokenId] = label; // Cross-payer registrations seed a zero-amount refundable position; // ghost-state mirrors that by leaving `depositAmounts` at zero. depositAmounts[tokenId] = 0; - if (newInsurance > priorInsurance) { - ghost_insurancePaidIn += (newInsurance - priorInsurance); - } - - // Track InsuranceDraw outflows surfaced by this transaction (defensive; the - // register path itself does not draw insurance, but recordLogs is already on). - _accountInsuranceDraws(logs); + // The whole cross-payer charge becomes protocol fee. Accumulate the + // independently-computed `charge` so the conservation invariant verifies the + // escrow credited exactly what the caller was charged, not an echo of its own state. + ghost_protocolFeesPaidIn += charge; } catch { return; } @@ -307,8 +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. @@ -455,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. @@ -506,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 @@ -514,24 +497,13 @@ contract EscrowHandler is Test { uint256 requiredFee = registrar.quoteTransferFee(tokenId, to); if (requiredFee == 0) return; - uint256 priorInsurance = escrow.insuranceFund(); - - vm.recordLogs(); vm.prank(currentOwner); try registrar.transferFrom{value: requiredFee}(currentOwner, to, tokenId) { - Vm.Log[] memory logs = vm.getRecordedLogs(); - - // Read the on-chain insurance delta rather than predicting it. The - // chargeTransferFee path credits the reach floor to insurance; when - // the NFT is leaving its prior position recipient the position is - // rebound to the new holder rather than refunded, so reserves stay - // put and only insurance moves. Mirroring the formula in the handler - // would re-create the drift this guard is meant to prevent. - uint256 newInsurance = escrow.insuranceFund(); - if (newInsurance > priorInsurance) { - ghost_insurancePaidIn += (newInsurance - priorInsurance); - } - _accountInsuranceDraws(logs); + // The transfer fee is credited in full to protocol fees: the position rebinds + // to the new holder rather than refunding, so reserves stay put. Accumulate the + // independently-quoted `requiredFee` so the conservation invariant verifies the + // escrow credited exactly the quoted fee. + ghost_protocolFeesPaidIn += requiredFee; // Sync the amount as a safety net against future downgrade paths. // Under the deposit-follows-name design the leaving-recipient branch @@ -579,7 +551,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; @@ -591,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. @@ -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 7252fe01..82e8dc6c 100644 --- a/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol +++ b/test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol @@ -102,9 +102,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.insuranceFund(); + uint256 protocolFees = dotnsNameEscrow.protocolFees(); uint256 pendingTotal; for (uint256 i; i < 5; ++i) { @@ -118,8 +118,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 c6f2740a..2f9b0002 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,27 +419,27 @@ contract DotnsNameEscrowTest is BaseDotns { _commitAndRegister(liteLabel, ed, false); uint256 tokenId = _tokenIdForLabel(liteLabel); - uint256 startingPrice = popRules.startingPrice(); + uint256 ownPrice = popRules.price(liteLabel); uint256 quotedFee = dotnsRegistrar.quoteTransferFee(tokenId, leonardo); - assertEq(quotedFee, startingPrice, "holder-downgrade should fire at D"); + assertEq(quotedFee, ownPrice, "downgrade re-prices at the name's own length"); - uint256 priorInsurance = dotnsNameEscrow.insuranceFund(); + uint256 priorProtocolFees = dotnsNameEscrow.protocolFees(); vm.deal(ed, quotedFee); vm.prank(ed); dotnsRegistrar.transferFrom{value: quotedFee}(ed, leonardo, tokenId); assertEq( - dotnsNameEscrow.insuranceFund() - priorInsurance, - startingPrice, - "downgrade friction must settle to insurance" + dotnsNameEscrow.protocolFees() - priorProtocolFees, + ownPrice, + "downgrade fee settles to protocol fees" ); } function test_transfer_charges_friction_and_rebinds_position() public { // Downward cross-tier transfer of a funded NoStatus name: the friction fee settles to - // insurance and the deposit travels with the NFT. There is no transfer-time refund: + // protocol fees and the deposit travels with the NFT. There is no transfer-time refund: // `position.recipient` rebinds to the new holder, the locked deposit follows, and only // the new holder can later release into escrow. Promoting `ed` to PopFull before the // transfer forces `PopRules.transferFloor` to return `startingPrice` while the position @@ -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,9 +472,9 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves must not move when the deposit follows the NFT" ); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, startingPrice, - "friction fee settles to insurance independently of the deposit" + "friction fee settles to protocol fees independently of the deposit" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -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(); @@ -511,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 }) ); @@ -525,9 +524,9 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves unchanged when no refund fires" ); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, fee, - "fee leg still settles to insurance" + "fee leg still settles to protocol fees" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -617,7 +616,7 @@ contract DotnsNameEscrowTest is BaseDotns { uint256 tokenId = _registerNoStatus(LABEL, ed); uint256 reservesAtStart = dotnsNameEscrow.reserves(address(0)); - uint256 insuranceAtStart = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesAtStart = dotnsNameEscrow.protocolFees(); uint256 edRefundsAtStart = dotnsNameEscrow.pendingRefundCount(ed); uint256 leonardoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(leonardo); uint256 tiagoRefundsAtStart = dotnsNameEscrow.pendingRefundCount(tiago); @@ -650,9 +649,9 @@ contract DotnsNameEscrowTest is BaseDotns { "reserves must stay put while the deposit follows the NFT" ); assertEq( - dotnsNameEscrow.insuranceFund(), - insuranceAtStart, - "same-tier hops must not credit insurance" + dotnsNameEscrow.protocolFees(), + protocolFeesAtStart, + "same-tier hops must not credit protocol fees" ); assertEq( dotnsNameEscrow.pendingRefundCount(ed), @@ -714,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"; @@ -722,23 +721,23 @@ 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; 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.insuranceFund() - priorInsurance, + 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, @@ -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 99b11969..478ececb 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 f7635e48..b65b9112 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 73154de7..2a2e6661 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,18 +92,17 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { dotnsRegistrarController.commit(commitment); vm.warp(block.timestamp + dotnsRegistrarController.minCommitmentAge() + 1); - uint256 insuranceBefore = dotnsNameEscrow.insuranceFund(); + uint256 protocolFeesBefore = dotnsNameEscrow.protocolFees(); vm.prank(payer); dotnsRegistrarController.register{value: expectedCharge}(registration); - // Cross-payer charge is the greater of owner-side price and reach-floor friction, - // never their sum. The whole charge routes to the insurance fund; the refundable - // deposit branch is reserved for direct registrants. + // Cross-payer charge is the name's own curve price. The whole charge routes to protocol + // fees; the refundable deposit branch is reserved for direct registrants. assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, expectedCharge, - "cross-payer must credit max(ownerPrice, reachFloor) to insurance" + "cross-payer must credit the name's own curve price to protocol fees" ); } @@ -139,7 +139,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; @@ -163,7 +163,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 +174,13 @@ contract DotnsRegistrarControllerLifecycleTest is BaseDotns { assertEq(position.amount, 0, "cross-payer must not seed a refundable position"); assertEq( - dotnsNameEscrow.insuranceFund() - insuranceBefore, + dotnsNameEscrow.protocolFees() - protocolFeesBefore, ownerPrice, - "cross-payer price must accrue to insurance" + "cross-payer price must accrue to protocol fees" ); } - function test_register_creates_escrow_position_for_zero_priced_registration() public { + function test_register_creates_funded_position_for_self_registration() public { string memory label = BASE_LABEL_A; address nameOwner = ed; _grantPopFull(nameOwner); @@ -197,14 +197,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 +216,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"); }