Skip to content

feat: price dotNS names on a scarcity curve - #208

Open
sphamjoli wants to merge 18 commits into
masterfrom
spha/scarcity-pricing
Open

feat: price dotNS names on a scarcity curve#208
sphamjoli wants to merge 18 commits into
masterfrom
spha/scarcity-pricing

Conversation

@sphamjoli

@sphamjoli sphamjoli commented Aug 7, 2026

Copy link
Copy Markdown
Member

Description

Prices every name by its length on one geometric curve instead of a flat fee. The price doubles for each character below nine and halves for each character above it, down to a floor F, so short scarce names cost the most and long abundant names bottom out at F rather than zero. A trailing two-digit number never changes the price, because it comes off before the length is measured.

Three bands share the curve. Names of nine characters or more are open to anyone at the curve price for the length. Names of six to eight characters are the premium band, registrable on the public paid path only by a verified person, who pays the curve for the length. Names of five characters or fewer are never sold on the curve; they enter circulation only through the reserved path, at zero base cost. Personhood only unlocks the premium band; the price there is the same curve everyone pays.

Names shorter than nine are closed on the public paid path by default. Governance opens them with a single switch (shortNamesEnabled); while it is off, a paid registration below nine reverts. The switch gates the public paid path alone: the gateway free grant and registerReserved do not consult it.

Registering under your own key locks a refundable deposit equal to the name's curve price. Fees, paid when someone else pays for a name or on a qualifying transfer, are non-refundable and gather in one protocol fee pot that only accumulates; it backs no refund and has no burn, sweep, or withdrawal. Moving a name re-prices it at its own length when the recipient cannot clear the band or the move is a personhood downgrade, so a scarce name cannot be handed cheaply to a party who could never have registered it.

The wei amounts live in a swappable cost model resolved through the protocol registry. Changing the curve deploys and registers a new model version; earlier versions stay priceable, and a commitment binds both a price ceiling (maxPrice) and the pricingVersion, so the reveal prices at the committed version and a model change between commit and reveal cannot move the amount. Governance can also point the current version back at an earlier registered model.

D and F are fixed for the life of a model; the model constructor enforces 0 < F <= D and an anti-overflow ceiling, so the curve can never invert or price at zero. A stateful invariant proves the cost-model registry stays coherent under any sequence of registrations and version moves: every registered version keeps returning the same amount, and the current pointer always resolves to its registered model.

The formula

$$ \text{price}(n) = \max!\left(F,; D \cdot 2^{,9 - n}\right) $$

n is the base length: the characters left once a trailing two-digit number is set aside. D is the base fee, the price at nine characters; F is the floor. Below nine, dropping a character doubles the price; from nine upward, adding a character halves it, until the floor F stops it reaching zero.

Every length, at the launch seed D = 10 DOT, F = 0.1 DOT

Only names of nine characters or more are on public sale by default; base lengths below nine sit behind the short-name switch.

Base length (n) Curve Price On public sale by default
6 8D 80 DOT No, behind the short-name switch
7 4D 40 DOT No, behind the short-name switch
8 2D 20 DOT No, behind the short-name switch
9 D 10 DOT Yes
10 D/2 5 DOT Yes
11 D/4 2.5 DOT Yes
12 D/8 1.25 DOT Yes
13 D/16 0.625 DOT Yes
14 D/32 0.3125 DOT Yes
15 D/64 0.15625 DOT Yes
16 and above floor F 0.1 DOT Yes

At base length 16 the curve value is D/128 = 0.078 DOT, below the floor, so every name of 16 characters or more settles at F. Base lengths of five and below sit further up the same curve but are never sold: governance issues reserved names through the reserved path at zero base cost.

Type

  • Bug fix
  • Feature
  • Breaking change
  • Documentation
  • Chore
  • Refactor
  • Security

Scope

  • Registration
  • Resolver
  • Store
  • Proof of Personhood
  • Deployment scripts
  • Tests

Related Issues

Transfer-fee cleanup that composes with soulbinding is tracked in #212.

Fixes

Closes #209.

Checklist

Code

  • Follows project style
  • forge build passes
  • forge test passes
  • No new compiler warnings

Testing

  • New tests added for changed behavior
  • Fuzz tests added where applicable
  • Invariant tests verified

Security

  • No new selfdestruct or delegatecall
  • Access control reviewed
  • No storage layout conflicts (for upgradeable contracts)

Documentation

  • NatSpec updated on changed interfaces
  • README updated if needed

Breaking Changes

  • No breaking changes
  • Breaking changes documented below

Breaking changes: the escrow fee pot is renamed from the insurance fund to protocol fees. insuranceFund() becomes protocolFees(), depositInsurance becomes depositProtocolFee, and the InsuranceDraw shortfall event and its top-up path are removed; refunds are backed by the per-asset reserve alone. PopRules.initialize now takes only the protocol registry: the base fee and floor no longer live on PopRules but seed a DotnsScarcityPricing model, registered in a new DotnsCostModelRegistry under the COST_MODEL protocol-registry key. Consumers reading the fee balance or the price must move to the new names.

How to test

forge test --no-match-path 'test/fork/**'

Notes

Governance is trusted for economic policy; the contract enforces only coherence (0 < F <= D, no overflow, no zero price) at model construction. There is no on-chain band or timelock on D by design, so any rate or notice guarantee is the governance owner's. Fees accumulate in one pot with no withdrawal path; there is no burn or treasury routing in these contracts.

Replace flat pricing with a geometric curve on base length: the price
doubles for each character below nine and flattens to the base fee from
nine upward. Every caller pays the curve; personhood gates access to the
premium band rather than discounting it. Transfers re-price at the name's
own length.

Rename the escrow insurance fund to protocol fees, a non-refundable pot
that only accumulates, and remove the dead refund-shortfall draw so
refunds draw solely on the per-asset reserve.
@sphamjoli
sphamjoli requested a review from waytwotall as a code owner August 7, 2026 05:43
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

CI Summary

Check Result
4naly3er Analysis Failed
Slither Analysis Failed
Contract Tests (Unit + Fuzz) Failed - Build error
Contract Tests (Invariant) Failed - Build error
Coverage Failed
Documentation Unknown
Format & Lint Failed - Build errors
Deploy Contracts Failed - Deployment error
PR Title PR Title Valid
Labels Unknown
Gas Report Failed - missing shard: pr master
Secret Scan Passed - No secrets detected

Deploy Contracts

Deployment Failed

Errors Found:

  • script "deploy:all" exited with code 134
  • script "deploy:all" exited with code 134

Last 50 lines of output:

  19: �[91m<forge_lint[7375ea74cfbb4dd9]::sol::low::RequireRevertInLoop as solar_lint[e7f807423041189f]::late::LateLintPass>::check_function�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  20: �[91m<solar_lint[e7f807423041189f]::late::LateLintVisitor as solar_sema[b8af583255ca61ff]::hir::visit::Visit>::visit_nested_item�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  21: �[91m<solar_lint[e7f807423041189f]::late::LateLintVisitor as solar_sema[b8af583255ca61ff]::hir::visit::Visit>::visit_nested_item�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  22: �[91mrayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper::<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  23: �[91mrayon_core[6b4d8c467a87af00]::join::join_context::<rayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>::{closure#0}, rayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>::{closure#1}, rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectResult<solar_lint[e7f807423041189f]::project::ProjectSource>, rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectResult<solar_lint[e7f807423041189f]::project::ProjectSource>>::{closure#0}�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  24: �[91mrayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper::<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  25: �[91mrayon_core[6b4d8c467a87af00]::join::join_context::<rayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>::{closure#0}, rayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>::{closure#1}, rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectResult<solar_lint[e7f807423041189f]::project::ProjectSource>, rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectResult<solar_lint[e7f807423041189f]::project::ProjectSource>>::{closure#0}�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  26: �[91mrayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper::<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  27: �[91m<rayon_core[6b4d8c467a87af00]::job::StackJob<rayon_core[6b4d8c467a87af00]::latch::SpinLatch, rayon_core[6b4d8c467a87af00]::join::join_context::call_b<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectResult<solar_lint[e7f807423041189f]::project::ProjectSource>, rayon[ca940aeba9d5821c]::iter::plumbing::bridge_producer_consumer::helper<rayon[ca940aeba9d5821c]::slice::IterProducer<(&std[5167731717db2e51]::path::PathBuf, alloc[93e694a8365d93dd]::sync::Arc<solar_interface[3ce003846366b0f0]::source_map::file::SourceFile>, &solar_ast[c909fe0e58661f22]::ast::SourceUnit, solar_sema[b8af583255ca61ff]::hir::SourceId)>, rayon[ca940aeba9d5821c]::iter::map::MapConsumer<rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectConsumer<solar_lint[e7f807423041189f]::project::ProjectSource>, solar_lint[e7f807423041189f]::runner::run_lints::{closure#0}>>::{closure#1}>::{closure#0}, rayon[ca940aeba9d5821c]::iter::collect::consumer::CollectResult<solar_lint[e7f807423041189f]::project::ProjectSource>> as rayon_core[6b4d8c467a87af00]::job::Job>::execute�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  28: �[91m<rayon_core[6b4d8c467a87af00]::registry::WorkerThread>::wait_until_cold�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  29: �[91m<rayon_core[6b4d8c467a87af00]::registry::ThreadBuilder>::run�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  30: �[91mstd[5167731717db2e51]::sys::backtrace::__rust_begin_short_backtrace::<<solar_interface[3ce003846366b0f0]::session::Session>::thread_pool::{closure#0}::{closure#0}::{closure#0}, ()>�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  31: �[91m<std[5167731717db2e51]::thread::lifecycle::spawn_unchecked<<solar_interface[3ce003846366b0f0]::session::Session>::thread_pool::{closure#0}::{closure#0}::{closure#0}, ()>::{closure#1} as core[8c5ac2f034688f9a]::ops::function::FnOnce<()>>::call_once::{shim:vtable#0}�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m
  32: �[91m<std[5167731717db2e51]::sys::thread::unix::Thread>::new::thread_start�[0m�[90m<unknown>�[0m
      at �[35m<unknown source file>�[0m:�[35m<unknown line>�[0m

Run with COLORBT_SHOW_HIDDEN=1 environment variable to disable frame filtering.
Run with RUST_BACKTRACE=full to include source snippets.
warning[calls-loop]: external call inside a loop
   ╭▸ test/invariant/registrar/DotnsRegistrarControllerInvariant.t.sol:60:17
   │
60 │                 dotnsRegistrarController.available(registeredLabels[i]),
   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   │
   ╰ help: https://getfoundry.sh/forge/linting/calls-loop

warning[calls-loop]: external call inside a loop
   ╭▸ lib/forge-std/src/StdAssertions.sol:72:13
   │
72 │             vm.assertFalse(data);
   │             ━━━━━━━━━━━━━━━━━━━━
   │
   ╰ help: https://getfoundry.sh/forge/linting/calls-loop

/home/runner/work/dotns/dotns/scripts/deploy/run.sh: line 75:  3733 Aborted                 (core dumped) forge build
error: script "deploy:all" exited with code 134

View full logs

…in state

The protocol-fee conservation invariant mirrored the on-chain balance delta into its own tracker, so it compared the escrow balance against an echo of itself and could never catch a miscredit. The handler now accumulates the independently computed charge and quoted transfer fee, and a new invariant asserts those tracked inflows equal the on-chain protocol fees. Dead insurance-draw scaffolding, its recorded-log parsing, and stale insurance wording are removed to match the protocol-fee model.
…in state (#210)

## Description

Follow-up to the scarcity-pricing change.

The protocol-fee conservation invariant used to read the on-chain
protocol-fee balance before and after each action and fold the
difference into its own tracker. Because both sides came from the same
source, the check compared the balance against an echo of itself and
would have passed even if a caller had been charged the wrong amount.
The handler now accumulates the fee it independently expects (the charge
on a cross-paid registration, the quoted fee on a payable transfer), so
the invariant proves the escrow credited exactly what the caller was
asked to pay. A second invariant asserts that the tracked inflows sum to
the on-chain protocol-fee balance, which holds because protocol fees
only ever accrue.

## Type

- [ ] Bug fix
- [ ] Feature
- [ ] Breaking change
- [ ] Documentation
- [ ] Chore
- [ ] Refactor
- [ ] Security

## Scope

- [ ] Registration
- [ ] Resolver
- [ ] Store
- [ ] Proof of Personhood
- [ ] Deployment scripts
- [x] Tests

## Related Issues

Stacked on #208.

## Fixes

## Checklist

### Code

- [x] Follows project style
- [x] `forge build` passes
- [x] `forge test` passes
- [x] No new compiler warnings

### Testing

- [ ] New tests added for changed behavior
- [ ] Fuzz tests added where applicable
- [x] Invariant tests verified

### Security

- [x] No new `selfdestruct` or `delegatecall`
- [x] Access control reviewed
- [x] No storage layout conflicts (for upgradeable contracts)

### Documentation

- [x] NatSpec updated on changed interfaces
- [x] README updated if needed

### Breaking Changes

- [x] No breaking changes
- [ ] Breaking changes documented below

**Breaking changes:**

## How to test

```bash
forge test --mc DotnsNameEscrowInvariantTest
```

## Notes

Stacks on #208; review and merge that first. The new pricing-invariant
suite is tracked separately in #209.
The base-fee setter rejects a value large enough to overflow the scarcity multiplier, so short-name pricing and transfers cannot be knocked offline by an oversized fee. Cross-payer registration charges the name's own curve price directly; the floor comparison that could never change the amount is removed. The transfer-fee event fires only when a fee is taken, so a zero-fee position rebind emits nothing. The escrow transfer-fee parameter and its registrar call sites read transferFee to match the value they carry, and the transfer-floor and fee-parameter documentation is completed.
The free name is claimed per wallet through the personhood gateway, which waives the price. The gateway lane accepts a name of any length and applies no public band gate; it only refuses the governance-reserved stems of five characters or fewer.
@re-gius

re-gius commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I would keep this PR on hold and have a broader discussion on this argument. This can be taken as a concrete proposal.

@re-gius re-gius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes based on the design proposed in #209 (comment).

The curve itself is fine, and the work here isn't wasted. Under the agreed design the pricing function moves into a standalone economics contract that governance owns and can replace, and a modified version of this curve can become its first proposal.

@sphamjoli

Copy link
Copy Markdown
Member Author

On the standalone economics contract: I've put the case for keeping pricing in PopRules and adding commitment-to-version binding in #209 (comment)

@re-gius re-gius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your effort and all these changes, it's much closer to what I was expecting. However, there are some fundamental design choices I would still change:

  1. v1 must reproduce today's on-chain prices: remove the scarcity pricing exponential model from this PR. Default deposit for v1 will be 10 DOT constant for all 9+ char names like today, while your scarcity model (and maybe some others) will be candidates for replacement in v2. We don't have any clarity on that yet.
  2. Bind the pricing version and enforce it: we may add currentVersion() to commit and make sure reveal requires a version check
  3. Allow governance to upgrade the economics: DotnsCostModelRegistry.sol:27 and :37 are onlyOwner with no originIsRoot() check. You can do what you did already for whitelisting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Decide the economics of DotNS name pricing

2 participants