Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bebd473
feat: price dotNS names on a scarcity curve
sphamjoli Aug 7, 2026
39c496b
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 7, 2026
7a709c8
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 7, 2026
954eab5
test(invariant): make protocol-fee conservation independent of on-cha…
sphamjoli Aug 7, 2026
57fa214
test(invariant): make protocol-fee conservation independent of on-cha…
sphamjoli Aug 7, 2026
25c397d
fix(pricing): bound the base fee and align transfer-fee accounting
sphamjoli Aug 8, 2026
56222cc
docs(pricing): key the free grant to the wallet, any length
sphamjoli Aug 9, 2026
524881b
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 12, 2026
eb940f7
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 18, 2026
80b7fef
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 21, 2026
16850ee
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 22, 2026
3585208
chore: ensure D and F are configurable
sphamjoli Aug 22, 2026
64515fb
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 24, 2026
b2276bd
fix: model update
sphamjoli Aug 26, 2026
e93b429
chore: update readme
sphamjoli Aug 26, 2026
ae813e0
Merge remote-tracking branch 'origin/master' into spha/scarcity-pricing
sphamjoli Aug 26, 2026
745fffe
fix: manifest
sphamjoli Aug 27, 2026
4d9f342
fix: deployment script
sphamjoli Aug 27, 2026
a136f09
chore: address PR comments
sphamjoli Aug 31, 2026
70bff87
Merge branch 'master' into spha/scarcity-pricing
sphamjoli Aug 31, 2026
f87a996
Merge branch 'spha/scarcity-pricing' of https://github.com/paritytech…
sphamjoli Aug 31, 2026
3a8945c
fix: remove root checking and remove decorative seperators
sphamjoli Aug 31, 2026
8c801fc
chore: address PR comments
sphamjoli Aug 31, 2026
59c9a58
fix: stale manifest
sphamjoli Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/abi-contracts.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ DotnsPopResolver
DotnsRoleManager
RootGatewayDispatcher
DotnsNameWhitelist
DotnsCostModelRegistry
DotnsFlatPricing

IStoreFactory
ILabelStore
Expand All @@ -46,3 +48,5 @@ IDotnsPopResolver
IDotnsController
IDotnsRoleManager
IDotnsNameWhitelist
IDotnsCostModelRegistry
IDotnsPricing
10 changes: 8 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,19 @@ Before opening a pull request:
- Interfaces should describe public/external functions with NatSpec.
- Keep implementations aligned with the interface surface area. Avoid unused methods.

3. Tests
3. Comments
- Comments explain behaviour. Decorative separator comments are not allowed: a comment whose
content is a run of rule characters, such as a line of dashes, equals, hashes, or asterisks
under or around a heading. Group code with functions and NatSpec, not with drawn rules. The
pre-commit check rejects them.

4. Tests
- Add unit tests for behaviour changes.
- Use fuzz tests where they add meaningful coverage.
- Prefer readable, behaviour-oriented test names and assertions.
- Keep tests deterministic unless explicitly fuzzing.

4. Commit hygiene
5. Commit hygiene
- Keep commits logically grouped.
- Squash when appropriate to keep history clean (maintainers may squash on merge).

Expand Down
133 changes: 77 additions & 56 deletions README.md

Large diffs are not rendered by default.

86 changes: 33 additions & 53 deletions contracts/escrow/DotnsNameEscrow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,10 @@ contract DotnsNameEscrow is
/// @notice Reverse lookup into `_releasedTokens` (one-based) for O(1) remove-by-swap.
mapping(uint256 tokenId => uint256 indexPlusOne) private _releasedIndexPlusOne;

/// @notice Cumulative balance of cross-tier fees held against unreleased shortfalls.
/// @dev Credited by cross-tier registration deposits, reach-floor friction, and transfer-fee
/// deltas; debited only when `withdraw` needs to top up a refund that exceeds the
/// asset's reserved balance.
uint256 public insuranceFund;
/// @notice Cumulative balance of non-refundable protocol fees; only accumulates.
/// @dev Credited by cross-paid registration fees and transfer fees. Never debited: protocol
/// fees do not back refunds, which draw solely on the per-asset reserve.
uint256 public protocolFees;

/// @notice Pull-payment ledger storing each recipient's claimable refund balance.
/// @dev Per-recipient isolation ensures a failing or reentrant receiver cannot block other
Expand Down Expand Up @@ -255,8 +254,8 @@ contract DotnsNameEscrow is
ReleasePosition storage position = _positions[params.tokenId];

// Use `recipient` as the "is this slot funded?" sentinel so zero-amount
// positions (seeded by free PopFull / PopLite registrations) still count
// as funded and cannot be re-seeded with a different recipient.
// positions (seeded by cross-paid registrations, which pay a fee rather than a deposit)
// still count as present and cannot be re-seeded with a different recipient.
require(position.recipient == address(0), PositionAlreadyFunded(params.tokenId));
require(!position.released, AlreadyReleased(params.tokenId));

Expand All @@ -278,15 +277,15 @@ contract DotnsNameEscrow is
}

/// @inheritdoc IDotnsNameEscrow
function depositInsurance(InsuranceDepositParams calldata params)
function depositProtocolFee(ProtocolFeeDepositParams calldata params)
external
payable
override
onlyController
{
require(msg.value > 0, InvalidAmount());

insuranceFund += msg.value;
protocolFees += msg.value;

emit CrossTierFeePaid(
params.tokenId,
Expand Down Expand Up @@ -315,7 +314,7 @@ contract DotnsNameEscrow is

address priorRecipient = position.recipient;

uint256 fee = params.reachFloor;
uint256 fee = params.transferFee;
require(msg.value >= fee, InsufficientValue());

// Deposits follow the NFT, not the depositor. When the position is funded the locked
Expand All @@ -326,20 +325,19 @@ contract DotnsNameEscrow is
position.recipient = params.to;
}

if (fee > 0) {
insuranceFund += fee;
}

charged = fee;

emit CrossTierFeePaid(
params.tokenId,
params.payer,
params.to,
fee,
/* isRegistration */
false
);
if (fee > 0) {
protocolFees += fee;
emit CrossTierFeePaid(
params.tokenId,
params.payer,
params.to,
fee,
/* isRegistration */
false
);
}

uint256 overpayment = msg.value - fee;
if (overpayment > 0) {
Expand All @@ -355,8 +353,8 @@ contract DotnsNameEscrow is

ReleasePosition storage position = _positions[tokenId];
// Recipient is the canonical "is this position present?" sentinel; zero-amount positions
// seeded for free PopFull / PopLite registrations are still releasable so every minted
// name has a reachable lifecycle.
// seeded for cross-paid registrations are still releasable so every minted name has a
// reachable lifecycle.
require(position.recipient != address(0), DepositNotConfigured(tokenId));
require(!position.released, AlreadyReleased(tokenId));

Expand Down Expand Up @@ -415,20 +413,16 @@ contract DotnsNameEscrow is
WithdrawalTooEarly(tokenId, position.withdrawAvailableAt, block.timestamp)
);

// `position.recipient == msg.sender` was just enforced above, so reuse the local in place
// of an extra warm SLOAD.
_settleDeposit(position, tokenId, msg.sender);
}

/// @notice Moves a position's outstanding deposit onto the recipient's pull-payment balance.
/// @dev Shared by @custom:function withdraw, where the recipient pulls the deposit themselves,
/// and by @custom:function reclaim, where a third party takes the name and the deposit is
/// settled on the departing holder's behalf. Both credit the same ledger and neither
/// transfers value, so the accounting is identical and lives here once. Draws from the
/// per-asset `tokenReserved` pool first and tops up from `insuranceFund` on shortfall;
/// @custom:reverts InsufficientFunds when even the combined balance cannot cover the
/// amount owed. Emits @custom:emits RefundWithdrawn, and @custom:emits InsuranceDraw
/// whenever the insurance fund contributes.
/// transfers value, so the accounting is identical and lives here once. The per-asset
/// `tokenReserved` pool backs the refund in full, and @custom:reverts InsufficientFunds
/// when it cannot cover the amount owed. Emits @custom:emits RefundWithdrawn.
/// A zero-amount position is a no-op: it writes nothing and emits nothing, which keeps the
/// free-registration lifecycle free of meaningless ledger entries and events.
/// @param position Storage pointer to the position being settled.
Expand All @@ -455,28 +449,14 @@ contract DotnsNameEscrow is
// Effects: from here the deposit really is being handed over, so the flag is set.
position.claimed = true;

uint256 reserved = tokenReserved[asset];

uint256 fromRefundable;
uint256 fromInsurance;
if (reserved >= owed) {
fromRefundable = owed;
// fromInsurance is already 0 from default initialization.
} else {
fromRefundable = reserved;
fromInsurance = owed - reserved;
require(
insuranceFund >= fromInsurance,
InsufficientFunds(tokenId, owed, reserved + insuranceFund)
);
}
// The per-asset reserve backs every refundable deposit; protocol fees are non-refundable
// and never cover a refund.
require(
tokenReserved[asset] >= owed, InsufficientFunds(tokenId, owed, tokenReserved[asset])
);

position.amount = 0;
tokenReserved[asset] -= fromRefundable;
if (fromInsurance > 0) {
insuranceFund -= fromInsurance;
emit InsuranceDraw(tokenId, fromInsurance);
}
tokenReserved[asset] -= owed;

_pendingWithdrawals[recipient] += owed;

Expand Down Expand Up @@ -720,8 +700,8 @@ contract DotnsNameEscrow is
// instead, and reclaim settles any unwithdrawn value rather than holding it hostage.
//
// Lifecycle state only. `reclaim` also settles the deposit, which can in principle revert
// `InsufficientFunds` when the reserved balance plus the insurance fund cannot cover the
// amount owed, so a true answer here is a claim about the window rather than a guarantee
// `InsufficientFunds` when the reserved balance cannot cover the amount owed, so a true
// answer here is a claim about the window rather than a guarantee
// that the call is funded. The two coincide because `tokenReserved` is by construction the
// exact sum of live position amounts: only `deposit` credits it, and only `_settleDeposit`
// debits it, by exactly the amount it zeroes. `invariant_reserves_match_positions` holds
Expand Down
71 changes: 32 additions & 39 deletions contracts/escrow/IDotnsNameEscrow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -146,9 +146,9 @@ interface IDotnsNameEscrow {
/// @param recipient Address the NFT was returned to, which is also the position recipient.
event NameRedeemed(uint256 indexed tokenId, address indexed recipient);

/// @notice Emitted when a cross-tier fee is paid into the insurance fund.
/// @notice Emitted when a cross-paid fee is paid into the protocol fee pot.
/// @param payer Original `msg.sender` whose value funded the fee.
/// @param isRegistration True when emitted from `depositInsurance`; false from
/// @param isRegistration True when emitted from `depositProtocolFee`; false from
/// `chargeTransferFee`.
event CrossTierFeePaid(
uint256 indexed tokenId,
Expand All @@ -158,10 +158,6 @@ interface IDotnsNameEscrow {
bool isRegistration
);

/// @notice Emitted when a withdrawal draws from the insurance fund to cover a shortfall in
/// `tokenReserved`.
event InsuranceDraw(uint256 indexed tokenId, uint256 amount);

/// @notice Emitted when overpayment is refunded to the payer.
event OverpaymentRefunded(address indexed payer, uint256 amount);

Expand All @@ -177,8 +173,8 @@ interface IDotnsNameEscrow {
/// @notice Thrown when the attached call value is insufficient to cover the computed charge.
error InsufficientValue();

/// @notice Thrown when neither `tokenReserved` nor the insurance fund can cover the refund.
/// @param available Combined balance available across reserves and insurance.
/// @notice Thrown when the per-asset reserve cannot cover the refund owed.
/// @param available Reserve balance available for the asset.
error InsufficientFunds(uint256 tokenId, uint256 owed, uint256 available);

/// @notice Thrown when assets being deposited are not supported by the escrow.
Expand Down Expand Up @@ -322,11 +318,11 @@ interface IDotnsNameEscrow {
/// Emits @custom:emits NativeDepositRecorded once the deposit is booked.
function deposit(DepositParams calldata params) external payable;

/// @notice Records a cross-tier registration fee into the insurance fund.
/// @notice Records a cross-paid registration fee into the protocol fee pot.
/// @dev Only the configured controller may call this, otherwise @custom:reverts NotController.
/// `msg.value` must be non-zero, otherwise @custom:reverts InvalidAmount. Emits
/// @custom:emits CrossTierFeePaid with `isRegistration = true` once the fee is booked.
function depositInsurance(InsuranceDepositParams calldata params) external payable;
function depositProtocolFee(ProtocolFeeDepositParams calldata params) external payable;

/// @notice Credits `msg.value` to `recipient`'s pull-payment ledger so the caller can later
/// pull the balance with @custom:func claimWithdrawal.
Expand All @@ -338,7 +334,7 @@ interface IDotnsNameEscrow {
/// @param recipient Address whose pending balance should grow by `msg.value`.
function creditOverpayment(address recipient) external payable;

/// @notice Charges transfer friction and rebinds the token's escrow position to the new holder.
/// @notice Charges the transfer fee and rebinds the token's escrow position to the new holder.
/// @dev Only the configured registrar may call this, otherwise @custom:reverts NotRegistrar.
/// When a fee is owed, the attached value must cover it or @custom:reverts
/// InsufficientValue. Whenever a position exists for the token and the NFT is leaving its
Expand All @@ -347,17 +343,17 @@ interface IDotnsNameEscrow {
/// escrow does not refund anyone at transfer time; the only path back to the locked
/// deposit is for the current holder to release into escrow and wait the cooldown.
/// Emits @custom:emits CrossTierFeePaid (non-registration) when a non-zero fee is credited
/// to insurance, and credits any surplus value to the payer on the time-locked refund
/// ledger via @custom:emits RefundCredited.
/// @return charged Amount actually credited to insurance.
/// to the protocol fee pot, and credits any surplus value to the payer on the time-locked
/// refund ledger via @custom:emits RefundCredited.
/// @return charged Amount actually credited to the protocol fee pot.
function chargeTransferFee(ChargeTransferFeeParams calldata params)
external
payable
returns (uint256 charged);

/// @notice Returns the cumulative cross-tier fee balance held against future shortfalls.
/// @return balance Current insurance fund balance, in wei.
function insuranceFund() external view returns (uint256 balance);
/// @notice Returns the cumulative protocol fee balance, non-refundable and accumulating.
/// @return balance Current protocol fee balance, in wei.
function protocolFees() external view returns (uint256 balance);

/// @notice Releases a token into escrow and starts the withdrawal cooldown.
/// @dev First step of the phased lifecycle. The caller must be the current NFT holder and the
Expand Down Expand Up @@ -386,12 +382,11 @@ interface IDotnsNameEscrow {
/// AlreadyClaimed on re-entry). Only the current position recipient (the address that
/// released the name, which mirrored the NFT holder at that moment) may call this,
/// otherwise @custom:reverts NotRefundRecipient, and `block.timestamp` must have reached
/// `withdrawAvailableAt`, otherwise @custom:reverts WithdrawalTooEarly. Draws from the
/// per-asset `tokenReserved` pool first and falls back to the shared insurance fund on
/// shortfall; if even the combined balance is short, @custom:reverts InsufficientFunds.
/// Funds are not transferred here, only credited to the pull-payment ledger. Emits
/// @custom:emits RefundWithdrawn once the credit lands, and @custom:emits InsuranceDraw
/// whenever the insurance fund tops up a shortfall.
/// `withdrawAvailableAt`, otherwise @custom:reverts WithdrawalTooEarly. Refunds are backed
/// entirely by the per-asset `tokenReserved` pool; if that reserve is short,
/// @custom:reverts InsufficientFunds. Protocol fees never back a refund. Funds are not
/// transferred here, only credited to the pull-payment ledger. Emits @custom:emits
/// RefundWithdrawn once the credit lands.
function withdraw(uint256 tokenId) external;

/// @notice Pulls the caller's accumulated pending refund balance.
Expand All @@ -416,13 +411,11 @@ interface IDotnsNameEscrow {
/// NotReclaimable. Emits @custom:emits NameReclaimed once custody is transferred.
/// Reclaim does not require the deposit to have been withdrawn first. If the position
/// still holds value, this call settles it: the amount is debited from `tokenReserved`
/// (topping up from the insurance fund on shortfall, @custom:reverts InsufficientFunds if
/// even the combined balance is short) and credited to the previous recipient's
/// pull-payment balance, claimable through @custom:function claimWithdrawal with no
/// deadline. That is what keeps a name recyclable when its previous holder never returns:
/// the value follows them, the name does not wait for them. Emits @custom:emits
/// RefundWithdrawn on settlement, and @custom:emits InsuranceDraw when the insurance fund
/// tops up a shortfall.
/// (@custom:reverts InsufficientFunds if the reserve is short) and credited to the
/// previous recipient's pull-payment balance, claimable through @custom:function
/// claimWithdrawal with no deadline. That is what keeps a name recyclable when its
/// previous holder never returns: the value follows them, the name does not wait for them.
/// Emits @custom:emits RefundWithdrawn on settlement.
/// @param newOwner Address of the new registrant taking over the name.
function reclaim(uint256 tokenId, address newOwner) external;

Expand Down
Loading
Loading