diff --git a/.github/workflows/paseo-upgrade-step.yml b/.github/workflows/paseo-upgrade-step.yml new file mode 100644 index 000000000..bc360d666 --- /dev/null +++ b/.github/workflows/paseo-upgrade-step.yml @@ -0,0 +1,151 @@ +name: Paseo Upgrade Step + +# Broadcasts one runbook step against Paseo Asset Hub Next, driven by labels on the review pull +# request. Adding `run:UpgradeRegistry` runs that script, once, after a human approves. +# +# Why labels on a PR, of all things. A dispatchable workflow only registers from the default +# branch, and nothing that can broadcast with the owner key is going on master. A `pull_request` +# workflow is taken from the PR itself, so this file lives on `dev/testnet-upgrades` alone and +# still runs, and the review PR whose base is master becomes the broadcast console. It is +# unconventional and it is the only shape that keeps master clean, keeps the approval gate, and +# gives one explicit human action per step. +# +# The gate is the `testnet-upgrades` environment: every run pauses until a required reviewer +# approves it, whoever added the label. The label is the request; the approval is the decision. +# Labels are also how retries work: the job removes its label when it finishes, so re-adding it +# re-runs the step. Re-running one that already landed is harmless: a single-proxy upgrade swaps +# in the same code again, and the other steps skip what is already on chain. +# +# Key handling. Every step signs with `DOTNS_ADMIN_KEY` from the `testnet-upgrades` +# environment, the owner of every proxy and of the protocol registry. Runner logs on this public +# repository are world-readable, so nothing here may ever echo key material; the password below +# only encrypts a keystore that lives for one run. + +on: + pull_request: + types: [labeled] + branches: [master] + +permissions: + contents: read + pull-requests: write + +# One broadcast at a time, queued not cancelled: two runs would race nonces on the same chain, +# and a queued step must never be dropped just because someone labelled twice. +concurrency: + group: testnet-upgrades-broadcast + cancel-in-progress: false + +jobs: + broadcast: + name: ${{ github.event.label.name }} + # Same-repo head only. A fork's PR gets no secrets anyway, but the guard makes the intent + # readable instead of implicit. + if: >- + startsWith(github.event.label.name, 'run:') + && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + environment: testnet-upgrades + steps: + - name: Resolve and validate the requested step + id: step + env: + LABEL: ${{ github.event.label.name }} + run: | + set -euo pipefail + SCRIPT="${LABEL#run:}" + # Each step names the contracts it upgrades, whose snapshots are checked against the + # chain right before it broadcasts. Steps that upgrade no proxy name none. + case "$SCRIPT" in + UpgradeRegistry) SUBJECTS="DotnsRegistry" ;; + UpgradePopRules) SUBJECTS="PopRules" ;; + UpgradePopResolverAndController) SUBJECTS="DotnsPopResolver DotnsPopController" ;; + RedeployPopLens|DeclareRelease) SUBJECTS="" ;; + *) + echo "::error::'$SCRIPT' is not a runbook step; see docs/PASEO-V100-RUNBOOK.md" + exit 1 + ;; + esac + echo "script=$SCRIPT" >> "$GITHUB_OUTPUT" + echo "subjects=$SUBJECTS" >> "$GITHUB_OUTPUT" + + # The head commit, not the merge ref. A pull_request checkout defaults to the PR merged + # into its base, and master merged into this branch is not the code that was reviewed or + # fork-tested. Pinning the SHA also means the approver knows exactly what will run: a push + # to the branch after the label does not move an approved run. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + submodules: recursive + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - run: bun install + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + # The same adapter the deploy pipeline broadcasts through, pointed at the same node. The + # hosted gateway has only ever been exercised read-only here, and a broadcast is the wrong + # moment to learn whether it accepts transactions. + - name: Start the ETH-RPC adapter + run: | + docker compose up --build -d eth-rpc + scripts/shell/wait-for-eth-rpc.sh http://127.0.0.1:8545 + + # Scoped to the contracts this step upgrades. Every proxy an earlier step upgraded has + # moved past its snapshot by design; the ones this step is about to swap must still match, + # and a mismatch here means the code changed underneath the campaign. + - name: Verify snapshots against the deployed bytecode + if: steps.step.outputs.subjects != '' + env: + RPC_URL: http://127.0.0.1:8545 + SNAPSHOT_SUBJECTS: ${{ steps.step.outputs.subjects }} + run: scripts/shell/verify-snapshots.sh + + - name: Broadcast ${{ steps.step.outputs.script }} + env: + SCRIPT: ${{ steps.step.outputs.script }} + RPC_URL: http://127.0.0.1:8545 + # `_account.sh` imports this into a run-local keystore when none exists. + PRIVATE_KEY: ${{ secrets.DOTNS_ADMIN_KEY }} + # Encrypts a keystore that exists for the lifetime of this runner. Masked because no + # log line should print even throwaway credentials in cleartext. + ACCOUNT_PASSWORD: testnet-upgrades-run-${{ github.run_id }} + # Step parameter, configured as an environment variable on `testnet-upgrades`: the + # release tag `RedeployPopLens` scopes its salt to and `DeclareRelease` declares. + DOTNS_RELEASE_TAG: ${{ vars.DOTNS_RELEASE_TAG }} + run: | + set -euo pipefail + echo "::add-mask::$ACCOUNT_PASSWORD" + ./scripts/deploy/upgrade.sh + + # The broadcast record and the manifest are the two things a step changes on disk. The + # manifest cannot be pushed back from here: the branch requires signed commits, which a + # runner cannot produce, so the manifest `RedeployPopLens` writes is committed by the + # operator from the artifact. Everything else leaves the workspace unchanged. + - name: Keep the broadcast record + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.step.outputs.script }}-${{ github.run_id }} + path: | + broadcast/ + deployments/ + if-no-files-found: ignore + retention-days: 90 + + - name: Report and clear the label + if: always() + env: + GH_TOKEN: ${{ github.token }} + RESULT: ${{ job.status }} + SCRIPT: ${{ steps.step.outputs.script }} + PR: ${{ github.event.pull_request.number }} + LABEL: ${{ github.event.label.name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" \ + --body "**${SCRIPT}**: ${RESULT}. [Run](${RUN_URL}). Broadcast record and manifest attached as artifacts. Re-add \`${LABEL}\` to retry." + gh pr edit "$PR" --repo "$GITHUB_REPOSITORY" --remove-label "$LABEL" diff --git a/.github/workflows/push_checking.yml b/.github/workflows/push_checking.yml index f65fe10be..bbcdeac81 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -12,10 +12,16 @@ name: Run Solidity Tests # inside a `pull_request_target` job would let a malicious PR exfiltrate # repo secrets. Re-review carefully if this trigger surface widens. on: + # `dev/**` covers the long-lived upgrade branches, which are never merged to + # master and therefore never get tested by the master triggers alone. Without + # them a PR into an upgrade branch runs lint and nothing else, which is how a + # storage snapshot that no longer matched the live chain reached review. + # Matched by prefix, not by name, so the next such branch is covered the day + # it is created instead of the day someone remembers this file. pull_request: - branches: [master] + branches: [master, "dev/**"] push: - branches: [master] + branches: [master, "dev/**"] paths: ["contracts/**", "test/**", "**.sol"] pull_request_target: types: [closed] @@ -107,15 +113,6 @@ jobs: restore-keys: | foundry-build-${{ runner.os }}- - # Only the unit-fuzz job needs the fork adapter, and only when fork tests - # actually exist in the tree. Fork tests are PR-scoped, so `test/fork/` is - # empty on master and `hashFiles` returns '' (step skipped, no docker build). - - name: Start revive-eth-rpc (paseo_local fork target) - if: matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != '' - run: | - docker compose up -d --build - bash scripts/shell/wait-for-eth-rpc.sh - - name: Run tests id: run env: @@ -166,20 +163,6 @@ jobs: exit 1 fi - # Fork tests run against the revive-eth-rpc adapter started above, only - # when present. The preceding `forge build` already produced full build-info - # for the OZ upgrade validator, so this reuses it. - - name: Run fork tests - if: matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != '' - env: - FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" - FOUNDRY_PROFILE: "ci" - run: forge test -vv --match-path 'test/fork/**' - - - name: Tear down revive-eth-rpc - if: always() && matrix.kind.id == 'unit-fuzz' && hashFiles('test/fork/**') != '' - run: docker compose down --volumes --remove-orphans - - name: Stage matrix-kind output for the aggregator if: always() run: | @@ -215,9 +198,122 @@ jobs: if: always() run: rm -rf cache/fuzz cache/invariant cache/test-failures + # Fork tests validate the PR-scoped upgrade scripts against live Paseo Asset Hub + # state through the ETH-RPC adapter. They exist only during an upgrade PR, so a + # cheap detect job decides whether the heavy fork runner is provisioned at all: + # nothing sets up on a PR without `test/fork/**`. + detect-fork: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + outputs: + has_fork: ${{ steps.detect.outputs.has_fork }} + steps: + - uses: actions/checkout@v4 + - id: detect + run: | + if ls test/fork/*.t.sol > /dev/null 2>&1; then + echo "has_fork=true" >> "$GITHUB_OUTPUT" + else + echo "has_fork=false" >> "$GITHUB_OUTPUT" + fi + + fork: + name: Upgrade Fork Tests + needs: detect-fork + if: github.event.action != 'closed' && needs.detect-fork.outputs.has_fork == 'true' + # The fork job builds the revive ETH-RPC adapter image and runs the upgrade + # suite in a job of its own, separate from the matrix test runners. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: ./.github/actions/setup-foundry + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.2.6" + no-cache: true + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/cache@v4 + with: + path: node_modules + key: bun-${{ hashFiles('bun.lock') }} + restore-keys: bun- + + - run: bun install + + # Reuse the repository's docker compose eth-rpc service as the paseo_local + # fork target. + - name: Start revive-eth-rpc (paseo_local fork target) + run: | + docker compose up -d --build + bash scripts/shell/wait-for-eth-rpc.sh + + - name: Run fork tests + id: fork + env: + FOUNDRY_DISABLE_NIGHTLY_WARNING: "1" + FOUNDRY_PROFILE: "ci" + run: | + set +e + # A clean build-info keeps the OZ upgrade validator (vm.ffi) from reading + # a partial JSON on a warm self-hosted runner. + rm -rf out/build-info + # The layout diff compares whatever pair it is handed, so it stays green when a + # snapshot has drifted away from the implementation actually deployed, and a + # calldata-only change leaves no trace in a layout at all. This is the only check + # that catches that, and it belongs here as well as in `fork-tests.sh`: CI runs + # `forge test` directly, so a local-only guard protects nobody reviewing a PR. + scripts/shell/verify-snapshots.sh 2>&1 | tee -a fork.log + SNAPSHOTS=${PIPESTATUS[0]} + if [ "$SNAPSHOTS" -ne 0 ]; then + echo "result=Failed (snapshots)" >> "$GITHUB_OUTPUT" + exit 1 + fi + forge test -vv --match-path 'test/fork/**' 2>&1 | tee -a fork.log + FORK=${PIPESTATUS[0]} + if [ "$FORK" -eq 0 ]; then + echo "result=Passed" >> "$GITHUB_OUTPUT" + else + echo "result=Failed" >> "$GITHUB_OUTPUT" + fi + exit "$FORK" + + - name: Tear down revive-eth-rpc + if: always() + run: docker compose down --volumes --remove-orphans + + # Report the outcome as its own CI Summary row through a shard artifact the + # report job renders. `always()` so a failed run still uploads the Failed row + # and the fork log for the reviewer. + - name: Stage upgrade-fork output for the aggregator + if: always() + run: | + mkdir -p forkshard + printf 'Upgrade Fork Tests|%s|false\n' \ + "${{ steps.fork.outputs.result || 'Failed' }}" > forkshard/result.txt + [ -f fork.log ] && cp fork.log forkshard/fork.log || true + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-shard-fork + path: forkshard + retention-days: 7 + report: name: Report Test Results - needs: test + needs: [test, fork] # A pull request from a fork gets a read-only token, so publishing the reports and the comment # would fail. if: >- @@ -324,7 +420,7 @@ jobs: } } - const order = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Gas Report', 'Coverage', 'Documentation', 'Format & Lint', 'File Validation', 'Deploy Contracts', 'PR Title', 'Labels']; + const order = ['4naly3er Analysis', 'Slither Analysis', 'Contract Tests (Unit + Fuzz)', 'Contract Tests (Invariant)', 'Upgrade Fork Tests', 'Gas Report', 'Coverage', 'Documentation', 'Format & Lint', 'File Validation', 'Deploy Contracts', 'PR Title', 'Labels']; const sortedKeys = Object.keys(rows).sort((a, b) => (order.indexOf(a) === -1 ? 999 : order.indexOf(a)) - (order.indexOf(b) === -1 ? 999 : order.indexOf(b))); let table = `| Check | Result |\n|:------|:-------|\n`; for (const key of sortedKeys) table += `| ${key} | ${rows[key]} |\n`; diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml index 340950a5c..49d6eca99 100644 --- a/.github/workflows/release-metadata.yml +++ b/.github/workflows/release-metadata.yml @@ -60,6 +60,17 @@ jobs: IS_DEPLOY_RECORD: ${{ contains(github.event.pull_request.labels.*.name, 'deployment-record') }} run: | changed=$(git diff --name-only "$BASE_SHA"...HEAD -- 'deployments/*/*.json') + # A manifest identical to master's was carried in by merging master into a long-lived + # branch, which records nothing new. On a pull request into master it cannot match. + edited="" + for f in $changed; do + if git diff --quiet origin/master HEAD -- "$f" 2>/dev/null; then + echo "Carried from master unchanged: $f" + else + edited="$edited $f" + fi + done + changed="${edited# }" if [ -z "$changed" ]; then echo "No live network manifest was edited." exit 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aafe7900..f1a3b3060 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,6 +249,8 @@ git config core.hooksPath .githooks The conventions below apply specifically to PRs that upgrade an already-deployed proxy. They are scoped to the lifetime of the PR and must be removed before merge; the cleanup checklist at the end of this section is the gate reviewers enforce. +**Long-lived upgrade branches are the exception, and the rest of this section reads differently on one.** A branch under `dev/` holds the tooling for a network that is upgraded in place and is never merged to `master`: `master` flows into it, never back. The reason the artefacts have to be deleted is that they must not reach `master`, and on a branch that never merges they cannot. Deleting them there would throw away the only record of what was deployed, and the starting point for the next round, in exchange for nothing. So on such a branch the snapshots, upgrade scripts and fork tests stay, and the cleanup checklist below applies to the ordinary case: an upgrade PR that is going to `master`. + ### Storage-collision checks **Storage-layout safety is non-negotiable on every deploy and upgrade path.** The OpenZeppelin validator runs end-to-end on every proxy: on upgrades it diffs the new implementation's storage layout against a pinned `Old.sol` reference snapshot and fails the build if a slot moves, shrinks, or changes type; on fresh deploys it catches unsafe-upgrade-incompatible patterns (constructors, state-variable assignments and immutables in the implementation, `selfdestruct`, raw `delegatecall`, external library linking, missing initialisers, and so on) that would only surface as a bug the first time a future upgrade is attempted. **No deploy or upgrade script in this repository passes `unsafeSkipAllChecks` or any `unsafeAllow` override, and adding one is not on the table. If validation fails, fix the contract, not the script.** @@ -261,16 +263,52 @@ The `Old.sol` convention has a fixed shape. For a contract `Foo.sol` declaring ` **`Old.sol` snapshots are PR-scoped and must never land on `master`.** They exist only for the upgrade PR that introduces them, so CI and local `forge build` can diff the new layout against the pre-upgrade layout. **Before the PR merges, every `Old.sol` (and every matching `I*Old.sol`) must be deleted, along with the `referenceContract` wiring in the upgrade script.** Once the upgrade is live, the "old" layout is the on-chain deployment, not a file in the repository; keeping the snapshot around after merge would create a phantom contract that future diffs would treat as real code. Reviewers should refuse any PR that ships `Old.sol` files to `master`. +**A snapshot is of the code that is deployed, which is not always the previous release.** A proxy upgraded in place since its last release runs code no tag describes, so a snapshot taken from the tag is a snapshot of something that has not executed on that network for months. Nothing in the build can notice: the layout diff compares whatever pair it is given and reports honestly on the wrong one, and a change that lives in calldata leaves no trace in a layout at all. `scripts/shell/verify-snapshots.sh` is what closes this. It builds every snapshot and compares runtime bytecode against the implementation behind the proxy, masking only `UUPSUpgradeable.__self` and the trailing metadata, and `fork-tests.sh` runs it before the suite. Treat a snapshot that has not been through it as unverified, whatever the layout diff says. + +Where `master` has moved on since the deployed build, the snapshot set is larger than the contracts being upgraded. A snapshot that imports the current tree stops reproducing the deployed bytecode, and one that imports a snapshot hands a renamed type to a signature expecting the current one. The set has to be closed over both: everything reachable that changed, plus everything that reaches one of those. Unchanged interfaces and libraries stay shared and unrenamed. + +### The upgrade script + +Each upgraded proxy has one `Upgrade.s.sol` under `scripts/deploy/`, paired with its fork test. The script resolves the target proxy from the on-disk manifest, runs the layout diff against the `Old.sol` snapshot, and swaps the implementation through `Upgrades.upgradeProxy`. A beacon-backed store rotates its shared beacon through the factory's upgrade entrypoint after `Upgrades.validateUpgrade`, rather than a per-proxy call. The `referenceContract` is always supplied, so the layout diff is mandatory and fails closed; there is no environment switch that turns it off. + +The script asserts the broadcaster owns the proxy, or for a beacon the factory that owns it, before the swap, so a wrong signer fails fast with a clear message rather than reverting inside the upgrade call. It passes no initialiser data unless the new implementation adds storage that needs seeding. + ### Fork tests Fork tests are upgrade-PR scoped. They live in `test/fork/` for the duration of an upgrade PR, paired 1:1 with the upgrade script under `scripts/deploy/`. They run against a local Paseo Asset Hub fork via the ETH-RPC adapter described in the README's deployment note, and they are deleted alongside the upgrade script and the matching `Old.sol` snapshots before merge. Between upgrade PRs the directory is empty. +Each fork test forks live Asset Hub state, seeds or reads real on-chain state through the deployed implementation, runs the upgrade script, and asserts that state and every P0 path survive on the new implementation. Assertions exercise the real flows rather than bare mints, so a layout regression in a live slot fails the test. The `Old.sol` snapshot reproduces the layout of the implementation currently deployed on-chain, and the fork test is what confirms it: a snapshot that diverged from the live implementation makes the preserved-state assertions fail. + +CI wires this in automatically, so an upgrade PR adds fork tests without touching any workflow. The `push_checking` workflow detects `test/fork/**`: when fork tests are present it brings up the ETH-RPC adapter and runs them on a dedicated job that reports an `Upgrade Fork Tests` row in the CI summary; when the directory is empty that job is skipped and no adapter starts. Locally, run the same suite with `bun run test:fork`. + While a fork test is in flight, skip it with: ```bash forge test --no-match-path 'test/fork/**' ``` +### Broadcasting the upgrade + +Broadcast one upgrade at a time with `scripts/deploy/upgrade.sh`, which is permanent tooling and stays on `master`: + +```bash +SCRIPT=UpgradeRegistrar ACCOUNT_NAME= RPC_URL= ./scripts/deploy/upgrade.sh +``` + +It resolves the deployer account and reuses the shared forge flags (`--legacy`, `--slow`, and the gas limit matching the block gas limit), so an upgrade broadcast cannot drift from the deploy pipeline. The simulation is never skipped. + +### Limits the simulator cannot see + +pallet-revive meters transactions in more dimensions than gas, and neither forge's simulation nor a fork test models any of them: both run on a plain EVM where the only budget is gas. A transaction can therefore simulate green and still be unbroadcastable, rejected at gas estimation at every gas limit, with the weight exhaustion surfacing as an empty inner revert (OpenZeppelin call helpers turn it into `FailedCall`). The v0.8.0 store migration hit exactly this: one transaction importing 63 store bindings exceeded a block's weight, dying around the 44th. + +The rule that follows: **a broadcast transaction must never do work proportional to unbounded live state.** Any script leg that loops over on-chain collections (stores, holders, keys) is written paged from the start, one bounded page per transaction, idempotent so replaying a page is a no-op and an interrupted run is finished by running the pages again. Size pages well under measured capacity, and make the page size an environment override so a moved ceiling is an operator setting, not a code change. + +Operational pitfalls of the local ETH-RPC adapter, each learned the hard way: + +- forge pins a fork by block hash, so the adapter's `--eth-pruning` depth must exceed the longest run; at the default of 1 the second read fails with `Ethereum block not found`. +- Readiness is not "the port answers": wait until the adapter serves a full latest block body and the block number advances, or the first script call races the warm-up. +- RPC reads flake under load; wrap chain reads in shell tooling with a small retry and backoff. Never retry a broadcast itself. + ### Cleanup checklist before merging an upgrade PR 1. Delete the upgrade script under `scripts/deploy/`. diff --git a/DEPLOYMENTS.md b/DEPLOYMENTS.md index 630e0e9f9..67e0519c0 100644 --- a/DEPLOYMENTS.md +++ b/DEPLOYMENTS.md @@ -488,4 +488,54 @@ Every network deployed through the shared CREATE3 factory lands on the same addr Each release also publishes the same addresses as `deployments.json`, attached to the release and at the root of `dotns-abis-.zip`, for consumers outside this repository. See [`RELEASE_ARTIFACTS.md`](./RELEASE_ARTIFACTS.md). +### Paseo Asset Hub Next, and why its manifest folder is ambiguous + +`deployments/paseo-assethub/420420417.json` is Paseo Asset Hub Next, reached at +`https://eth-rpc-paseo-next.polkadot.io`. Chain id 420420417 is shared with other Paseo-style +environments, including the public Polkadot Hub TestNet gateway, which answers on that id and has +no code at any of these addresses. Point the fork adapter at the wrong one and every address +resolves and every call reverts for reasons that look like anything but the real cause. + +The public gateway also answers `eth_getLogs` with an empty result for every range, not an error, +so anything derived from a log replay against it looks like it worked and is empty. Nothing in +this repository depends on that: the store migration reads its holders from the factory's own +`getLabelStores` and each store's `owner`. Worth knowing before reaching for logs to answer a +question about this network. Use an archive node, or Blockscout at +`https://blockscout-paseo-next.polkadot.io`. + +### Broadcast order + +The in-place upgrade to v1.0.0 has a fixed order: the PoP resolver and controller change together +because the new controller writes links through a resolver function the old resolver lacks, and +the old controller through one the new resolver drops; the lens goes after them +because it reads the resolver's new getters, and the declaration goes last because it is a claim +about the whole deployment. [`docs/PASEO-V100-RUNBOOK.md`](./docs/PASEO-V100-RUNBOOK.md) is the +step list, with what to check after each one. + +### The deployed code is not always the code in a release + +This network's proxies are upgraded in place from the `dev/testnet-upgrades` branch, which is +never merged to `master`, so no release tag describes what they run. Two consequences that look +like faults and are not: + +- `verify --tag` reports the `registrarController` key as drift, permanently. The deployed + implementation carries a retained storage slot that keeps `protocolRegistry` where the live + proxy has it; an implementation built from the tag reads that field as the zero address and + bricks the contract, so the branch build is the only deployable one. Every other key verifies. + A second drifting key is a real finding. +- `protocolVersion()` names the release the deployment tracks, and is accurate for every contract + but that one, which runs a superset differing only in storage-slot constants. + +Before broadcasting, re-run `scripts/shell/verify-snapshots.sh` against the network. It compares +every snapshot with the implementation actually deployed, which is the only check that catches a +snapshot describing code that stopped running months ago. + +### Lite usernames issued before the numeric namespace + +Lite usernames issued before that upgrade were recorded as atomic labels under the top level. The +current code addresses a lite name at the container-then-stem node instead, so a pre-upgrade lite +name is not read by the new path, and its subname node is free to be issued afresh. That overwrite +is accepted: the numeric namespace is the intended shape. Nothing reads the old records, and they +are not migrated. + Prefer reading an address from the protocol registry at runtime. Every consumer contract exposes `protocolRegistry`, and the registry resolves each well-known key in `DotnsConstants`, so one known address is enough to reach the rest and the chain stays the authority. diff --git a/contracts/pop/IPopRulesOld.sol b/contracts/pop/IPopRulesOld.sol new file mode 100644 index 000000000..5fb8f5c0c --- /dev/null +++ b/contracts/pop/IPopRulesOld.sol @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title Proof of Personhood Rules for Dotns +/// @notice Proof of personhood interface defining Dotns price calculation, PoP-tier requirements, +/// and base-name reservation rules. +/// @dev Classifies labels into the PoP tier required for registration and exposes reservation +/// metadata. Every label is measured as written, except a gateway lite label, whose +/// separator and allocated digits are removed first. Base length <= 5 is reserved for +/// governance; 6-8 requires PopFull; >= 9 is open to every caller as NoStatus. PopLite is +/// the separated form alone, so digits in an ordinary label carry no personhood meaning. +/// Reservations are keyed by that same stem, so `joseph` and `joseph.42` share a slot while +/// `joseph42` is an unrelated name. +/// +/// Amounts come from the cost model registered under `DotnsConstants.COST_MODEL`, which owns +/// the curve; only the base length crosses that seam. Every caller pays the same amount for a +/// given length; personhood only unlocks the premium band. +/// @custom:security-contact admin@parity.io +interface IPopRulesOld { + /// @notice Proof-of-Personhood eligibility tier. + /// @dev `NoStatus` is the default for unverified users; `PopLite` and `PopFull` are the two + /// personhood tiers; `Reserved` covers both governance-held names and base stems held by + /// another user through the reservation table. + enum PopStatus { + NoStatus, + PopLite, + PopFull, + Reserved + } + + /// @notice Emitted when a base name receives a reservation. + /// @param baseName The digit-stripped label receiving the reservation. + /// @param owner Address obtaining the reservation right. + /// @param expires UNIX timestamp when the reservation expires. + event BaseNameReserved(string indexed baseName, address indexed owner, uint64 expires); + + /// @notice Emitted when the public market for names shorter than nine characters is opened or + /// closed. + /// @dev Set by the Root-gated @custom:function setShortNamesEnabled. + /// @param enabled Whether names shorter than nine characters may now be bought. + event ShortNamesEnabledUpdated(bool enabled); + + /// @notice Thrown when a name violates PoP-tier or reservation requirements. + /// @param reason Human-readable explanation of the failure condition. + error PopError(string reason); + + /// @notice Thrown when @custom:function setShortNamesEnabled is called without a substrate + /// Root origin. + /// @dev Carries no caller parameter: a Root origin has no account to report, and reading + /// `msg.sender` under one traps. + error NotRoot(); + + /// @notice Thrown when a caller is not an authorised controller on the registrar. + error NotRegistry(); + + /// @notice Thrown when registering a name whose base stem is held as a live reservation by + /// another user. + /// @param label Caller-supplied label whose stem is reserved. + error NameReserved(string label); + + /// @notice Thrown when registering a label that classifies as governance-reserved at the + /// protocol level. + /// @dev Distinct from @custom:reverts NameReserved so off-chain consumers can tell "wait for + /// the holder to relinquish" apart from "this label is permanently held by governance". + /// @param label Caller-supplied label that classifies as governance-reserved. + error GovernanceReserved(string label); + + /// @notice Thrown on the cross-payer path when the owner's recorded PoP tier does not meet the + /// label's required tier. The direct path's `priceWithCheck` covers this same condition via its + /// own revert. + /// @param label Label whose tier requirement was unmet. + /// @param userStatus Owner's recorded tier. + /// @param required Required tier for the label. + error OwnerStatusInsufficient(string label, PopStatus userStatus, PopStatus required); + + /// @notice Bundle returned from metadata-aware pricing queries. + /// @param price Registration cost from the current cost model for the label's base length. + /// @param status Required PoP tier for this name. + /// @param userStatus Current PoP status recorded for the querying user. + /// @param message Human-readable classification description. + struct PriceWithMeta { + uint256 price; + PopStatus status; + PopStatus userStatus; + string message; + } + + /// @notice Reservation metadata for a base name (digits removed). + /// @param owner Address holding exclusive claim rights during the reservation window. + /// @param expires UNIX timestamp when the reservation expires. + /// @param controller Address that wrote the reservation; the only address permitted to release + /// it before expiry. + struct Reservation { + address owner; + uint64 expires; + address controller; + } + + /// @notice Classifies a name into a required PoP tier per DotNS naming rules. + /// @dev Pure; inputs are the label bytes only. Callers use the returned tier to decide which + /// pricing and verification branch applies. A label that is neither a single lowercase + /// ASCII DNS label nor a lite label triggers @custom:reverts PopError; a trailing-digit + /// suffix of any length is accepted and classified by the length it leaves. + /// @param name The name label being evaluated. + /// @return requirement Required tier for registration. + /// @return message Explanation of the classification result. + function classifyName(string calldata name) + external + pure + returns (PopStatus requirement, string memory message); + + /// @notice Opens or closes the public market for names shorter than nine characters. + /// @dev Restricted to a substrate Root origin; any other caller triggers @custom:reverts + /// NotRoot. Short names are otherwise issued through the PoP gateway, so this flag is the + /// Root-only lever that additionally admits them on the public paid path. + /// While closed, which is the deploy default, @custom:function priceWithCheck and + /// @custom:function priceWithoutCheck trigger @custom:reverts PopError for a base length + /// below nine, so no public caller buys a short name. The gateway free grant and the + /// registrar's registerReserved path do not read this flag. Emits @custom:emits + /// ShortNamesEnabledUpdated. + /// @param enabled Whether names shorter than nine characters may be bought. + function setShortNamesEnabled(bool enabled) external; + + /// @notice Returns the personhood tier recorded for an account. + /// @dev Reads the account's dotns-scoped tier from the personhood precompile and maps it to a + /// `PopStatus`. This is the direct account-tier read; the same tier otherwise surfaces + /// only as the `userStatus` field of a pricing query. Never returns `Reserved`, so the + /// result is one of `NoStatus`, `PopLite`, or `PopFull`. + /// @param account Address whose tier is read. + /// @return tier The account's personhood tier. + function personhoodOf(address account) external view returns (PopStatus tier); + + /// @notice Creates or refreshes a reservation entry for a stem in the 6 to 8 band. + /// @dev Authorised-controller entry point: only a controller in the registrar's `controllers` + /// set may call this, otherwise @custom:reverts NotRegistry. The gateway queue writes + /// through @custom:function reserveBaseNameForPop and the public commit-reveal flow + /// reads the slot rather than writing one, so this is the entry point for a sibling + /// controller. Its length window bounds the stem and does not name a tier: PopLite is + /// decided by the separated label shape. The caller passes the already-stripped stem; a + /// non-canonical label, a trailing digit, or a length outside `[6, 8]` triggers + /// @custom:reverts PopError. Cross-user collision on a live slot triggers @custom:reverts + /// PopError so the caller cannot silently overwrite another user's reservation; same-user + /// refresh and writes into an empty or expired slot emit @custom:emits BaseNameReserved. + /// @param stem The base label with no trailing digits. + /// @param user The address receiving reservation rights. + function reserveBaseName(string calldata stem, address user) external; + + /// @notice Emitted when a base-name reservation is cleared. + /// @param baseName The base label whose reservation was released. + event BaseNameReleased(string indexed baseName); + + /// @notice Writes or refreshes a reservation for a bare base-name stem. + /// @dev Gateway-driven reservation path used by the PoP controller. Only a controller in the + /// registrar's `controllers` set may call this, otherwise @custom:reverts NotRegistry. + /// Does not apply the lite-format length window that @custom:function reserveBaseName + /// enforces, but does require the input to be canonical and stem-shaped (no trailing + /// digits); a non-canonical or non-stem label triggers @custom:reverts PopError. If the + /// slot is already live and held by a different user, @custom:reverts PopError so the + /// caller's local bookkeeping and PopRulesOld state stay in lockstep; if it is live for + /// the same user, expiry is refreshed to `block.timestamp + MAX_RESERVATION_TIME`. Emits + /// @custom:emits BaseNameReserved on every successful write. + /// @param stem The base label with no trailing digits. + /// @param user The address receiving reservation rights. + function reserveBaseNameForPop(string calldata stem, address user) external; + + /// @notice Clears a reservation for a base-name stem. + /// @dev Only a controller in the registrar's `controllers` set may call this, otherwise + /// @custom:reverts NotRegistry. Non-canonical or non-stem labels trigger + /// @custom:reverts PopError. Live reservations may only be cleared by the same controller + /// that wrote them; another authorised controller attempting to clear a live slot triggers + /// @custom:reverts PopError. Expired reservations may be cleared by any authorised + /// controller as garbage collection. Used by the PoP controller when a reservation is + /// claimed, relinquished, or a queue head promotion leaves the slot empty. Emits + /// @custom:emits BaseNameReleased once the slot is cleared. + /// @param stem The base label whose reservation should be cleared (no trailing digits). + function releaseBaseName(string calldata stem) external; + + /// @notice Clears a reservation when the slot owner matches `expectedOwner`, allowing any + /// registrar-authorised controller (not only the stamping one) to release the slot. + /// @dev Narrower than @custom:function releaseBaseName: callers must prove they know the + /// slot owner, so cross-controller release is gated on a positive match rather than on + /// caller identity. Intended for the public registrar controller's reclaim path, where + /// a prior occupant has handed the name back to escrow and the new registrant needs + /// the cross-flow guard cleared regardless of which controller originally stamped it. + /// Only a registrar-authorised controller may call this (@custom:reverts NotRegistry). + /// Non-canonical or non-stem labels trigger @custom:reverts PopError. A live reservation + /// whose owner does not match `expectedOwner` triggers @custom:reverts PopError; expired + /// reservations are cleared regardless. Emits @custom:emits BaseNameReleased. + /// @param stem The base label whose reservation should be cleared (no trailing digits). + /// @param expectedOwner The address the caller expects to be the current reservation owner. + function releaseReservationForReclaim(string calldata stem, address expectedOwner) external; + + /// @notice Retrieves reservation information for a base name. + /// @dev Raw accessor: returns the stored slot regardless of expiry. Use + /// @custom:function isBaseNameReserved + /// when live-window semantics are needed. Non-canonical labels trigger + /// @custom:reverts PopError. + /// @param baseName The base label without trailing digits. + /// @return owner The address assigned to the reservation. + /// @return expires UNIX timestamp when the reservation expires. + function getBaseNameReservation(string calldata baseName) + external + view + returns (address owner, uint64 expires); + + /// @notice Returns the reservation stem of a label: a lite label without its allocated + /// suffix, or any other label unchanged. + /// @dev Mirrors the normalisation applied before a reservation is written, so callers can + /// look up or release one by passing the full label. Only a lite label is shortened, + /// because only the gateway allocates the digits it carries: `joseph.42` yields + /// `joseph` while `joseph42` is an unrelated name and yields itself. Non-canonical + /// labels trigger @custom:reverts PopError. + /// @param name Full label, lite or otherwise. + /// @return stem The reservation stem of `name`. + function stripDigits(string calldata name) external pure returns (string memory stem); + + /// @notice Indicates whether a base name is currently reserved. + /// @dev Applies the live-window predicate to the stored slot so an expired reservation reads + /// as free. Non-canonical labels trigger @custom:reverts PopError. + /// @param baseName The base label without trailing digits. + /// @return reservedStatus True if a live reservation is active. + /// @return owner The reservation holder (zero when not reserved). + /// @return expires UNIX timestamp when the reservation expires. + function isBaseNameReserved(string calldata baseName) + external + view + 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 the scarcity + /// curve for the label's base length and is charged to every caller, verified or not; + /// personhood only unlocks the premium band. Non-canonical + /// labels, a base stem held live by another user, a governance-reserved label, or a + /// `userAddress` whose personhood tier does not meet the label's required tier each + /// trigger @custom:reverts PopError. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @return metadata Price with PoP requirements and classification. + function priceWithCheck( + string calldata name, + address userAddress + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Calculates price at a specific cost-model version with PoP classification and + /// reservation enforcement. + /// @dev The versioned counterpart of @custom:function priceWithCheck: identical classification, + /// tier gating, and reservation rules, but the amount comes from the model registered for + /// `pricingVersionValue` rather than the current one. The commit-reveal controller prices + /// a reveal at the version bound into its commitment, so a model change between commit and + /// reveal does not move the amount. @custom:reverts UnknownVersion when the version was + /// never registered. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @param pricingVersionValue Cost-model version to price against. + /// @return metadata Price with PoP requirements and classification. + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Calculates price with PoP classification and reservation metadata, without + /// reverting on conflicts. + /// @dev Non-reverting counterpart to `priceWithCheck`: surfaces the same fields, but reports + /// a `Reserved` status through `metadata` instead of reverting when the base stem is + /// held by another user. Used by front-ends that need to present a price and eligibility + /// preview without forcing a transaction attempt. Governance-reserved names are not + /// rejected here either; the caller decides what to do. Non-canonical labels still + /// trigger @custom:reverts PopError because the input is malformed rather than just + /// contested. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @return metadata Price with PoP requirements and classification. + function priceWithoutCheck( + string calldata name, + address userAddress + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Calculates price at a specific cost-model version with PoP classification and + /// reservation metadata, without reverting on conflicts. + /// @dev The versioned counterpart of @custom:function priceWithoutCheck: same non-reverting + /// preview behaviour, but the amount comes from the model registered for + /// `pricingVersionValue`. @custom:reverts UnknownVersion when the version was never + /// registered. + /// @param name Domain label. + /// @param userAddress Registering user for the given label. + /// @param pricingVersionValue Cost-model version to price against. + /// @return metadata Price with PoP requirements and classification. + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + returns (PriceWithMeta memory metadata); + + /// @notice Transfer-time floor: the greater of the recipient-reach component and the + /// sender-tier-downgrade component, each priced at the name's own length. + /// @dev Re-prices the name at its own length on every move: returns the name's curve price when + /// either (i) the recipient does not meet the label's required tier, or (ii) the + /// recipient's personhood tier is strictly below the sender's, and zero when neither + /// holds. Passing a name to a wallet that could never have registered it therefore costs + /// the name's own curve price. The two components overlap on pure + /// tier mismatches, so the function takes their maximum rather than their sum to avoid + /// double-charging. Consumed by @custom:function DotnsRegistrarOld.quoteTransferFee. + /// A label that is neither a single lowercase ASCII DNS label nor a lite label triggers + /// @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, + address to + ) + external + view + returns (uint256 floor); + + /// @notice Returns whether `name` is a base name under PoP rules. + /// @dev A base name has no trailing digits, so it is what a reservation may be keyed by. A + /// lite label always ends in two, so it is never a base name. Non-canonical labels + /// trigger @custom:reverts PopError. + /// @param name The label to check. + /// @return isBase True when the label has no trailing digits. + function isBaseName(string calldata name) external pure returns (bool isBase); + + /// @notice Calculates registration cost for a label. + /// @dev Prices the label by its base length through the cost model registered under + /// `DotnsConstants.COST_MODEL`. Ignores the caller's personhood status and reservation + /// state. A non-canonical label triggers @custom:reverts PopError. An ordinary label is + /// priced as written; only a lite label's allocated suffix is removed first. + /// @param name Domain label to price. + /// @return cost Registration cost in wei. + function price(string calldata name) external view returns (uint256 cost); + + /// @notice Returns the current cost-model version. + /// @dev The current version held by the registry under `DotnsConstants.COST_MODEL`. The + /// commit-reveal controller binds it into a commitment and prices the reveal at that + /// version, so a model change between commit and reveal leaves the committed amount + /// unchanged. @custom:reverts PopError when no registry is configured. + /// @return modelVersion Identifier of the current cost model and its parameters. + function pricingVersion() external view returns (uint256 modelVersion); +} diff --git a/contracts/pop/PopRulesOld.sol b/contracts/pop/PopRulesOld.sol new file mode 100644 index 000000000..65f3a1efc --- /dev/null +++ b/contracts/pop/PopRulesOld.sol @@ -0,0 +1,652 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {StringUtilsOld} from "../utils/StringUtilsOld.sol"; +import {SystemUtils} from "../utils/SystemUtils.sol"; +import {IPopRulesOld} from "./IPopRulesOld.sol"; +import {IDotnsCostModelRegistry} from "./IDotnsCostModelRegistry.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {IDotnsController} from "../registrars/IDotnsController.sol"; +import {DotnsRegistrarOld} from "../registrars/DotnsRegistrarOld.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {IPersonhood} from "../external/personhood/IPersonhood.sol"; + +/// @title PopRulesOld +/// @notice Implements DotNS classification, cost-model-driven pricing, and base-name reservations. +/// @dev Tiers are set by base length. Every label is measured as written, except a lite label, +/// whose separator and allocated digits are not part of the name the candidate chose, so +/// `joseph.42` measures six and `joseph42` measures eight. +/// Base lengths <= 5 are governance-reserved, base lengths 6-8 require PopFull, and base +/// lengths >= 9 are open to any caller as NoStatus. PopLite is the separated form alone: a +/// digit suffix on an ordinary label says nothing about personhood. +/// Every caller pays the same amount for a given base length. The amount comes from the cost +/// model registered under `DotnsConstants.COST_MODEL`, which owns the curve; this contract +/// passes it only the base length and keeps the classification, reservation, and tier rules. +/// Personhood only unlocks the premium band. Base lengths below nine are closed to the public +/// paid path until Root sets `shortNamesEnabled`; the gateway and registerReserved do +/// not consult it. +/// @custom:security-contact admin@parity.io +contract PopRulesOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IPopRulesOld +{ + using StringUtilsOld for *; + + /// @notice Active reservations keyed by stem. + mapping(string baseName => Reservation reservation) public reservations; + + /// @notice Maximum time a base name can be reserved. + uint256 public constant MAX_RESERVATION_TIME = 12 weeks; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Whether the public paid path may register names shorter than nine characters. + /// Closed by default; only governance opens it. + bool public shortNamesEnabled; + + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @notice Restricts function to any registry-authorised controller. + modifier onlyRegistry() { + _onlyRegistry(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the oracle (public entry point). + /// @dev Runs once behind the proxy; subsequent calls trigger @custom:reverts + /// InvalidInitialization via the `initializer` modifier. Amounts come from the cost model + /// registered under `DotnsConstants.COST_MODEL`, so no price is seeded here. + /// @param initialOwner Address that owns the contract once initialised. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(address initialOwner, IDotnsProtocolRegistry registry) public initializer { + __Ownable_init(initialOwner); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IPopRulesOld + function setShortNamesEnabled(bool enabled) external override { + // Opening the short-name band to the public path is a governance decision, so it is gated + // on a substrate Root origin rather than the owner. `msg.sender` is deliberately not read: + // a Root origin has no account behind it, so reading it would trap. + require(SystemUtils.originIsRoot(), NotRoot()); + shortNamesEnabled = enabled; + emit ShortNamesEnabledUpdated(enabled); + } + + /// @inheritdoc IPopRulesOld + function classifyName(string calldata name) + external + pure + override + returns (PopStatus requirement, string memory message) + { + _requireLabel(name); + (requirement, message,) = _classifyValidatedName(name); + } + + /// @inheritdoc IPopRulesOld + function reserveBaseName( + string calldata stem, + address userAddress + ) + external + override + onlyRegistry + { + _requireStem(stem); + uint256 stemLength = bytes(stem).length; + require( + stemLength >= 6 && stemLength <= 8 && _countTrailingDigits(stem) == 0, + PopError("Reservation stem must be 6-8 chars with no trailing digits") + ); + _writeReservation(stem, userAddress); + } + + /// @inheritdoc IPopRulesOld + function isBaseName(string calldata baseName) external pure override returns (bool isBase) { + _requireLabel(baseName); + uint256 digits = _countTrailingDigits(baseName); + return digits == 0; + } + + /// @inheritdoc IPopRulesOld + function getBaseNameReservation(string calldata baseName) + external + view + override + returns (address reservationOwner, uint64 expiryTimestamp) + { + _requireStem(baseName); + Reservation memory reserved = reservations[baseName]; + return (reserved.owner, reserved.expires); + } + + /// @inheritdoc IPopRulesOld + function isBaseNameReserved(string calldata baseName) + external + view + override + returns (bool isReserved, address reservationOwner, uint64 expiryTimestamp) + { + _requireStem(baseName); + Reservation memory reservation = reservations[baseName]; + return (_isLive(reservation), reservation.owner, reservation.expires); + } + + /// @inheritdoc IPopRulesOld + function priceWithCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRulesOld + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, true, pricingVersionValue); + } + + /// @inheritdoc IPopRulesOld + function priceWithoutCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRulesOld + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, true, pricingVersionValue); + } + + /// @notice Shared body for the reservation-enforcing pricing reads. + /// @dev `atVersion` selects the amount source: the current model when false, the model for + /// `pricingVersionValue` when true. Classification, tier gating, and reservation rules are + /// the same on both paths, so they live here once. + function _priceWithCheck( + string calldata name, + address userAddress, + bool atVersion, + uint256 pricingVersionValue + ) + internal + view + returns (PriceWithMeta memory metadata) + { + _requireLabel(name); + _enforceReservationRules(name, userAddress); + + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); + PopStatus userStatus = _personhoodTier(userAddress); + + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); + metadata.status = requiredStatus; + metadata.userStatus = userStatus; + metadata.message = classification; + + require(requiredStatus != PopStatus.Reserved, PopError(classification)); + require(_meetsReach(requiredStatus, userStatus), PopError(classification)); + + return metadata; + } + + /// @notice Shared body for the non-reverting pricing reads. + /// @dev Mirror of @custom:function _priceWithCheck for the front-end preview path: reports a + /// contested reservation through `metadata` rather than reverting. `atVersion` selects the + /// amount source in the same way. + function _priceWithoutCheck( + string calldata name, + address userAddress, + bool atVersion, + uint256 pricingVersionValue + ) + internal + view + returns (PriceWithMeta memory metadata) + { + _requireLabel(name); + + (PopStatus requiredStatus, string memory classification, uint256 baseLength) = + _classifyValidatedName(name); + _requireShortNamesOpen(baseLength); + PopStatus userStatus = _personhoodTier(userAddress); + + metadata.price = atVersion + ? _priceValidatedNameAtVersion(pricingVersionValue, baseLength) + : _priceValidatedName(baseLength); + metadata.status = requiredStatus; + metadata.userStatus = userStatus; + metadata.message = classification; + + string memory baseName = _stripDigits(name); + Reservation memory reservation = reservations[baseName]; + + if (_isLive(reservation) && reservation.owner != userAddress) { + metadata.message = "Base name reserved for original Lite registrant"; + metadata.status = IPopRulesOld.PopStatus.Reserved; + } + + return metadata; + } + + /// @inheritdoc IPopRulesOld + function price(string calldata name) external view override returns (uint256) { + _requireLabel(name); + return _priceValidatedName(_validatedBaseLength(name)); + } + + /// @inheritdoc IPopRulesOld + function pricingVersion() external view override returns (uint256 modelVersion) { + return _costModelRegistry().currentVersion(); + } + + /// @inheritdoc IPopRulesOld + function transferFloor( + string calldata name, + address from, + address to + ) + external + view + override + returns (uint256 floor) + { + _requireLabel(name); + if (from == to) return 0; + (PopStatus required,, uint256 baseLength) = _classifyValidatedName(name); + uint256 ownPrice = _priceValidatedName(baseLength); + + PopStatus toTier = _personhoodTier(to); + 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 ? ownPrice : 0; + + return reachComponent > downgradeComponent ? reachComponent : downgradeComponent; + } + + /// @inheritdoc IPopRulesOld + function personhoodOf(address account) external view override returns (PopStatus tier) { + return _personhoodTier(account); + } + + /// @notice Reads `account`'s dotns-scoped personhood tier from the alias-accounts + /// precompile and translates it into a `PopStatus`. + /// @dev Single source of truth so callers cannot read the precompile directly and + /// drift on the status mapping. Tiers are defined incrementally on the + /// precompile side: 0=None, 1=Lite, 2=Full. Anything outside that range + /// collapses to `NoStatus` so a future tier addition fails closed instead of + /// silently being treated as a higher level than it actually is. + function _personhoodTier(address account) private view returns (PopStatus) { + IPersonhood.PersonhoodInfo memory info = IPersonhood(DotnsConstants.PERSONHOOD) + .personhoodStatus(account, DotnsConstants.PERSONHOOD_CONTEXT); + if (info.status == 2) return PopStatus.PopFull; + if (info.status == 1) return PopStatus.PopLite; + return PopStatus.NoStatus; + } + + /// @notice Single canonical "is `userStatus` at reach for `required`?" predicate. + /// @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 + /// the caller charges the friction fee, providing defence-in-depth if a Reserved label ever + /// enters circulation. + function _meetsReach(PopStatus required, PopStatus userStatus) private pure returns (bool) { + return userStatus >= required; + } + + /// @notice Amount for a base length at the current cost-model version. + /// @dev The cost-model registry owns the curve; this contract passes it only the base length. + /// The call is a view because it runs on the ERC721 transfer floor read through + /// @custom:function transferFloor. + function _priceValidatedName(uint256 baseLength) internal view returns (uint256 priceValue) { + return _costModelRegistry().priceForBaseLength(baseLength); + } + + /// @notice Amount for a base length at a specific cost-model version. + /// @dev Prices an in-flight registration at the version it committed to, so a model change + /// between commit and reveal does not move its cost. @custom:reverts UnknownVersion (from + /// the registry) when the version was never registered. + function _priceValidatedNameAtVersion( + uint256 pricingVersionValue, + uint256 baseLength + ) + internal + view + returns (uint256 priceValue) + { + return _costModelRegistry().priceForBaseLengthAtVersion(pricingVersionValue, baseLength); + } + + /// @notice Resolves the cost-model registry registered under `DotnsConstants.COST_MODEL`. + /// @dev @custom:reverts PopError when no registry is configured, so a pricing read fails closed + /// rather than resolving through the zero address. + function _costModelRegistry() private view returns (IDotnsCostModelRegistry registry) { + address configured = protocolRegistry.get(DotnsConstants.COST_MODEL); + require(configured != address(0), PopError("Cost model not configured")); + return IDotnsCostModelRegistry(configured); + } + + /// @notice Reverts a public paid registration of a base length below nine while the short-name + /// market is closed. + /// @dev The one gate both public price reads share. Base lengths of nine and above are always + /// open. @custom:reverts PopError when a base length below nine is priced while + /// `shortNamesEnabled` is false. The gateway and @custom:function registerReserved never + /// reach this, so neither is gated. + function _requireShortNamesOpen(uint256 baseLength) private view { + require(shortNamesEnabled || baseLength >= 9, PopError("Short names are not for sale")); + } + + /// @notice Index one past `name`'s stem: a lite label without its suffix, or the whole of + /// any other label. + /// @dev Only a lite label has a suffix to remove. The gateway allocates those two digits to + /// tell apart people who chose the same stem, so removing them recovers what the + /// candidate actually picked. No such allocation stands behind the digits in an + /// ordinary label, where they are part of the name: `web3` is a four-character word, + /// not `web` with a counter. + function _stemEnd(string calldata name) private pure returns (uint256 stemEnd) { + stemEnd = bytes(name).length; + if (!name.isLitePersonLabel()) return stemEnd; + return stemEnd - StringUtilsOld.LITE_SUFFIX_DIGITS - 1; + } + + /// @notice The base length that pricing and classification both use to place a name in its + /// band, which is the length of the name's stem. + /// @dev Every label is measured as written, except a lite label, whose allocated suffix is + /// not part of the name the candidate chose. So `web3` and `blink182` are measured + /// whole and no digit count is privileged or rejected. + function _validatedBaseLength(string calldata name) internal pure returns (uint256 baseLength) { + return _stemEnd(name); + } + + /// @notice Enforces base-name reservation rules. + /// @param name Domain label. + /// @param userAddress Registering user. + function _enforceReservationRules(string calldata name, address userAddress) internal view { + string memory baseName = _stripDigits(name); + Reservation memory reservation = reservations[baseName]; + + if (_isLive(reservation)) { + require( + reservation.owner == userAddress, + PopError("Base name reserved for original Lite registrant") + ); + } + } + + /// @notice Returns whether `reservation` is live at `block.timestamp`. + function _isLive(Reservation memory reservation) internal view returns (bool) { + return reservation.owner != address(0) && reservation.expires > block.timestamp; + } + + /// @notice Counts trailing digits in a string. + /// @param label String to analyse. + /// @return digitCount Number of trailing digits. + function _countTrailingDigits(string calldata label) + internal + pure + returns (uint256 digitCount) + { + bytes calldata bytesLabel = bytes(label); + for (uint256 i = bytesLabel.length; i > 0; i--) { + if (bytesLabel[i - 1] >= 0x30 && bytesLabel[i - 1] <= 0x39) { + digitCount++; + } else { + break; + } + } + } + + /// @notice Returns `name`'s stem: a lite label without its allocated suffix, or any other + /// label verbatim. + /// @dev The reservation key. Because only a lite label is shortened, `joseph.42` contends + /// with `joseph` while `joseph42` is an unrelated name and contends with nothing. + /// @param name Domain label. + function _stripDigits(string calldata name) internal pure returns (string memory baseName) { + bytes calldata bytesName = bytes(name); + uint256 endPosition = _stemEnd(name); + + // No suffix to strip: return the input verbatim and skip the manual copy. + if (endPosition == bytesName.length) return name; + + bytes memory output = new bytes(endPosition); + for (uint256 i = 0; i < endPosition; i++) { + output[i] = bytesName[i]; + } + + return string(output); + } + + function _classifyValidatedName(string calldata name) + internal + pure + returns (PopStatus requirement, string memory message, uint256 baseLength) + { + baseLength = _validatedBaseLength(name); + + if (baseLength <= 5) { + return (PopStatus.Reserved, "Reserved for Governance", baseLength); + } + + if (baseLength >= 6 && baseLength <= 8) { + // PopLite is the gateway's separated form. Digits in an ordinary label say nothing + // about personhood, so such a label sits in the band its length earns. + if (name.isLitePersonLabel()) { + return (PopStatus.PopLite, "Requires Lite personhood verification", baseLength); + } + return (PopStatus.PopFull, "Requires Full personhood verification", baseLength); + } + + // Base length >= 9 is open to any caller, and is reached by a lite label whose stem is + // nine or more through the gateway. + return (PopStatus.NoStatus, "Available to all", baseLength); + } + + /// @notice Requires `stem` to be a canonical DNS label, carrying no separator. + /// @dev Reservation keys are stems, so a separator here is a caller error rather than a lite + /// name. A digit suffix passes this check, because a DNS label admits digits; the entry + /// points that write a reservation reject one themselves. + /// @custom:function _requireLabel is the guard for full labels. + function _requireStem(string calldata stem) internal pure { + require(stem.isSingleLabel(), PopError("Name must be lowercase ASCII DNS label")); + } + + /// @notice Requires `name` to be a label DotNS can issue: a canonical DNS label, or a lite + /// label carrying its separator. + /// @dev The union is the full set of issuable labels, so a near miss such as `alice.4` or + /// `a.b.42` still reverts. @custom:function _requireStem is the stricter guard for + /// reservation keys, which never carry a separator. + function _requireLabel(string calldata name) internal pure { + require( + name.isSingleLabel() || name.isLitePersonLabel(), + PopError("Name must be a lowercase ASCII DNS label or a lite label") + ); + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override + returns (bool supported) + { + return interfaceId == type(IPopRulesOld).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /// @notice Returns the release this network declares it runs, read live from the protocol + /// registry so every DotNS contract reports one synchronised value. + /// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical + /// `version()` selector for ABI compatibility. It reports the network's declaration, + /// not this contract's build; per-contract identity is the codehash declared on the + /// registry. + /// @return versionString Declared release as bare semver, empty when never declared. + function version() external view virtual returns (string memory versionString) { + versionString = protocolRegistry.protocolVersion(); + } + + /// @notice Ensures the caller is any controller authorised on the registrar. + function _onlyRegistry() internal view { + DotnsRegistrarOld registrar = + DotnsRegistrarOld(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.controllers(IDotnsController(msg.sender)), NotRegistry()); + } + + /// @inheritdoc IPopRulesOld + function reserveBaseNameForPop( + string calldata stem, + address userAddress + ) + external + override + onlyRegistry + { + _requireStem(stem); + require( + _countTrailingDigits(stem) == 0, + PopError("Reservation stem must have no trailing digits") + ); + _writeReservation(stem, userAddress); + } + + /// @inheritdoc IPopRulesOld + function stripDigits(string calldata name) external pure override returns (string memory stem) { + _requireLabel(name); + return _stripDigits(name); + } + + /// @inheritdoc IPopRulesOld + function releaseBaseName(string calldata stem) external override onlyRegistry { + _requireStem(stem); + require( + _countTrailingDigits(stem) == 0, + PopError("Reservation stem must have no trailing digits") + ); + Reservation memory reservation = reservations[stem]; + // Live reservations can only be cleared by the controller that wrote + // them, so one registrar-authorised controller cannot wipe another's + // active slot. Expired reservations are dead weight and may be cleared + // by any authorised controller as garbage collection. + if (_isLive(reservation)) { + require( + msg.sender == reservation.controller, + PopError("Only reserving controller can release") + ); + } + delete reservations[stem]; + emit BaseNameReleased(stem); + } + + /// @inheritdoc IPopRulesOld + function releaseReservationForReclaim( + string calldata stem, + address expectedOwner + ) + external + override + onlyRegistry + { + _requireStem(stem); + require( + _countTrailingDigits(stem) == 0, + PopError("Reservation stem must have no trailing digits") + ); + Reservation memory reservation = reservations[stem]; + // Cross-controller release is gated on owner match rather than controller match, + // so the public registrar controller can clear a PoP-stamped slot during reclaim + // when the prior occupant is the reservation owner. + if (_isLive(reservation)) { + require(reservation.owner == expectedOwner, PopError("Reservation owner mismatch")); + } + delete reservations[stem]; + emit BaseNameReleased(stem); + } + + /// @notice Internal single-source-of-truth writer for stem reservations. + /// @dev Routes both @custom:function reserveBaseName and @custom:function reserveBaseNameForPop + /// through one path so the cross-user collision semantics stay identical: a live slot held + /// by a different user @custom:reverts PopError, and any other case writes a fresh expiry + /// and emits @custom:emits BaseNameReserved. Same-owner re-reservations refresh the expiry + /// to `block.timestamp + MAX_RESERVATION_TIME`. Callers are responsible for validating + /// `stem` is canonical and stem-shaped (no trailing digits); this helper does no input + /// validation of its own so each public entry can layer additional eligibility checks. + function _writeReservation(string calldata stem, address userAddress) internal { + Reservation memory existing = reservations[stem]; + bool liveSlot = _isLive(existing); + if (liveSlot) { + require(existing.owner == userAddress, PopError("Base name held by another user")); + } + + // `block.timestamp + MAX_RESERVATION_TIME` cannot overflow `uint64`: `MAX_RESERVATION_TIME` + // is bounded (12 weeks, ~7.26e6) and `uint64` saturates at ~5.84e11, a horizon that does + // not arrive until year 2554. + // forge-lint: disable-next-line(unsafe-typecast) + uint64 expiryTime = uint64(block.timestamp + MAX_RESERVATION_TIME); + // Preserve the original stamping `controller` on same-owner refresh so a sibling controller + // tracking the same stem (e.g. the PoP queue head) retains the right to release. Without + // this, a same-user re-reservation through a different controller silently steals the slot + // and bricks the original controller's release/advance/claim paths. + address stampingController = liveSlot ? existing.controller : msg.sender; + reservations[stem] = + Reservation({owner: userAddress, expires: expiryTime, controller: stampingController}); + emit BaseNameReserved(stem, userAddress, expiryTime); + } +} diff --git a/contracts/registrars/DotnsPopController.sol b/contracts/registrars/DotnsPopController.sol index d0b01853a..ec778f249 100644 --- a/contracts/registrars/DotnsPopController.sol +++ b/contracts/registrars/DotnsPopController.sol @@ -146,8 +146,9 @@ contract DotnsPopController is /// boundary, which is the question it is asking. mapping(string label => bool issued) internal _popIssued; - /// @dev Reserved storage space to allow for layout changes in future upgrades. - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in future upgrades. The + /// `_popIssued` mapping consumes one of the reserved slots, so the gap holds 49. + uint256[49] private __gap; /// @notice Restricts calls to a Root origin. modifier onlyRoot() { diff --git a/contracts/registrars/DotnsPopControllerOld.sol b/contracts/registrars/DotnsPopControllerOld.sol new file mode 100644 index 000000000..cb6b5c847 --- /dev/null +++ b/contracts/registrars/DotnsPopControllerOld.sol @@ -0,0 +1,948 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IDotnsPopControllerOld} from "./IDotnsPopControllerOld.sol"; +import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {IDotnsPopResolverOld} from "../resolvers/IDotnsPopResolverOld.sol"; +import {IPopRulesOld} from "../pop/IPopRulesOld.sol"; +import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {ILabelStore} from "../store/ILabelStore.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {RegistrationUtils} from "../utils/RegistrationUtils.sol"; +import {SubnodeUtilsOld} from "../utils/SubnodeUtilsOld.sol"; +import {IDotnsRegistry} from "../registry/IDotnsRegistry.sol"; +import {StringUtilsOld} from "../utils/StringUtilsOld.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; +import {SystemUtils} from "../utils/SystemUtils.sol"; + +/// @title DotnsPopControllerOld +/// @notice Dedicated PoP controller orchestrating lite-person and full-person username +/// issuance on behalf of the PoP gateway. +/// @dev Lives behind its own UUPS proxy with its own storage. Registered on `DotnsRegistrarOld` +/// via `addController`, which is how multiple controllers coexist on the same registrar +/// without interfering with each other. +/// +/// Enforcement: +/// Personhood is attested off-chain by the gateway before the call reaches this +/// contract, so the on-chain personhood precompile is not re-queried on the gateway path. +/// Every base-label mint path still calls @custom:function IPopRulesOld.classifyName to reject +/// governance-reserved labels (@custom:reverts InvalidBaseLabel on the base path, +/// @custom:reverts InvalidLiteLabel on the lite path). The lite leg accepts any two-digit lite +/// label whose stem is not governance-reserved, regardless of stem length. Native-token pricing +/// is bypassed entirely; the gateway pays no rent. +/// +/// Decoupling: +/// This contract does not import or call `IDotnsRegistrarController`. The public +/// commit-reveal controller is equally unaware of this one. Cross-flow collision handling +/// relies on two distinct properties, neither of which requires the two controllers to know +/// about each other: +/// (1) Lite-person labels (`stem.NN`) occupy a namespace the public path cannot reach: the +/// separator is legal only on a lite label, and the public path rejects it, so no public +/// registration can spell one. A digit suffix is not exclusive, but an ordinary label carrying +/// one is measured as written and so is simply a different name. The two flows therefore cannot +/// contend for the same label. This holds of labels the contracts minted, not of an arbitrary +/// string: a subname stored under a digit-only parent reads the same way, which is why +/// provenance is published through @custom:function isPopIssued rather than inferred. +/// (2) Base-name reservations are synchronised into `IPopRulesOld`. The head of this +/// controller's reservation queue is written through `IPopRulesOld.reserveBaseNameForPop` on +/// every head transition; the slot is cleared through `IPopRulesOld.releaseBaseName` when the +/// queue empties (claim, final relinquish, final expiry). The public commit-reveal +/// controller routes through `IPopRulesOld.priceWithCheck`, which rejects any registration +/// targeting a base-name stem reserved for another user, so the public flow respects +/// gateway reservations without ever importing this contract. PopRulesOld is the single +/// cross-flow authority; the queue here is the intra-PoP ordering layer on top of it. +/// +/// Shared primitives: labelhash / namehash via @custom:contract LabelUtils; the mint + +/// forward-registry + store-write triad via @custom:contract RegistrationUtils; chat-key and +/// lite-to-full link persistence via +/// @custom:contract IDotnsPopResolverOld. Keeping per-name records on the resolver preserves the +/// "Store = labels only" invariant. +/// @custom:security-contact admin@parity.io +contract DotnsPopControllerOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsPopControllerOld +{ + using StringUtilsOld for *; + using EnumerableSet for EnumerableSet.AddressSet; + + /// @notice Upper bound for the number of simultaneously queued reservations per label. + /// @dev Keeps `expireReservation` gas bounded. + uint16 public constant MAX_RESERVATION_QUEUE = 64; + + /// @notice Minimum value accepted by @custom:function setReservationDuration. + /// @dev Prevents owner misconfiguration from instantly expiring every live queue and + /// pending-claim entry. The actual production duration is governance-tuned higher. + uint64 public constant MIN_RESERVATION_DURATION = 1 hours; + + /// @notice Required byte length for a non-empty chat key. + /// @dev Mirrors @custom:contract IDotnsPopResolverOld `InvalidChatKeyLength` so the controller + /// can fail closed before the mint instead of bubbling the resolver's revert after partial + /// state has been committed. + uint256 private constant CHAT_KEY_LENGTH = 65; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Per-label queue metadata (head/tail pointers). + mapping(bytes32 labelhash => ReservationQueueMeta meta) internal _reservationMeta; + + /// @notice Per-label sparse entries keyed by monotonically-increasing index. + mapping(bytes32 labelhash => mapping(uint64 index => ReservationEntry entry)) internal + _reservationEntries; + + /// @notice Single per-user pointer into the reservation queues. + /// @dev Keeps per-user reservation data behind one key and one struct value so callers + /// read both fields in one call instead of two. + mapping(address user => UserReservation reservation) internal _userReservations; + + /// @notice Remembers the base-label string for each reserved labelhash so the PopRulesOld + /// sync path can address the reservation by its original string form (PopRulesOld keys its + /// `reservations` mapping by string). + /// @dev Populated on first enqueue for a label, cleared when the queue empties. Exists + /// only to bridge the queue's `bytes32` key space to PopRulesOld' `string` key space; + /// nothing else reads it. + mapping(bytes32 labelhash => string baseLabel) internal _reservedBaseLabel; + + /// @notice Duration (in seconds) after which a reservation entry is considered expired. + /// @dev Sets the reservation duration, configurable by + /// governance via `setReservationDuration`. + uint64 public override reservationDuration; + + /// @notice Enumeration set of users holding at least one pending claim. + /// @dev Membership equals the set of users with a non-empty queue. Used by + /// `pendingClaimUserCount` and `pendingClaimUsers` for paginated enumeration. + EnumerableSet.AddressSet private _pendingClaimUsers; + + /// @notice Per-user pile of deferred names awaiting a `LabelStore`. + /// @dev The mint origin cannot deploy a `LabelStore`, so deferred names accumulate here until a + /// signed-origin + /// @custom:function settlePendingClaims deploys the store and writes the stashed labels. Each + /// entry's deadline is measured from its own `mintedAt` against `reservationDuration`. + mapping(address user => PendingClaim[] queue) internal _pendingClaimQueue; + + /// @notice Labels this controller minted, keyed by the bare label without the TLD. + /// @dev Provenance, not a transfer rule: written once at mint and never cleared, so it + /// stays true if a name later becomes transferable. Keyed by the label text rather + /// than the node because a reader holding only `joseph.42` cannot derive the node + /// without first deciding whether the separator is part of the label or a subname + /// boundary, which is the question it is asking. + mapping(string label => bool issued) internal _popIssued; + + /// @dev Reserved storage space to allow for layout changes in future upgrades. The + /// `_popIssued` mapping consumes one of the reserved slots, so the gap holds 49. + uint256[49] private __gap; + + /// @notice Restricts calls to a Root origin. + modifier onlyRoot() { + _onlyRoot(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the PoP controller. + /// @dev Called once through the UUPS proxy; `_disableInitializers` on the implementation + /// makes direct calls revert with @custom:reverts InvalidInitialization, and any nested + /// call outside an active initialiser scope reverts with @custom:reverts NotInitializing. + /// Emits @custom:emits ReservationDurationSet so indexers observe the initial value + /// through the same event the setter uses later. + /// @param initialOwner Address that owns the contract once initialised. + function initialize( + address initialOwner, + IDotnsProtocolRegistry registry, + uint64 reservationDuration_ + ) + external + initializer + { + require( + reservationDuration_ >= MIN_RESERVATION_DURATION, + ReservationDurationTooLow(reservationDuration_) + ); + __Ownable_init(initialOwner); + __ERC165_init(); + protocolRegistry = registry; + reservationDuration = reservationDuration_; + emit ReservationDurationSet(reservationDuration_); + } + + /// @inheritdoc IDotnsPopControllerOld + function isPopIssued(string calldata label) external view override returns (bool issued) { + return _popIssued[label]; + } + + /// @inheritdoc IDotnsPopControllerOld + function reserveLiteName(LiteRegistration calldata params) external override onlyRoot { + _reserveLite(_popRules(), params); + } + + /// @inheritdoc IDotnsPopControllerOld + function reserveBaseName(BaseReservation calldata params) external override onlyRoot { + IPopRulesOld rules = _popRules(); + bytes32 reservedHash; + bool hasReservation = bytes(params.reservedBaseLabel).length != 0; + if (hasReservation) { + (reservedHash,) = _validateReservableBaseLabel(rules, params.reservedBaseLabel); + } + + _reserveLite(rules, params.lite); + + if (hasReservation) { + _advanceExpiredHead(reservedHash); + _removeUserFromQueue(params.lite.user); + _enqueueReservation(rules, reservedHash, params.reservedBaseLabel, params.lite.user); + } + } + + /// @inheritdoc IDotnsPopControllerOld + function reserveBaseNameOnly(BaseNameReservation calldata params) external override onlyRoot { + IPopRulesOld rules = _popRules(); + (bytes32 reservedHash,) = _validateReservableBaseLabel(rules, params.reservedBaseLabel); + _advanceExpiredHead(reservedHash); + _removeUserFromQueue(params.user); + _enqueueReservation(rules, reservedHash, params.reservedBaseLabel, params.user); + } + + /// @notice Lite-only mint shared by @custom:function reserveLiteName and the lite leg + /// of @custom:function reserveBaseName. + /// @dev Gateway attestation is the authority for personhood on this path; the on-chain + /// precompile is not consulted. The label is stored in the `stem.NN` form the gateway sends, + /// which is the canonical form of the name, so no normalisation happens here. The shape check + /// runs before classification so a malformed label reverts + /// @custom:reverts InvalidLiteLabel, which the gateway decodes by selector; letting + /// `classifyName` catch it instead would surface an undecodable PopRulesOld string. + /// Takes the @custom:struct LiteRegistration struct directly so both call sites pass the same + /// payload shape: the typed entrypoint forwards its own `params`, the `reserveBaseName` + /// entrypoint forwards `params.lite`. + function _reserveLite(IPopRulesOld rules, LiteRegistration calldata params) internal { + require(params.liteLabel.isLitePersonLabel(), InvalidLiteLabel()); + _requireValidChatKey(params.chatKey); + + (IPopRulesOld.PopStatus required,) = rules.classifyName(params.liteLabel); + // The shape check fixes the suffix, so classification lands on PopLite (stem 6-8), + // NoStatus (stem 9 or more), or Reserved (stem 5 or fewer). Accept the first two; a + // stem short enough to be governance-reserved is not issued from this path. + require(required != IPopRulesOld.PopStatus.Reserved, InvalidLiteLabel()); + (bytes32 labelhash, bytes32 node) = _validateLiteLabel(params.liteLabel); + + _completeGatewayRegistration( + params.user, params.liteLabel, labelhash, node, params.chatKey, bytes32(0) + ); + + emit LiteNameReserved(labelhash, params.user, params.liteLabel); + } + + /// @inheritdoc IDotnsPopControllerOld + function registerBaseName(FullRegistration calldata params) external override onlyRoot { + Link calldata link = params.link; + address user = params.user; + string calldata label = params.label; + + (bytes32 labelhash, bytes32 node) = _validateBaseLabel(label); + + IPopRulesOld rules = _popRules(); + (IPopRulesOld.PopStatus required,) = rules.classifyName(label); + require( + required != IPopRulesOld.PopStatus.Reserved + && required != IPopRulesOld.PopStatus.PopLite, + InvalidBaseLabel() + ); + + _advanceExpiredHead(labelhash); + + // Cross-flow guard: after the local queue has had a chance to release its own + // PopRulesOld slot via head-advance, any remaining live slot was written by a sibling + // controller. Reject when held by another user so PopRulesOld stays the single + // cross-flow authority in both directions; the public flow reads this slot through + // `priceWithCheck` and writes none of its own. + (bool slotLive, address slotOwner,) = rules.isBaseNameReserved(label); + require(!slotLive || slotOwner == user, NotHolder(user, labelhash)); + + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + ReservationEntry memory headEntry = meta.head < meta.tail + ? _reservationEntries[labelhash][meta.head] + : ReservationEntry({owner: address(0), joinedAt: 0}); + bool isClaim = _userReservations[user].labelhash == labelhash && meta.head < meta.tail + && headEntry.owner == user; + + if (!isClaim && meta.head < meta.tail) { + if ( + headEntry.owner != address(0) && headEntry.owner != user + && !_isExpired(headEntry.joinedAt) + ) { + revert NotHolder(user, labelhash); + } + } + + if (isClaim) { + _clearQueue(labelhash); + } else { + _removeUserFromQueue(user); + } + + bytes32 liteLabelhash; + bytes32 liteNode; + bytes memory chatKeyToPersist; + if (link.kind == LinkKind.LiteUsername) { + require(link.liteLabel.isLitePersonLabel(), InvalidLiteLabel()); + (liteLabelhash, liteNode) = _validateLiteLabel(link.liteLabel); + // A lite username is a subnode, so its owner lives in the registry record rather than + // the registrar's ERC-721 ledger. + require( + _registry().owner(liteNode) == user, LiteLabelNotOwnedByUser(user, liteLabelhash) + ); + chatKeyToPersist = _popResolver().chatKey(liteNode); + } else { + _requireValidChatKey(link.chatKey); + chatKeyToPersist = link.chatKey; + } + + _completeGatewayRegistration(user, label, labelhash, node, chatKeyToPersist, liteLabelhash); + + if (isClaim) { + emit BaseNameClaimed(labelhash, user, label); + } else { + emit StandaloneNameRegistered(labelhash, user, label); + } + if (link.kind == LinkKind.LiteUsername) { + emit LiteToFullLinked(labelhash, liteLabelhash); + } + } + + /// @inheritdoc IDotnsPopControllerOld + function expireReservation(string calldata reservedBaseLabel) external override { + (bytes32 labelhash,) = _validateBaseLabel(reservedBaseLabel); + _advanceExpiredHead(labelhash); + } + + /// @inheritdoc IDotnsPopControllerOld + function relinquishReservation() external override { + UserReservation memory userRes = _userReservations[msg.sender]; + require(userRes.labelhash != bytes32(0), NoActiveReservation(msg.sender)); + _removeUserFromQueue(msg.sender); + emit ReservationRelinquished(userRes.labelhash, msg.sender); + } + + /// @inheritdoc IDotnsPopControllerOld + function claimLabelStore() external override returns (bool moreRemaining) { + (, moreRemaining) = _settlePending(msg.sender, DotnsConstants.MAX_PAGE_SIZE); + } + + /// @inheritdoc IDotnsPopControllerOld + function settlePendingClaims( + address user, + uint256 limit + ) + external + override + returns (uint256 settledCount, bool moreRemaining) + { + return _settlePending(user, limit); + } + + /// @notice Shared settlement loop behind @custom:function claimLabelStore and + /// @custom:function settlePendingClaims. + /// @dev Settles up to `limit` of the user's pending claims, deploying the store on the first + /// write, and removes the user from the enumeration set once their queue empties. + function _settlePending( + address user, + uint256 limit + ) + internal + returns (uint256 settledCount, bool moreRemaining) + { + IStoreFactory factory = _storeFactory(); + address store = factory.getLabelStore(user); + + PendingClaim[] storage queue = _pendingClaimQueue[user]; + uint256 remaining = queue.length; + settledCount = limit < remaining ? limit : remaining; + + // Settle from the tail: read the last entry, pop it, then write. Popping the tail removes + // an entry with no storage copy, unlike a swap-from-front. Settlement order does not + // matter to the reads. The pop runs before the external write (deploy + store label), so a + // store or factory that ever gained a callback could not re-enter onto an un-popped queue. + for (uint256 i; i < settledCount; ++i) { + --remaining; + string memory label = queue[remaining].label; + queue.pop(); + store = _settlePendingLabel(factory, store, user, label); + } + + moreRemaining = remaining != 0; + if (!moreRemaining) { + _pendingClaimUsers.remove(user); + } + } + + /// @notice Writes a single pending label into the user's store, deploying the store lazily. + /// @dev The store is created only when there is a label to write, so a caller who settles an + /// empty queue never leaves a fresh store behind with nothing in it. Returns the (possibly + /// newly deployed) store so the caller threads it through the remaining entries. + function _settlePendingLabel( + IStoreFactory factory, + address store, + address user, + string memory label + ) + internal + returns (address) + { + bytes32 labelhash = LabelUtils.labelhashMemory(label); + // A lite label settled its ownership as a subnode, so its store entry keys the same + // hierarchical node; a full label keys the second-level node under the TLD. + bytes32 node = label.isLitePersonLabelMemory() + ? _liteSubnode(label) + : LabelUtils.namehashUnder(protocolRegistry.tldNode(), labelhash); + if (store == address(0)) { + store = factory.deployLabelStoreFor(user); + } + _writeRecord(store, node, label); + emit PendingClaimSettled(user, labelhash, store, msg.sender); + emit NameRegistered(label, labelhash, user, store); + return store; + } + + /// @inheritdoc IDotnsPopControllerOld + function isReservedForClaim(string calldata reservedBaseLabel) + external + view + override + returns (bool reserved, address holder) + { + (bytes32 labelhash,) = _validateBaseLabel(reservedBaseLabel); + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + if (meta.head >= meta.tail) return (false, address(0)); + + ReservationEntry memory head = _reservationEntries[labelhash][meta.head]; + if (head.owner == address(0)) return (false, address(0)); + if (_isExpired(head.joinedAt)) return (false, address(0)); + + return (true, head.owner); + } + + /// @inheritdoc IDotnsPopControllerOld + function setReservationDuration(uint64 duration) external override onlyOwner { + require(duration >= MIN_RESERVATION_DURATION, ReservationDurationTooLow(duration)); + reservationDuration = duration; + emit ReservationDurationSet(duration); + } + + /// @inheritdoc IDotnsPopControllerOld + function reservationMeta(bytes32 labelhash) + external + view + override + returns (uint64 head, uint64 tail) + { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + return (meta.head, meta.tail); + } + + /// @inheritdoc IDotnsPopControllerOld + function reservationEntry( + bytes32 labelhash, + uint64 index + ) + external + view + override + returns (address entryOwner, uint64 joinedAt) + { + ReservationEntry memory entry = _reservationEntries[labelhash][index]; + return (entry.owner, entry.joinedAt); + } + + /// @inheritdoc IDotnsPopControllerOld + function userReservation(address user) + external + view + override + returns (UserReservation memory reservation) + { + return _userReservations[user]; + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaims( + address user, + uint256 offset, + uint256 limit + ) + external + view + override + returns (PendingClaim[] memory claims) + { + PendingClaim[] storage queue = _pendingClaimQueue[user]; + uint256 total = queue.length; + if (offset >= total) return new PendingClaim[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + if (count > DotnsConstants.MAX_PAGE_SIZE) count = DotnsConstants.MAX_PAGE_SIZE; + + claims = new PendingClaim[](count); + for (uint256 i; i < count; ++i) { + claims[i] = queue[offset + i]; + } + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaimCountOf(address user) external view override returns (uint256 count) { + return _pendingClaimQueue[user].length; + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaimUserCount() external view override returns (uint256 count) { + return _pendingClaimUsers.length(); + } + + /// @inheritdoc IDotnsPopControllerOld + function pendingClaimUsers( + uint256 offset, + uint256 limit + ) + external + view + override + returns (address[] memory users) + { + uint256 total = _pendingClaimUsers.length(); + if (offset >= total) return new address[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + if (count > DotnsConstants.MAX_PAGE_SIZE) count = DotnsConstants.MAX_PAGE_SIZE; + + users = new address[](count); + for (uint256 i; i < count; ++i) { + users[i] = _pendingClaimUsers.at(offset + i); + } + } + + /// @inheritdoc IDotnsPopControllerOld + function reservedBaseLabelOf(bytes32 labelhash) + external + view + override + returns (string memory baseLabel) + { + return _reservedBaseLabel[labelhash]; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return interfaceId == type(IDotnsPopControllerOld).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Returns the release this network declares it runs, read live from the protocol + /// registry so every DotNS contract reports one synchronised value. + /// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical + /// `version()` selector for ABI compatibility. It reports the network's declaration, + /// not this contract's build; per-contract identity is the codehash declared on the + /// registry. + /// @return versionString Declared release as bare semver, empty when never declared. + function version() external view virtual returns (string memory versionString) { + versionString = protocolRegistry.protocolVersion(); + } + + /// @notice Mints a name, wires forward registry, persists PoP-flow records (chat key, + /// lite link) on the PoP resolver, and either writes the label into the owner's + /// existing `LabelStore` or stashes a pending claim when the owner has none yet. + /// @dev The mint + forward-registry pair is delegated to + /// @custom:function RegistrationUtils.registerAndStore so this flow and the public + /// commit-reveal flow share exactly one implementation of that sequence. The label is + /// passed empty so the registrar does not deploy a `LabelStore`; the mint origin cannot + /// run the `LabelStore` constructor. PoP-flow per-name records + /// (chat key, lite link) are persisted eagerly on @custom:contract IDotnsPopResolverOld + /// here, before the label is written, so the resolver carries the full identity record + /// from mint time regardless of whether the owner already has a `LabelStore`. The Store + /// stays labels-only. Warm path emits @custom:emits NameRegistered immediately; the + /// cold path emits @custom:emits PendingClaimStashed at mint and defers + /// @custom:emits NameRegistered to @custom:function settlePendingClaims when the claim + /// settles. + function _completeGatewayRegistration( + address user, + string memory label, + bytes32 labelhash, + bytes32 node, + bytes memory chatKeyBytes, + bytes32 liteLabelhash + ) + internal + { + _popIssued[label] = true; + + // A lite username is a subname under its numeric container, so it takes the subnode path + // and never mints a token. A full-person name is a tokenised second-level registration and + // keeps the shared token triad untouched. `persist` is false because the store write is + // deferred to the pending-claim queue below and the user syncs it later. + if (label.isLitePersonLabelMemory()) { + // A lite name is issued once. Its subnode already existing means a duplicate issuance, + // which would rehome the identity and overwrite its records, so it is rejected. + require(!_registry().recordExists(node), LiteNameAlreadyIssued()); + (string memory stem, string memory suffix) = label.splitLiteLabel(); + // Take the node from the registry write itself, so the chat-key and store writes below + // land on exactly the node the record was created at rather than a separately derived + // one that could drift from it. + node = SubnodeUtilsOld.registerSubname( + SubnodeUtilsOld.SubnameContext({ + protocolRegistry: protocolRegistry, + parentLabel: suffix, + subLabel: stem, + owner: user, + persist: false + }) + ); + } else { + RegistrationUtils.registerAndStore( + RegistrationUtils.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: user, + label: "", + labelhash: labelhash, + node: node + }) + ); + } + + if (chatKeyBytes.length != 0 || liteLabelhash != bytes32(0)) { + IDotnsPopResolverOld resolver = _popResolver(); + if (chatKeyBytes.length != 0) { + resolver.setChatKey(node, chatKeyBytes); + } + if (liteLabelhash != bytes32(0)) { + resolver.setLiteLink(node, liteLabelhash); + } + } + + address store = _storeFactory().getLabelStore(user); + if (store == address(0)) { + _stashPendingClaim(user, label, labelhash); + } else { + _writeRecord(store, node, label); + emit NameRegistered(label, labelhash, user, store); + } + } + + /// @notice Writes a name's label into `store`. + /// @dev Single canonical persistence step shared by the warm gateway path and + /// @custom:function settlePendingClaims. The store key is `node`, matching + /// the registrar's `_writeOwnerLabel` convention. Idempotent on already-locked slots so a + /// user whose store was pre-populated under the same `node` (e.g. by a sibling protocol + /// flow) can still settle their pending claim without bricking on `LabelAlreadyExists`. + /// @param store Owner's `LabelStore` proxy. + /// @param node The name's node. A lite label resolves to its stem beneath its numeric + /// container, so this is not always `namehash(tldNode, keccak(label))` for the whole label. + /// @param label Bare label without the TLD, which is appended on write. A lite label + /// carries its separator, so this is not always a single DNS label. + function _writeRecord(address store, bytes32 node, string memory label) internal { + if (ILabelStore(store).isLocked(node)) return; + ILabelStore(store).storeLabel(node, string.concat(label, protocolRegistry.tld())); + } + + /// @notice Appends a deferred binding for `user` and adds them to the enumeration set. + /// @dev The mint origin cannot deploy the user's `LabelStore`, so deferred names pile + /// up in `_pendingClaimQueue` until a signed-origin @custom:function settlePendingClaims + /// writes them. Adding the user to the set is idempotent, so repeat stashes keep a single + /// enumeration entry. Emits @custom:emits PendingClaimStashed. + function _stashPendingClaim(address user, string memory label, bytes32 labelhash) internal { + _pendingClaimQueue[user].push( + PendingClaim({label: label, mintedAt: uint64(block.timestamp)}) + ); + _pendingClaimUsers.add(user); + + emit PendingClaimStashed(user, labelhash, label); + } + + /// @notice Returns whether a queue entry is expired relative to `block.timestamp`. + function _isExpired(uint64 joinedAt) internal view returns (bool) { + return joinedAt + reservationDuration < block.timestamp; + } + + /// @notice Appends a new reservation entry to the tail of the queue for `labelhash`. + /// @dev Reverts if the queue is full or the user already holds a reservation. When the + /// enqueued entry is the new head of an empty queue, the controller also reserves the + /// base name on PopRulesOld so the public commit-reveal flow sees the reservation through + /// its existing `priceWithCheck` guard. Subsequent waiters only live in the local queue + /// until they are promoted. + function _enqueueReservation( + IPopRulesOld rules, + bytes32 labelhash, + string memory baseLabel, + address user + ) + internal + { + require(_userReservations[user].labelhash == bytes32(0), AlreadyReserved(user, labelhash)); + + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + require(meta.tail - meta.head < MAX_RESERVATION_QUEUE, QueueFull(labelhash)); + + uint64 index = meta.tail; + bool becomesHead = index == meta.head; + + _reservationEntries[labelhash][index] = + ReservationEntry({owner: user, joinedAt: uint64(block.timestamp)}); + _reservationMeta[labelhash] = ReservationQueueMeta({head: meta.head, tail: index + 1}); + + _userReservations[user] = UserReservation({labelhash: labelhash, index: index}); + + if (becomesHead) { + _reservedBaseLabel[labelhash] = baseLabel; + rules.reserveBaseNameForPop(baseLabel, user); + } + + emit ReservationQueued(labelhash, user, index - meta.head); + } + + /// @notice Wipes the entire reservation queue for `labelhash` and releases the + /// corresponding PopRulesOld reservation. + /// @dev Used when a holder claims their reservation: every waiter is evicted and their + /// per-user tracking state is cleared, and PopRulesOld is told the slot is free so future + /// public registrations are unblocked (the claim itself just minted the name, so there + /// is nothing left to reserve). + function _clearQueue(bytes32 labelhash) internal { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + for (uint64 i = meta.head; i < meta.tail; i++) { + ReservationEntry memory entry = _reservationEntries[labelhash][i]; + if (entry.owner != address(0)) { + delete _userReservations[entry.owner]; + } + delete _reservationEntries[labelhash][i]; + } + delete _reservationMeta[labelhash]; + _releasePopRulesSlot(labelhash); + } + + /// @notice Advances the queue head past every expired entry at the head of the queue. + /// @dev Reset semantics matter: when the queue empties (head catches tail), the meta slot + /// is deleted AND the PopRulesOld base-name slot is released, so the public commit-reveal + /// flow can register the label again. When a new live head emerges, PopRulesOld is re-synced + /// to that head so reservations cannot be paid around by another address. Emits + /// @custom:emits ReservationExpired once per expired entry reaped from the head. + function _advanceExpiredHead(bytes32 labelhash) internal { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + uint64 head = meta.head; + uint64 tail = meta.tail; + + while (head < tail) { + ReservationEntry memory entry = _reservationEntries[labelhash][head]; + if (entry.owner == address(0)) { + // `owner == 0` implies the slot is fully zero (it can only have arrived here + // via a prior full-slot `delete`), so skip the no-op SSTORE. + head++; + continue; + } + if (!_isExpired(entry.joinedAt)) break; + + delete _userReservations[entry.owner]; + delete _reservationEntries[labelhash][head]; + emit ReservationExpired(labelhash, entry.owner); + head++; + } + + if (head == tail) { + delete _reservationMeta[labelhash]; + _releasePopRulesSlot(labelhash); + } else if (head != meta.head) { + _reservationMeta[labelhash] = ReservationQueueMeta({head: head, tail: tail}); + address newHead = _reservationEntries[labelhash][head].owner; + _syncPopRulesToHead(labelhash, newHead); + } + } + + /// @notice Removes `user` from whichever reservation queue they currently occupy. + /// @dev For a head removal, we delete the entry without bumping `meta.head` and delegate + /// the advance to `_advanceExpiredHead`. Its existing zero-owner skip walks past the + /// freshly-deleted slot, and its `head != meta.head` branch fires the PopRulesOld resync + /// in the one place head promotion is actually handled. Non-head removals leave the + /// queue shape intact, so no advance or resync is needed. + function _removeUserFromQueue(address user) internal { + UserReservation memory userRes = _userReservations[user]; + bytes32 labelhash = userRes.labelhash; + if (labelhash == bytes32(0)) return; + + uint64 entryIndex = userRes.index; + ReservationQueueMeta memory queueMeta = _reservationMeta[labelhash]; + + delete _userReservations[user]; + delete _reservationEntries[labelhash][entryIndex]; + + if (entryIndex == queueMeta.head) { + _advanceExpiredHead(labelhash); + } + } + + /// @notice Validates a lite-person `stem.NN` label and derives `(labelhash, node)`. + /// @dev The stem is lowercase letters only, so this rejects a stem carrying a digit or a + /// hyphen before any node is derived. `node` is the hierarchical subnode `stem` under the + /// numeric container `NN`, the node a resolver reaches by walking the dotted name, and + /// `labelhash` stays the keccak of the whole label so it is a stable text identifier for events + /// and the reservation queue. + function _validateLiteLabel(string memory liteLabel) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + require(liteLabel.isLitePersonLabelMemory(), InvalidLiteLabel()); + labelhash = LabelUtils.labelhashMemory(liteLabel); + node = _liteSubnode(liteLabel); + } + + /// @notice Derives the hierarchical subnode for a lite label `.`. + /// @dev Splits at the separator and walks `suffix.tld` then `stem` under it, so a lite name + /// resolves as `stem` beneath its numeric container rather than as a hash of the whole label. + /// Shared by @custom:function _validateLiteLabel and pending-claim settlement so every lite + /// consumer agrees on one node. + /// @param liteLabel Lite label held in memory, e.g. `alice.01`. + /// @return subnode Namehash of `stem` under `suffix.tld`. + function _liteSubnode(string memory liteLabel) internal view returns (bytes32 subnode) { + subnode = SubnodeUtilsOld.liteSubnodeOf(protocolRegistry.tldNode(), liteLabel); + } + + /// @notice Validates a base (full-person) label and derives `(labelhash, node)`. + /// @dev Letters only, so this is stricter than a DNS label: a hyphen or an interior digit + /// is rejected here even though @custom:function StringUtilsOld.isSingleLabel would admit it. + function _validateBaseLabel(string calldata baseLabel) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + // Letters only, matching the gateway's full-person label rule: a + // full-person label is a name a person chose, so it admits no digits and no hyphens. + // Classification does not cover this on its own, since a suffixed label with nine or + // more characters lands on NoStatus and would otherwise pass. + require(baseLabel.isPersonLabel(), InvalidBaseLabel()); + (labelhash, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), baseLabel); + } + + /// @notice Validates a base label as reservable and returns its hashes. + /// @dev Shared by both reservation entrypoints so the guard cannot drift between them. Runs + /// three checks and reverts on the first failure, before any reservation state is mutated: the + /// label must classify outside the governance-reserved tier and be a base name, be a + /// letters-only person label, and have no owner on the registrar. The last check is the fix for + /// a reservation queued over an already-registered name: the queue keys by stem, so such a + /// reservation could never be redeemed yet would hold the stem, and so every lite name built + /// on it, for the full reservation window. `exists` (owner set) mirrors exactly what makes the + /// eventual claim's mint revert, so a label that passes here is one a claim can still register. + function _validateReservableBaseLabel( + IPopRulesOld rules, + string calldata baseLabel + ) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + (labelhash, node) = _validateBaseLabel(baseLabel); + + (IPopRulesOld.PopStatus required,) = rules.classifyName(baseLabel); + require( + required != IPopRulesOld.PopStatus.Reserved && rules.isBaseName(baseLabel), + InvalidBaseLabel() + ); + require(!_registrar().exists(uint256(node)), BaseNameAlreadyRegistered()); + } + + /// @notice Reverts when a non-empty chat key is not exactly `CHAT_KEY_LENGTH` bytes. + /// @dev Mirrors the resolver's own length gate so the gateway sees a controller-local + /// `InvalidChatKey` revert before any mint state is written. + function _requireValidChatKey(bytes memory chatKey) internal pure { + require( + chatKey.length == 0 || chatKey.length == CHAT_KEY_LENGTH, InvalidChatKey(chatKey.length) + ); + } + + /// @notice Resolves the PoP resolver via the protocol registry. + function _popResolver() internal view returns (IDotnsPopResolverOld) { + return IDotnsPopResolverOld(protocolRegistry.get(DotnsConstants.POP_RESOLVER)); + } + + /// @notice Resolves the PopRulesOld contract via the protocol registry. + function _popRules() internal view returns (IPopRulesOld) { + return IPopRulesOld(protocolRegistry.get(DotnsConstants.POP_RULES)); + } + + /// @notice Resolves the Store factory via the protocol registry. + function _storeFactory() internal view returns (IStoreFactory) { + return IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + } + + /// @notice Resolves the registrar via the protocol registry. + function _registrar() internal view returns (IDotnsRegistrar) { + return IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + } + + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistry) { + return IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)); + } + + /// @notice Writes the new head of the queue into PopRulesOld so the public commit-reveal flow + /// rejects registrations of this base name for anyone other than `newHead`. + /// @dev Callers guarantee `newHead` is non-zero (the queue holds a live entry) and that + /// `_reservedBaseLabel[labelhash]` is non-empty (any non-empty queue had its first head + /// write the slot). The release-then-reserve pair satisfies PopRulesOld' ownership gate on + /// `reserveBaseNameForPop`. + function _syncPopRulesToHead(bytes32 labelhash, address newHead) internal { + string memory baseLabel = _reservedBaseLabel[labelhash]; + IPopRulesOld rules = _popRules(); + rules.releaseBaseName(baseLabel); + rules.reserveBaseNameForPop(baseLabel, newHead); + emit ReservationHeadAdvanced(labelhash, newHead); + } + + /// @notice Clears the PopRulesOld slot and the local label bookkeeping when the queue empties + /// (claim, last-relinquish, last-expire). + function _releasePopRulesSlot(bytes32 labelhash) internal { + string memory baseLabel = _reservedBaseLabel[labelhash]; + if (bytes(baseLabel).length == 0) return; + _popRules().releaseBaseName(baseLabel); + delete _reservedBaseLabel[labelhash]; + } + + /// @notice Internal check enforcing a Root origin. + /// @dev Authorises a call when @custom:function SystemUtils.originIsRoot is true, and + /// reverts with NotRoot otherwise. `msg.sender` is deliberately not consulted: a + /// Root origin has no account behind it, so reading `msg.sender` traps. That holds + /// for this frame and any delegatecall sharing it; a nested call sees the calling + /// contract as its sender and reads normally. + /// + /// The check also holds for the whole Root transaction rather than the entry frame + /// alone, so nothing reachable from an onlyRoot entrypoint may call a + /// user-controlled address: such a callee could re-enter a gated function and still + /// pass. Every call out of this contract goes to a protocol contract resolved + /// through the registry. + function _onlyRoot() internal view { + require(SystemUtils.originIsRoot(), NotRoot()); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/DotnsRegistrar.sol b/contracts/registrars/DotnsRegistrar.sol index a1106534e..8cfdfbcf4 100644 --- a/contracts/registrars/DotnsRegistrar.sol +++ b/contracts/registrars/DotnsRegistrar.sol @@ -62,8 +62,10 @@ contract DotnsRegistrar is /// @custom:function quoteTransferFee. mapping(uint256 tokenId => bool soulbound) private _soulbound; - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in the future. `_soulbound` occupies + /// one reserved slot, so the gap holds 49 slots and the contract keeps a fixed 51-slot + /// footprint. + uint256[49] private __gap; /// @notice Restricts function access to authorised controllers. modifier onlyController() { diff --git a/contracts/registrars/DotnsRegistrarController.sol b/contracts/registrars/DotnsRegistrarController.sol index c91d4001f..c250606ce 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -51,6 +51,24 @@ contract DotnsRegistrarController is using StringUtils for *; using StoreUtils for IStoreFactory; + /// @notice One role's membership and its admin role. + /// @dev Member of the reserved AccessControl namespace, unused because gating is Root only. + /// @param hasRole Whether an account holds the role. + /// @param adminRole Admin role that manages the role. + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + /// @notice Reserved OpenZeppelin access-control namespace held at its ERC-7201 slot. + /// @dev Declared and left unused so the namespace stays present in the layout. Its slot derives + /// from the label, disjoint from the sequential slots below, so it consumes none of them. + /// @param _roles Role data keyed by role identifier. + /// @custom:storage-location erc7201:openzeppelin.storage.AccessControl + struct AccessControlStorage { + mapping(bytes32 role => RoleData) _roles; + } + /// @notice Upper bound for commitment validity to cap storage griefing risk. uint256 public constant MAX_ALLOWED_COMMITMENT_AGE = 7 days; @@ -70,11 +88,17 @@ contract DotnsRegistrarController is /// from this stamp. mapping(bytes32 hash => uint256 version) public committedPricingVersion; + /// @dev Reserved slot held so the sequential storage layout stays fixed across the in-place + /// upgrade. Unused: name eligibility lives in @custom:contract DotnsNameWhitelist. + /// @custom:oz-renamed-from whiteList + mapping(address account => bool retained) private __whiteListSlot; + /// @notice Protocol-level address registry for all dotNS contracts. IDotnsProtocolRegistry public protocolRegistry; - /// @dev Reserved storage space to allow for layout changes in the future. - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in the future. The retained + /// whitelist slot above holds one slot, so the gap holds 49 to keep the footprint fixed. + uint256[49] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol new file mode 100644 index 000000000..4a239a736 --- /dev/null +++ b/contracts/registrars/DotnsRegistrarOld.sol @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ERC721Upgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; + +import {IDotnsRegistrar} from "./IDotnsRegistrar.sol"; +import {IDotnsController} from "./IDotnsController.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; + +import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {ILabelStore} from "../store/ILabelStore.sol"; +import {StoreUtils} from "../utils/StoreUtils.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtilsOld} from "../utils/StringUtilsOld.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IPopRulesOld} from "../pop/IPopRulesOld.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title Dotns Registrar +/// @notice ERC721-backed registrar implementing permanent name ownership. +/// @dev Deliberately policy-free on pricing, reservations, and PoP gating; those live in the +/// controllers and @custom:contract IPopRulesOld. The registrar owns transferability itself: +/// publicly registered names transfer freely, while names minted through the PoP gateway are +/// soulbound and +/// revert on transfer. The `_update` hook enforces both the soulbound gate and the fee-on-transfer +/// settlement that consults the escrow. +/// @custom:security-contact admin@parity.io +contract DotnsRegistrarOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC721Upgradeable, + IDotnsRegistrar +{ + using StoreUtils for IStoreFactory; + using StringUtilsOld for *; + + /// @notice Mapping of authorised controllers. + /// @dev Controllers may call `register`. Keyed by the shared baseline @custom:contract + /// IDotnsController interface so the registrar doesn't depend on any specific controller shape. + /// Commit-reveal, PoP, and future controllers coexist here so long as they implement the + /// baseline interface. + /// @custom:oz-retyped-from mapping(IDotnsRegistrarController => bool) + mapping(IDotnsController controller => bool exists) public controllers; + + /// @notice Protocol-level address registry for all DotNS contracts. + /// @dev Used to resolve sibling contract addresses (store factory, controller, registry) + /// without storing individual references. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Marks a token as soulbound: minted through the PoP gateway and non-transferable. + /// @dev Set at mint by @custom:function register when the caller is the address registered + /// under `DotnsConstants.POP_CONTROLLER`. Write-once and never cleared: a name's soulbound + /// state is fixed at registration. Read by the `_update` transfer gate and by + /// @custom:function quoteTransferFee. + mapping(uint256 tokenId => bool soulbound) private _soulbound; + + /// @dev Reserved storage space to allow for layout changes in the future. `_soulbound` occupies + /// one reserved slot, so the gap holds 49 slots and the contract keeps a fixed 51-slot + /// footprint. + uint256[49] private __gap; + + /// @notice Restricts function access to authorised controllers. + modifier onlyController() { + _onlyController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registrar. + /// @dev Uses OpenZeppelin upgradeable initialisers and is callable once through the UUPS + /// proxy; direct calls on the implementation revert with @custom:reverts InvalidInitialization + /// because `_disableInitializers` runs in the constructor, and any nested call outside an + /// active initialiser scope reverts with @custom:reverts NotInitializing. + /// @param initialOwner Address that owns the contract once initialised. + function initialize( + address initialOwner, + string calldata name, + string calldata symbol, + IDotnsProtocolRegistry registry + ) + external + initializer + { + require(address(registry) != address(0), ProtocolRegistryRequired()); + __Ownable_init(initialOwner); + __ERC721_init(name, symbol); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistrar + function addController(IDotnsController controller) external onlyOwner { + controllers[controller] = true; + emit ControllerAdded(controller); + } + + /// @inheritdoc IDotnsRegistrar + function removeController(IDotnsController controller) external onlyOwner { + controllers[controller] = false; + emit ControllerRemoved(controller); + } + + /// @inheritdoc IDotnsRegistrar + function available(uint256 id) public view override returns (bool isAvailable) { + address holder = _ownerOf(id); + if (holder == address(0)) return true; + + address escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); + if (holder != escrow) return false; + + // Escrow custody on its own does not mean registrable: a released name inside its redeem + // window still belongs to its previous holder. The escrow owns that lifecycle and is asked + // directly, so availability here and reclaimability there cannot drift apart and start + // advertising names whose registration would revert. + return IDotnsNameEscrow(payable(escrow)).isReclaimable(id); + } + + /// @inheritdoc IDotnsRegistrar + function register( + uint256 id, + address owner, + string calldata label + ) + external + override + onlyController + { + // `available` returns true both for unminted ids and for ids currently held by escrow + // (so the controller can route through `escrow.reclaim`). `register` only handles the + // fresh-mint branch; the escrow-held branch must use the reclaim path and is rejected + // here with the typed error so callers do not see OZ's `ERC721InvalidSender(0)`. + require(!_exists(id), NameNotAvailable(id)); + require(owner != protocolRegistry.get(DotnsConstants.NAME_ESCROW), InvalidOwner()); + // Empty labels are an intentional gateway-cold path (substrate Root cannot deploy a + // `LabelStore` under `pallet-revive`, so the controller stashes a pending claim and the + // user settles via @custom:function IDotnsPopControllerOld.claimLabelStore later). + // Non-empty labels must still be canonical so the transfer-floor lookup in + // `_quoteTransferFee` + // cannot brick the token by reverting on a malformed stem. + require(bytes(label).length == 0 || label.isSingleLabel(), InvalidLabel()); + _mint(owner, id); + // Provenance is verified here rather than trusted from a caller-supplied flag: only the + // canonical PoP controller mints soulbound names, so a compromised or buggy peer controller + // cannot lock a public name and the PoP controller cannot mint an unlocked one. Written + // only on the true branch to leave the public path free of a redundant zero write. + bool soulbound = msg.sender == protocolRegistry.get(DotnsConstants.POP_CONTROLLER); + if (soulbound) _soulbound[id] = true; + if (bytes(label).length != 0) _writeOwnerLabel(owner, id, label); + emit NameRegistered(id, owner, soulbound); + } + + /// @inheritdoc IDotnsRegistrar + function labelOf(uint256 tokenId) external view override returns (string memory) { + address holder = _ownerOf(tokenId); + if (holder == address(0)) return ""; + return LabelUtils.stripTld(protocolRegistry.tld(), _readLabel(tokenId, holder)); + } + + /// @inheritdoc IDotnsRegistrar + function quoteTransferFee( + uint256 tokenId, + address to + ) + external + view + override + returns (uint256 requiredFee) + { + require(to != address(0), ERC721InvalidReceiver(address(0))); + // A soulbound name cannot be transferred, so it has no transfer price. Revert rather than + // return zero: a zero here would read as "transferable, no fee" to integrators while any + // real transfer reverts in `_update`. + require(!_soulbound[tokenId], NameSoulbound(tokenId)); + + address from = ownerOf(tokenId); + (,, requiredFee) = _quoteTransferFee(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrar + function transferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrar) + { + super.transferFrom(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrar + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrar) + { + super.safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IDotnsRegistrar + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrar) + { + super.safeTransferFrom(from, to, tokenId, data); + } + + /// @notice Returns the release this network declares it runs, read live from the protocol + /// registry so every DotNS contract reports one synchronised value. + /// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical + /// `version()` selector for ABI compatibility. It reports the network's declaration, + /// not this contract's build; per-contract identity is the codehash declared on the + /// registry. + /// @return versionString Declared release as bare semver, empty when never declared. + function version() external view virtual returns (string memory versionString) { + versionString = protocolRegistry.protocolVersion(); + } + + /// @inheritdoc IDotnsRegistrar + function exists(uint256 tokenId) external view override returns (bool tokenExists) { + tokenExists = _exists(tokenId); + } + + /// @inheritdoc IDotnsRegistrar + function isSoulbound(uint256 tokenId) external view override returns (bool soulbound) { + soulbound = _soulbound[tokenId]; + } + + /// @notice Checks whether a token ID exists. + function _exists(uint256 tokenId) internal view returns (bool) { + return _ownerOf(tokenId) != address(0); + } + + /// @notice Internal function to check for controller access. + function _onlyController() internal view { + require(controllers[IDotnsController(msg.sender)], NotController(msg.sender)); + } + + /// @inheritdoc ERC721Upgradeable + function _update( + address to, + uint256 tokenId, + address auth + ) + internal + override + returns (address from) + { + from = super._update(to, tokenId, auth); + + // Mints carry no economic event and must not be blocked: the soulbound flag is written + // after `_mint`, so a mint reaches here before the flag exists. Reject any attached value + // because nothing forwards it onward (no `receive`, no rescue path). + if (from == address(0)) { + require(msg.value == 0, UnexpectedValue()); + return from; + } + + // Soulbound names are non-transferable, including a move to the sender's own address, which + // keeps this in step with @custom:function quoteTransferFee and the interface contract. It + // reverts rather than returning, unwinding the ownership move `super._update` has already + // made, and sits before any escrow or store lookup so a soulbound token is rejected even + // when the escrow is unconfigured, blocking every custody move including release into + // escrow. + require(!_soulbound[tokenId], NameSoulbound(tokenId)); + + // Self-transfers of a transferable name carry no economic event. Reject attached value for + // the same trapped-funds reason as the mint path above. + if (from == to) { + require(msg.value == 0, UnexpectedValue()); + return from; + } + + // Resolve every registry-sourced dependency once and thread it into the helpers so a + // single transfer pays one external lookup per key rather than three. + IDotnsProtocolRegistry registry = protocolRegistry; + address escrow = registry.get(DotnsConstants.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + // `release` is the only caller that moves a name into custody, so any other sender is a + // deposit the escrow holds no position for. + require(to != escrow || msg.sender == escrow, UnsolicitedEscrowDeposit(tokenId)); + IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); + + bool isEscrowTouching = to == escrow || from == escrow; + // Skip mirroring on escrow-touching paths: release deposits the NFT into custody where + // a `LabelStore` would be wasted and reclaim hands it back to a fresh-mint controller + // that writes the label through its own flow. + if (!isEscrowTouching) { + _syncRecipientStore(factory, to, from, tokenId); + } + + (uint256 transferFee, uint256 requiredFee) = + _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); + if (requiredFee != 0) { + require(msg.value >= requiredFee, TransferFeeRequired(tokenId, to, requiredFee)); + } + + // Deposits follow the NFT, not the depositor: every transfer that moves a name off the + // prior position recipient rebinds the escrow position to the new holder so the locked + // deposit (when funded) and the lifecycle marker (when zero-amount) both travel with the + // name. Escrow-touching transfers are excluded because the escrow is mid-call and its + // non-reentrancy guard would reject a re-entry; release/reclaim manage the position + // directly. + bool positionSyncNeeded; + if (!isEscrowTouching) { + IDotnsNameEscrow.ReleasePosition memory position = + IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId); + positionSyncNeeded = position.recipient != address(0) && to != position.recipient; + } + + if (requiredFee == 0 && msg.value == 0 && !positionSyncNeeded) { + return from; + } + + IDotnsNameEscrow(payable(escrow)).chargeTransferFee{value: msg.value}( + IDotnsNameEscrow.ChargeTransferFeeParams({ + tokenId: tokenId, transferFee: transferFee, payer: msg.sender, to: to + }) + ); + + return from; + } + + /// @notice Mirrors the sender's label entry into the recipient's `LabelStore`. + function _syncRecipientStore( + IStoreFactory factory, + address to, + address from, + uint256 tokenId + ) + internal + { + string memory fullName = _readLabelFor(factory, tokenId, from); + if (bytes(fullName).length == 0) { + // Defensive: the sender holds no label entry for the token. Gateway mints reach this + // only at mint time, and a gateway name is soulbound so it never transfers; a public + // name always carries a label. Nothing to mirror, so do not deploy a recipient store; + // downstream writes are demand-deploy through `StoreUtils.ensureLabelStore`. + return; + } + // A slot holding a different string was written by someone else, and `storeLabel` has no + // delete, so mirroring nothing would hand over a name `_quoteTransferFeeFor` rejects on + // every onward transfer. A matching entry is still a no-op, so a transfer back to a prior + // owner passes. + factory.writeNewLabel(to, bytes32(tokenId), fullName); + } + + /// @notice Reads the full name (`label.tld`) for `tokenId` from `holder`'s `LabelStore` using + /// a caller-supplied factory. + function _readLabelFor( + IStoreFactory factory, + uint256 tokenId, + address holder + ) + private + view + returns (string memory fullName) + { + address store = factory.getLabelStore(holder); + if (store == address(0)) return ""; + return ILabelStore(store).getLabel(bytes32(tokenId)); + } + + /// @notice Reads the full name for `tokenId` from `holder`'s `LabelStore` via fresh lookups. + /// @dev Used by external view functions where caching the factory is not yet established; + /// the hot transfer path uses @custom:function _readLabelFor with a cached factory. + function _readLabel( + uint256 tokenId, + address holder + ) + private + view + returns (string memory fullName) + { + return _readLabelFor(_storeFactory(), tokenId, holder); + } + + /// @notice Resolves the configured name escrow address from the protocol registry. + function _escrow() private view returns (address escrow) { + escrow = protocolRegistry.get(DotnsConstants.NAME_ESCROW); + } + + /// @notice Resolves the configured PoP rules contract from the protocol registry. + function _popRules() private view returns (IPopRulesOld rules) { + rules = IPopRulesOld(protocolRegistry.get(DotnsConstants.POP_RULES)); + } + + /// @notice Resolves the configured store factory from the protocol registry. + function _storeFactory() private view returns (IStoreFactory factory) { + factory = IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + } + + /// @notice Writes the canonical full name into `owner`'s `LabelStore` keyed by + /// `bytes32(tokenId)`. + /// @dev Caller (@custom:function register) is responsible for short-circuiting on empty label; + /// the factory is a protocol-critical dependency and is assumed non-zero (a zero return from + /// the registry would have already broken every other call site). + function _writeOwnerLabel(address owner, uint256 tokenId, string calldata label) private { + _storeFactory() + .writeNewLabel(owner, bytes32(tokenId), string.concat(label, protocolRegistry.tld())); + } + + /// @notice Quotes the friction fee required for a transfer. + /// @dev Required fee is the name's own price returned by @custom:function + /// PopRulesOld.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( + address from, + address to, + uint256 tokenId + ) + private + view + returns (address escrow, uint256 transferFee, uint256 requiredFee) + { + if (from == to) return (address(0), 0, 0); + + IDotnsProtocolRegistry registry = protocolRegistry; + escrow = registry.get(DotnsConstants.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + + bool isEscrowTouching = to == escrow || from == escrow; + IStoreFactory factory = IStoreFactory(registry.get(DotnsConstants.STORE_FACTORY)); + (transferFee, requiredFee) = + _quoteTransferFeeFor(registry, factory, isEscrowTouching, from, to, tokenId); + } + + /// @notice Quotes the transfer floor reusing a caller-cached registry and store factory. + /// @dev Hot-path variant used by @custom:function _update. Returns `(0, 0)` for any + /// escrow-touching move or when the sender holds no label entry; otherwise reads the canonical + /// label and delegates to @custom:function PopRulesOld.transferFloor. + function _quoteTransferFeeFor( + IDotnsProtocolRegistry registry, + IStoreFactory factory, + bool isEscrowTouching, + address from, + address to, + uint256 tokenId + ) + private + view + returns (uint256 transferFee, uint256 requiredFee) + { + if (isEscrowTouching) return (0, 0); + + string memory fullName = _readLabelFor(factory, tokenId, from); + // No label means there is no label-derived price to charge against; treat as a zero-fee + // move. This is defensive: a gateway name is soulbound and reverts before reaching here, + // and a public name always carries a label, so no reachable transfer hits this branch. + if (bytes(fullName).length == 0) return (0, 0); + // A stored full name always carries the registry TLD suffix, so an empty strip means the + // name is malformed for this registry (a wrong or missing suffix); fail loudly rather than + // mis-pricing the move as zero-fee. + string memory label = LabelUtils.stripTld(registry.tld(), fullName); + require(bytes(label).length != 0, InvalidLabel()); + + transferFee = + IPopRulesOld(registry.get(DotnsConstants.POP_RULES)).transferFloor(label, from, to); + requiredFee = transferFee; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/IDotnsPopControllerOld.sol b/contracts/registrars/IDotnsPopControllerOld.sol new file mode 100644 index 000000000..418ff0ea9 --- /dev/null +++ b/contracts/registrars/IDotnsPopControllerOld.sol @@ -0,0 +1,528 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsController} from "./IDotnsController.sol"; + +/// @title IDotnsPopControllerOld +/// @notice Interface for the dedicated PoP controller orchestrating lite-person and full-person +/// username issuance on behalf of the PoP gateway. +/// @dev Deliberately disjoint from @custom:contract IDotnsRegistrarController. The two +/// controllers coexist on @custom:contract DotnsRegistrarOld via its multi-controller affordance +/// and neither imports the other. A full-person username collides through the registrar's ERC721 +/// availability check (first-to-mint wins); a lite username is not a token, so it collides through +/// @custom:function IDotnsRegistry.recordExists at its stem-under-container node +/// (@custom:reverts LiteNameAlreadyIssued). Reservation queuing for `reservedBaseLabel` +/// mirrors its live head into PopRulesOld, so a queued stem also blocks the public +/// commit-reveal flow, which reads that slot when it prices a name. +/// +/// Label formats: +/// Lite-person usernames (first argument to @custom:function reserveBaseName and the +/// `liteLabel` of a `LinkKind.LiteUsername` link) are a stem of lowercase ASCII letters, a +/// separator, then exactly two digits (e.g. `joseph.42`) per +/// @custom:function StringUtilsOld.isLitePersonLabel. The stem is stricter than a DNS label +/// because the name a person chooses is restricted to letters; a stem short enough to +/// be governance-reserved is rejected by classification, not by the shape. The label is stored in +/// the form the gateway sends, which is the canonical form of the name, so nothing here +/// normalises it. +/// Full-person usernames (the `label` of @custom:function registerBaseName and the +/// optional `reservedBaseLabel` of @custom:function reserveBaseName) are lowercase ASCII +/// letters only, per @custom:function StringUtilsOld.isPersonLabel (e.g. `alice`). That is the +/// same rule a lite stem follows and is stricter than a DNS label: no hyphens and no interior +/// digits, because a full-person name is also a name a person chose. A separator marks a lite +/// label and is rejected everywhere else, so only the gateway can create a dotted name; a +/// digit suffix on its own is not exclusive, since a public label may carry one directly. +/// Cross-flow priority on the base stem is arbitrated by +/// @custom:function IPopRulesOld.reserveBaseNameForPop. +/// @custom:security-contact admin@parity.io +interface IDotnsPopControllerOld is IDotnsController { + /// @notice Discriminant for the `Link` union supplied to `registerBaseName`. + /// @dev Selects the chat-key source for the full-person username. Orthogonal to whether + /// the registration is a claim or standalone; that is derived from on-chain reservation + /// state. `None` means the caller supplies a fresh chat key in `link.chatKey`. + /// `LiteUsername` means the full-person username is linked to a prior lite-person + /// username (`link.liteLabel`) and inherits its chat key. + enum LinkKind { + None, + LiteUsername + } + + /// @notice Tagged union selecting the chat-key source for a full-person registration. + /// @param liteLabel Lite-person `stem.NN` label (only read when `kind == LiteUsername`). + /// @param chatKey Chat key bytes (only read when `kind == None`). + struct Link { + LinkKind kind; + string liteLabel; + bytes chatKey; + } + + /// @notice Per-user reservation pointer: which queue the user sits in and where. + /// @param labelhash Non-zero when the user holds a live reservation; zero otherwise. + /// @param index Monotonic queue index, meaningful only when `labelhash` is non-zero. + struct UserReservation { + bytes32 labelhash; + uint64 index; + } + + /// @notice Reservation queue entry: a user and the timestamp they joined the queue. + /// @dev Packs into a single storage slot (20 + 8 bytes). + struct ReservationEntry { + address owner; + uint64 joinedAt; + } + + /// @notice Metadata describing the occupied range of a reservation queue. + /// @dev Uses monotonically increasing indices. Active entries occupy `[head, tail)`; + /// `length = tail - head`. Slots past `head` are deleted as the head advances so + /// garbage never accumulates. + struct ReservationQueueMeta { + uint64 head; + uint64 tail; + } + + /// @notice Deferred per-user binding of a freshly minted name to its `LabelStore`. + /// @dev Recorded by the gateway path when the user has no `LabelStore`. The binding later + /// settles via @custom:function settlePendingClaims, which deploys the store from a signed + /// origin and writes the stashed label. PoP-resolver records (chat key, lite link) are + /// persisted eagerly at mint time on @custom:contract IDotnsPopResolverOld, not at settlement, + /// so the resolver carries the full identity record regardless of whether the user has + /// settled their Store. A user accumulates one entry per deferred name: the Root gateway path + /// cannot deploy a `LabelStore` (contract creation is forbidden from the Root origin), so it + /// keeps stashing entries until a signed-origin @custom:function settlePendingClaims deploys + /// the store and settles the entries. Each entry's deadline is measured from its own + /// `mintedAt` against `reservationDuration`. + /// @param label Bare label without the TLD, which is appended at settlement time. A lite + /// claim carries its separator, so this is not always a single DNS label. + /// @param mintedAt Timestamp of the originating mint. + struct PendingClaim { + string label; + uint64 mintedAt; + } + + /// @notice Lite-person registration payload. + /// @dev Single struct so the gateway can ABI-encode one tuple as the cross-chain payload + /// and the contract decodes it directly out of `msg.data`. All fields are required; + /// `chatKey` may be empty bytes to skip the resolver write. + /// @param liteLabel Lite-person `stem.NN` label being minted. + /// @param user Beneficiary account on this chain. + /// @param chatKey Chat-key bytes persisted on the PoP resolver. Empty leaves the slot unset. + struct LiteRegistration { + string liteLabel; + address user; + bytes chatKey; + } + + /// @notice Lite-person registration combined with an optional base-name reservation. + /// @dev `BaseReservation` is a @custom:struct LiteRegistration plus a base-label reservation + /// slot, expressed as composition rather than duplicated fields so internal helpers can + /// consume the lite leg via `params.lite` without unpacking. The lite leg always runs; + /// the reservation leg only runs when `reservedBaseLabel` is non-empty. + /// @param lite Lite-person registration request; see LiteRegistration. + /// @param reservedBaseLabel Base label to enqueue for a later full-person claim. Empty + /// string skips the reservation leg. + struct BaseReservation { + LiteRegistration lite; + string reservedBaseLabel; + } + + /// @notice Base-name reservation payload for the split gateway flow. + /// @dev This is the reservation-only primitive. The lite username mint is handled by + /// @custom:function reserveLiteName, and LabelStore settlement is handled by + /// @custom:function settlePendingClaims. + /// @param user Beneficiary account that will hold the reservation. + /// @param reservedBaseLabel Base label to enqueue for a later full-person claim. + struct BaseNameReservation { + address user; + string reservedBaseLabel; + } + + /// @notice Full-person registration payload. + /// @param label Base DNS label being minted. + /// @param user Beneficiary account on this chain. + /// @param link Chat-key source for the new entry; see @custom:struct Link. + struct FullRegistration { + string label; + address user; + Link link; + } + + /// @notice Emitted when a lite-person username is registered via the PoP gateway. + event LiteNameReserved(bytes32 indexed labelhash, address indexed user, string label); + + /// @notice Emitted when a full-person username is claimed out of an existing reservation. + event BaseNameClaimed(bytes32 indexed labelhash, address indexed user, string label); + + /// @notice Emitted when a standalone full-person username is registered via the PoP gateway. + event StandaloneNameRegistered(bytes32 indexed labelhash, address indexed user, string label); + + /// @notice Emitted when a reservation entry is added to the queue for a base name. + /// @param position Position in the queue at the time of joining (0 = active holder). + event ReservationQueued( + bytes32 indexed reservedLabelhash, address indexed user, uint64 position + ); + + /// @notice Emitted when a reservation entry is removed due to expiry. + event ReservationExpired(bytes32 indexed reservedLabelhash, address indexed user); + + /// @notice Emitted when a user voluntarily relinquishes their reservation. + event ReservationRelinquished(bytes32 indexed reservedLabelhash, address indexed user); + + /// @notice Emitted when a full-person username is linked to a lite-person username. + event LiteToFullLinked(bytes32 indexed fullLabelhash, bytes32 indexed liteLabelhash); + + /// @notice Emitted when the reservation duration is updated. + event ReservationDurationSet(uint64 duration); + + /// @notice Emitted when a name is successfully registered via the PoP controller. + /// @param store The Store instance used to persist the immutable registration record. + event NameRegistered( + string indexed label, bytes32 indexed labelhash, address indexed owner, address store + ); + + /// @notice Emitted when a gateway-path mint defers its `LabelStore` write into the + /// pending-claim mapping because the user has no store yet. + event PendingClaimStashed(address indexed user, bytes32 indexed labelhash, string label); + + /// @notice Emitted when a pending claim is written into a `LabelStore`. + /// @dev Fires once per settled entry from @custom:function settlePendingClaims. `settledBy` + /// is the caller: it equals `user` for a self-settlement and is any other address for a + /// third-party settlement, so consumers can tell the two apart from the log alone. + /// @param user Account the settled name belongs to. + /// @param labelhash Labelhash of the settled name. + /// @param store The `LabelStore` the label was written into. + /// @param settledBy Caller that performed and paid for the settlement. + event PendingClaimSettled( + address indexed user, bytes32 indexed labelhash, address store, address indexed settledBy + ); + + /// @notice Emitted when a reservation queue's head transitions to a new user, either via + /// expiry of the prior head or via the explicit relinquish path. + /// @param labelhash Base-label hash whose queue head changed. + /// @param newHead Address now holding the head slot. + event ReservationHeadAdvanced(bytes32 indexed labelhash, address indexed newHead); + + /// @notice Thrown when a gated entrypoint is reached without a Root origin. + /// @dev Carries no caller parameter: a Root origin has no account to report, + /// and reading `msg.sender` under one traps. + error NotRoot(); + + /// @notice Thrown when a supplied lite-person label does not match `stem.NN`. + error InvalidLiteLabel(); + + /// @notice Thrown when a supplied base label is not a canonical DNS label. + error InvalidBaseLabel(); + + /// @notice Thrown when a reserved base label already has an owner on the registrar, so the + /// queued reservation could never be redeemed at mint time. + error BaseNameAlreadyRegistered(); + + /// @notice Thrown when a lite username is issued again while its subname already exists. + /// @dev A lite name is issued once; re-issuing it would rehome the identity to a new owner and + /// overwrite its records, so an existing subname is rejected rather than reassigned. + error LiteNameAlreadyIssued(); + + /// @notice Thrown when a supplied chat key is non-empty and not exactly 65 bytes long. + /// @dev Mirrors the resolver's `InvalidChatKeyLength` so the controller surfaces a + /// controller-local error before the mint runs. + /// @param length Caller-supplied chat key length, in bytes. + error InvalidChatKey(uint256 length); + + /// @notice Thrown when a user tries to claim or relinquish a reservation that they do not hold. + error NoActiveReservation(address user); + + /// @notice Thrown when a reservation queue has reached its capacity. + error QueueFull(bytes32 labelhash); + + /// @notice Thrown when attempting to enqueue a user who already has an active reservation. + error AlreadyReserved(address user, bytes32 labelhash); + + /// @notice Thrown when someone tries to mint a base label in standalone mode while another user + /// holds the live head-of-queue reservation. + error NotHolder(address user, bytes32 labelhash); + + /// @notice Thrown when a lite-link inheritance does not match the registrar-side owner + /// of the lite label. + /// @dev Prevents identity hijack by ensuring the registrant on the full-name leg actually + /// holds the prior lite identity whose chat key is being inherited. + /// @param user Registrant supplied by the gateway. + /// @param liteLabelhash Lite label whose ownership did not match. + error LiteLabelNotOwnedByUser(address user, bytes32 liteLabelhash); + + /// @notice Thrown when @custom:function setReservationDuration is called with a value below + /// the protocol minimum. + /// @param duration Caller-supplied duration, in seconds. + error ReservationDurationTooLow(uint64 duration); + + /// @notice Registers a lite-person username on behalf of the supplied user + /// and optionally enqueues a reservation for a base name they intend to + /// claim as a full person later. + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). The + /// lite leg validates the `stem.NN` shape and requires the label to classify outside the + /// governance-reserved tier (otherwise @custom:reverts InvalidLiteLabel), and rejects a + /// supplied chat key whose length is neither zero nor `CHAT_KEY_LENGTH` + /// (otherwise @custom:reverts InvalidChatKey). On a warm-path mint (user already has a + /// `LabelStore`) it @custom:emits LiteNameReserved and @custom:emits NameRegistered; + /// on a cold-path mint it @custom:emits LiteNameReserved and + /// @custom:emits PendingClaimStashed, with @custom:emits NameRegistered deferred to + /// @custom:function settlePendingClaims when the claim settles. The base-name leg only runs + /// when `reservedBaseLabel` is non-empty: it requires a letters-only person label, which is + /// therefore also a true base label (otherwise @custom:reverts InvalidBaseLabel), and + /// with no owner on the registrar (otherwise @custom:reverts BaseNameAlreadyRegistered), + /// since a name that already has an owner could never be claimed. This validation runs + /// before both the lite mint and any queue mutation, so an already-registered + /// `reservedBaseLabel` aborts the whole call and the candidate receives no lite username + /// either; callers should validate the reserved label before attesting rather than relying + /// on this revert. It then advances the + /// head past expired entries (@custom:emits ReservationExpired for each one), + /// removes the user from any prior queue position so a single user holds at most one live + /// reservation across all labels, and enqueues a fresh entry + /// (@custom:emits ReservationQueued). The enqueue rejects with @custom:reverts + /// AlreadyReserved when the user already holds a reservation that was not cleared by the + /// prior removal and with @custom:reverts QueueFull when the per-label queue has reached + /// `MAX_RESERVATION_QUEUE`. Cross-chain callers pass the ABI-encoded reservation tuple as + /// the call's payload, which Solidity decodes directly. + /// @param params Reservation request; see @custom:struct BaseReservation. + function reserveBaseName(BaseReservation calldata params) external; + + /// @notice Enqueues only the full/base-name reservation for a user. + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). + /// This is the second step of the split + /// gateway flow: @custom:function reserveLiteName mints the lite username first, then this + /// function reserves the full/base label in a separate transaction so proof-size stays below + /// per-call limits. Reverts with @custom:reverts InvalidBaseLabel when the label is empty, + /// is not lowercase ASCII letters (so a hyphen or any digit rejects it), or is + /// governance-reserved, and with + /// @custom:reverts BaseNameAlreadyRegistered when the label already has an owner on the + /// registrar and so could never be claimed. The caller remains agnostic about + /// backend batching; it simply exposes a small retryable primitive. + /// @param params Reservation request; see @custom:struct BaseNameReservation. + function reserveBaseNameOnly(BaseNameReservation calldata params) external; + + /// @notice Registers a lite-person username on behalf of the supplied + /// user without touching the base-name reservation queue. + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). The + /// supplied label must satisfy the `stem.NN` shape and must classify outside the + /// governance-reserved tier (otherwise @custom:reverts InvalidLiteLabel); a supplied chat + /// key whose length is neither zero nor `CHAT_KEY_LENGTH` reverts + /// @custom:reverts InvalidChatKey before mint and resolver writes run. A username that has + /// already been issued reverts @custom:reverts LiteNameAlreadyIssued. On a warm-path mint + /// @custom:emits LiteNameReserved and @custom:emits NameRegistered. On a cold-path + /// mint @custom:emits LiteNameReserved and @custom:emits PendingClaimStashed, with + /// @custom:emits NameRegistered deferred to @custom:function settlePendingClaims when the + /// claim settles. Cross-chain callers pass the ABI-encoded lite-registration tuple as the + /// call's payload, which Solidity decodes directly. + /// @param params Registration request; see @custom:struct LiteRegistration. + function reserveLiteName(LiteRegistration calldata params) external; + + /// @notice Whether this controller issued `label` as a PoP identity. + /// @dev Keyed by text, so it answers about a name rather than about a node. A lite label is + /// issued as a subname (`joseph` beneath its numeric container `42`) and a full-person label as + /// a second-level name, so a caller holding a node must check that the node is the one `label` + /// resolves to under those rules before reading this answer as being about what it holds; node + /// identity is what names the object. Set at mint and never cleared, so it is unaffected by a + /// name later becoming transferable; the soulbound flag is a transfer rule and cannot stand in + /// for it. + /// @param label Bare label without the TLD, for example `joseph.42`. + /// @return issued True when this controller issued `label`. + function isPopIssued(string calldata label) external view returns (bool issued); + + /// @notice Registers a full-person username on behalf of the supplied user. + /// @dev Callable only under a Root origin (otherwise @custom:reverts NotRoot). The + /// base label must be a letters-only person label, and therefore a true base label, + /// (otherwise @custom:reverts InvalidBaseLabel), and the label must not + /// classify as governance-reserved (otherwise @custom:reverts InvalidBaseLabel). The + /// gateway also defers to PopRulesOld as the single cross-flow authority: when PopRulesOld + /// carries a live base-name slot held by another user (this controller's prior queue head, + /// or a sibling controller's write), the call reverts @custom:reverts NotHolder before any + /// queue mutation. Two orthogonal axes drive the state machine. The reservation + /// axis treats the user as claiming if and only if they hold the live head-of-queue + /// reservation on the base label: a claim wipes the entire queue, releases the PopRulesOld + /// slot, and @custom:emits BaseNameClaimed; a non-claim silently relinquishes any + /// pending entry the user holds and @custom:emits StandaloneNameRegistered. Advancing + /// the queue head past expired entries @custom:emits ReservationExpired for each + /// one. The chat-key axis selects whether a fresh key is persisted on the resolver or the + /// new entry inherits its key from a prior lite-person username. The fresh-key branch + /// rejects a chat key whose length is neither zero nor `CHAT_KEY_LENGTH` (otherwise + /// @custom:reverts InvalidChatKey). The `LiteUsername` branch validates the lite label's + /// `stem.NN` shape (otherwise @custom:reverts InvalidLiteLabel), requires the registrant to + /// own the lite token (otherwise @custom:reverts LiteLabelNotOwnedByUser), reads the lite + /// node's chat key from the resolver and copies it across; if the lite node carries no chat + /// key the inherited value is empty and the full node's chat-key write is silently skipped + /// (the `LiteToFullLinked` event still fires). @custom:emits LiteToFullLinked + /// alongside the registration event. On a warm-path mint the event order is + /// @custom:emits NameRegistered first (from the inner mint), then + /// @custom:emits BaseNameClaimed or @custom:emits StandaloneNameRegistered, then + /// @custom:emits LiteToFullLinked when applicable. On a cold-path mint + /// @custom:emits PendingClaimStashed replaces the initial @custom:emits NameRegistered; + /// the deferred @custom:emits NameRegistered fires later from @custom:function + /// settlePendingClaims. Cross-chain callers pass the ABI-encoded full-registration tuple as + /// the call's payload, which Solidity decodes directly. + /// @param params Registration request; see @custom:struct FullRegistration. + function registerBaseName(FullRegistration calldata params) external; + + /// @notice Permissionlessly removes expired entries from the head of a reservation queue. + /// @dev Permissionless on purpose: anyone (typically a UI or a bot) can poke a stale queue + /// so the next live head takes over without waiting for the next gateway call. Validates + /// `reservedBaseLabel` as a letters-only person label (otherwise + /// @custom:reverts InvalidBaseLabel) + /// and @custom:emits ReservationExpired for every expired entry reaped from the + /// head. A label carrying a digit or a hyphen is not a person label and + /// @custom:reverts InvalidBaseLabel, as does a lite label, since a separator is not one + /// either. Only a letters-only label reaches the queue, and one that was never reserved + /// resolves to an empty queue so the call is a no-op. + function expireReservation(string calldata reservedBaseLabel) external; + + /// @notice Lets the caller voluntarily drop their own active reservation. + /// @dev Reverts with @custom:reverts NoActiveReservation when the caller holds no live + /// reservation. On success the caller's entry is removed from its queue and + /// @custom:emits ReservationRelinquished is emitted; if the removed entry was the queue + /// head, head advancement may additionally @custom:emits ReservationExpired for any + /// stale entries reaped behind it. + function relinquishReservation() external; + + /// @notice Returns whether a label currently has a live reservation at the queue head. + /// @dev Validates `reservedBaseLabel` as a letters-only person label (otherwise + /// @custom:reverts InvalidBaseLabel) before inspecting the queue. + function isReservedForClaim(string calldata reservedBaseLabel) + external + view + returns (bool reserved, address holder); + + /// @notice Updates the reservation duration used to decide when queue entries expire. + /// @dev Owner-gated (otherwise @custom:reverts OwnableUnauthorizedAccount); emits + /// @custom:emits ReservationDurationSet on success. + function setReservationDuration(uint64 duration) external; + + /// @notice Returns the queue metadata (`head`, `tail`) for `labelhash`. + /// @dev Read-only accessor over the per-label reservation queue. `head == tail` means + /// the queue is empty; active entries occupy `[head, tail)`. Exposed on the interface + /// because invariant tests and off-chain consumers use it to enumerate + /// live queue state without scanning storage. + /// @param labelhash Keccak-256 of the base label whose queue is being read. + /// @return head Index of the live queue head. + /// @return tail Index one past the last queued entry. + function reservationMeta(bytes32 labelhash) external view returns (uint64 head, uint64 tail); + + /// @notice Returns the queue entry at `index` for `labelhash`. + /// @dev Sparse storage: a zero `entryOwner` means the slot was relinquished, expired and + /// reaped, or never written. Callers pair this with @custom:function reservationMeta to walk + /// the live window `[head, tail)`. + /// @param labelhash Keccak-256 of the base label whose queue is being read. + /// @param index Queue index to look up. + /// @return entryOwner Owner of the slot (zero if empty/relinquished). + /// @return joinedAt Timestamp the entry was enqueued (only meaningful when + /// `entryOwner != address(0)`). + function reservationEntry( + bytes32 labelhash, + uint64 index + ) + external + view + returns (address entryOwner, uint64 joinedAt); + + /// @notice Returns `user`'s current reservation pointer. + /// @dev A zero `labelhash` on the returned struct means the user holds no reservation; + /// `index` is meaningful only when `labelhash` is non-zero. + /// @param user Account whose reservation pointer is being read. + /// @return reservation Per-user reservation pointer; see @custom:struct UserReservation. + function userReservation(address user) + external + view + returns (UserReservation memory reservation); + + /// @notice Returns the base label a reservation queue is keyed under. + /// @dev Reverse lookup from the `bytes32` queue key to its label string, so a consumer that + /// observed a queue by labelhash (for example from a reservation event) can recover the + /// human-readable label without holding its preimage. Returns an empty string when no + /// reservation was ever enqueued under `labelhash`. + /// @param labelhash Keccak-256 of the base label. + /// @return baseLabel The base label string, or empty when unknown. + function reservedBaseLabelOf(bytes32 labelhash) external view returns (string memory baseLabel); + + /// @notice Returns the window, in seconds, after which a queue or pending-claim entry lapses. + /// @dev Governance-configurable via @custom:function setReservationDuration. Read by the lens + /// to compute each pending claim's settlement deadline. + /// @return duration Reservation duration in seconds. + function reservationDuration() external view returns (uint64 duration); + + /// @notice Settles up to `limit` of a user's pending claims, writing each stashed label into + /// the user's `LabelStore` and deploying that store when the user has none yet. + /// @dev Permissionless: any caller may settle any user's claims and bears the full cost, + /// including the `LabelStore` storage deposit, which is charged to the + /// transaction signer. Settlement is never destructive: the name is already minted, so this + /// only completes the deferred label write. Each settled entry is removed from the queue and + /// the user leaves the pending-claim enumeration set once their queue empties. At most + /// `limit` entries are processed so a large queue cannot exceed the block gas limit; + /// `moreRemaining` reports whether entries are left for a follow-up call, and a `limit` of + /// zero settles nothing. Writes are idempotent on an already-locked store slot, so a claim + /// whose label was independently written settles harmlessly. Emits + /// @custom:emits PendingClaimSettled and @custom:emits NameRegistered per settled entry, with + /// `settledBy` set to the caller so a third-party settlement is distinguishable from a + /// self-settlement. + /// @param user Account whose pending claims are settled. + /// @param limit Maximum number of entries to settle in this call. + /// @return settledCount Number of entries settled. + /// @return moreRemaining Whether the user still holds unsettled entries. + function settlePendingClaims( + address user, + uint256 limit + ) + external + returns (uint256 settledCount, bool moreRemaining); + + /// @notice Settles the caller's own pending claims into their `LabelStore`. + /// @dev Convenience for a user settling their own store: equivalent to + /// @custom:function settlePendingClaims with `msg.sender` and a bounded batch. The caller + /// deploys and pays for their store on the first write. Settles at most one bounded batch so + /// the call cannot exceed the block gas limit; `moreRemaining` reports whether the caller + /// still holds unsettled entries, in which case they call again. Emits the same + /// @custom:emits PendingClaimSettled and @custom:emits NameRegistered as + /// @custom:function settlePendingClaims. + /// @return moreRemaining Whether the caller still holds unsettled entries. + function claimLabelStore() external returns (bool moreRemaining); + + /// @notice Returns a paginated slice of a user's pending claims in queue order. + /// @dev An empty array means the user has no pending claims at `offset`. Each entry carries + /// its `mintedAt`; the settlement deadline is `mintedAt + reservationDuration`. An `offset` + /// past the end returns an empty array rather than reverting, and a page holds at most + /// `DotnsConstants.MAX_PAGE_SIZE` entries. + /// @param user Account whose pending claims are read. + /// @param offset Start index into the queue. + /// @param limit Maximum entries to return. + /// @return claims Page of the user's pending claims; see @custom:struct PendingClaim. + function pendingClaims( + address user, + uint256 offset, + uint256 limit + ) + external + view + returns (PendingClaim[] memory claims); + + /// @notice Returns the number of pending claims currently staged for `user`. + /// @param user Account whose pending claims are counted. + /// @return count Number of staged pending claims. + function pendingClaimCountOf(address user) external view returns (uint256 count); + + /// @notice Returns the number of users with at least one live pending claim. + /// @dev Exact live count, not an all-time tally: fully settled users are removed from the + /// enumeration set so off-chain consumers can page through every stalled user without + /// filtering. + /// @return count Number of users currently holding a pending claim. + function pendingClaimUserCount() external view returns (uint256 count); + + /// @notice Returns a paginated slice of users with at least one live pending claim. + /// @dev Pair with @custom:function pendingClaims to read each user's stashed entries. + /// Ordering is not chronological; callers MUST NOT assume `mintedAt` is monotonic + /// across the slice. Returns an empty array when `offset` is past the live count, and a page + /// holds at most `DotnsConstants.MAX_PAGE_SIZE` entries. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return users Slice of users currently holding a pending claim. + function pendingClaimUsers( + uint256 offset, + uint256 limit + ) + external + view + returns (address[] memory users); +} diff --git a/contracts/registry/DotnsProtocolRegistry.sol b/contracts/registry/DotnsProtocolRegistry.sol index 248754e36..02ce506d5 100644 --- a/contracts/registry/DotnsProtocolRegistry.sol +++ b/contracts/registry/DotnsProtocolRegistry.sol @@ -50,7 +50,13 @@ contract DotnsProtocolRegistry is /// @notice Release tag the network was last declared to run, bare semver (e.g. `0.8.0`). string private _protocolVersion; - uint256[50] private __gap; + /// @dev Reserved storage space to allow for layout changes in future upgrades. The declaration + /// fields above consume two of the reserved slots, so the gap holds 48 and the contract + /// keeps the 54-slot footprint the deployed proxy already uses. Sized this way rather than + /// left at 50 because the gap would otherwise start two slots later than on chain, which + /// the storage-layout diff rejects and which would put any future appended field on a slot + /// the live proxy does not expect. + uint256[48] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { diff --git a/contracts/registry/DotnsRegistryOld.sol b/contracts/registry/DotnsRegistryOld.sol new file mode 100644 index 000000000..0d4d7fd5b --- /dev/null +++ b/contracts/registry/DotnsRegistryOld.sol @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {IDotnsRegistry} from "./IDotnsRegistry.sol"; +import {IDotnsController} from "../registrars/IDotnsController.sol"; +import {IDotnsRegistrar} from "../registrars/IDotnsRegistrar.sol"; +import {IStoreFactory} from "../store/IStoreFactory.sol"; +import {StoreUtils} from "../utils/StoreUtils.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {IDotnsProtocolRegistry} from "./IDotnsProtocolRegistry.sol"; +import {StringUtilsOld} from "../utils/StringUtilsOld.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title Dotns Registry +/// @author Parity +/// @notice Upgradeable on-chain registry for hierarchical name ownership and resolution. +/// @dev Tokenised second-level nodes store `owner == address(0)` as a sentinel and defer to +/// `IDotnsRegistrar.ownerOf`; subnodes carry an explicit owner address in `records`. +/// @custom:security-contact admin@parity.io +contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, IDotnsRegistry { + using StoreUtils for IStoreFactory; + using StringUtilsOld for *; + + /// @notice Mapping of node identifiers to records. + mapping(bytes32 node => Record record) private records; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistry public protocolRegistry; + + uint256[50] private __gap; + + /// @notice Restricts access to the current owner of `node`. + modifier authorised(bytes32 node) { + _authorised(node); + _; + } + + /// @notice Restricts access to the configured registrar controller. + modifier onlyRegistrarController() { + _onlyRegistrarController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the registry. + /// @dev Callable exactly once via `Initializable`, otherwise + /// @custom:reverts InvalidInitialization. `registry` must be non-zero, otherwise + /// @custom:reverts NotAllowed. + /// @param initialOwner Address that owns the contract once initialised. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize( + address initialOwner, + IDotnsProtocolRegistry registry + ) + external + initializer + { + __Ownable_init(initialOwner); + + require(address(registry) != address(0), NotAllowed()); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistry + function setSubnodeOwner(SubnodeRecord calldata record) + external + override + authorised(record.parentNode) + returns (bytes32 subnode) + { + address newOwner = record.owner; + require(newOwner != address(0), NotAllowed()); + + // Deferring the store write (persist == false) is a controller workflow: the deferred label + // is backfilled later by an authorised writer, and only a registered controller drives that + // path. Every other caller must persist eagerly rather than strand an unindexed label. + if (!record.persist) { + _onlyRegistrarController(); + } + + bytes32 parentNode = record.parentNode; + string calldata subLabel = record.subLabel; + string calldata parentLabel = record.parentLabel; + require(subLabel.isSingleLabel(), InvalidLabel()); + require(parentLabel.isNamePath(), ParentLabelMismatch()); + require(_parentNamehash(parentLabel) == parentNode, ParentLabelMismatch()); + + bytes32 labelhash = LabelUtils.labelhash(subLabel); + subnode = LabelUtils.namehashUnder(parentNode, labelhash); + + Record storage existing = records[subnode]; + address reverseResolver = protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER); + if (existing.exists) { + address previousOwner = existing.owner; + // Reset the resolver pointer on reassignment so the prior subnode owner's resolver + // (and any malicious wiring they staged before losing the subnode) cannot be inherited + // by the new holder. Records keyed by `subnode` on other resolver contracts are not + // wiped here; consumers should gate reads on current ownership. Skip the resolver + // write when it already matches the default to avoid a redundant SSTORE on no-op + // refresh paths. + existing.owner = newOwner; + if (existing.resolver != reverseResolver) { + existing.resolver = reverseResolver; + emit NewResolver(subnode, reverseResolver); + } + + if (record.persist && newOwner != previousOwner) { + string memory fullName = + string.concat(subLabel, ".", parentLabel, protocolRegistry.tld()); + _writeSubnodeToStore(newOwner, subnode, fullName); + } + } else { + records[subnode] = Record({owner: newOwner, resolver: reverseResolver, exists: true}); + if (record.persist) { + string memory fullName = + string.concat(subLabel, ".", parentLabel, protocolRegistry.tld()); + _writeSubnodeToStore(newOwner, subnode, fullName); + } + } + + emit NewOwner(parentNode, labelhash, newOwner); + } + + /// @inheritdoc IDotnsRegistry + function setOwner(bytes32 node, address newOwner) external override onlyRegistrarController { + require(newOwner != address(0), NotAllowed()); + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.ownerOf(uint256(node)) == newOwner, NotAuthorised()); + + // The resolver pointer is reset to the default reverse resolver on every call to this + // function, which the controller drives on registration and on reclaim from escrow, so a + // prior owner's resolver cannot follow the name across that recycle. This does not cover a + // secondary-market ERC-721 `transferFrom`: that path does not call the registry, so a name + // sold directly carries the seller's resolver pointer until the buyer overwrites it. + // Owner remains the zero sentinel so reads delegate to the registrar's ERC-721 holder. + records[node] = Record({ + owner: address(0), + resolver: protocolRegistry.get(DotnsConstants.REVERSE_RESOLVER), + exists: true + }); + + emit NodeTransferred(node, newOwner); + } + + /// @inheritdoc IDotnsRegistry + function setResolver(bytes32 node, address newResolver) external override authorised(node) { + records[node].resolver = newResolver; + emit NewResolver(node, newResolver); + } + + /// @inheritdoc IDotnsRegistry + function setSubnodeResolver(SubnodeResolverRecord calldata record) + external + override + authorised(record.parentNode) + { + string calldata subLabel = record.subLabel; + string calldata parentLabel = record.parentLabel; + require(subLabel.isSingleLabel(), InvalidLabel()); + require(parentLabel.isNamePath(), ParentLabelMismatch()); + require(_parentNamehash(parentLabel) == record.parentNode, ParentLabelMismatch()); + + bytes32 subnode = + LabelUtils.namehashUnder(record.parentNode, LabelUtils.labelhash(subLabel)); + Record storage existing = records[subnode]; + require(existing.exists, NotAuthorised()); + + existing.resolver = record.resolver; + emit NewResolver(subnode, record.resolver); + } + + /// @inheritdoc IDotnsRegistry + function owner(bytes32 node) external view override returns (address) { + Record storage record = records[node]; + // Read `owner` first: a non-zero stored owner proves the record exists and is a subnode, + // collapsing the lookup to a single SLOAD. The slower `exists` SLOAD only runs on the + // tokenised-or-missing branch. + address storedOwner = record.owner; + if (storedOwner != address(0)) return storedOwner; + if (!record.exists) return address(0); + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + return registrar.ownerOf(uint256(node)); + } + + /// @inheritdoc IDotnsRegistry + function resolver(bytes32 node) external view override returns (address) { + return records[node].resolver; + } + + /// @inheritdoc IDotnsRegistry + function recordExists(bytes32 node) external view override returns (bool) { + return records[node].exists; + } + + /// @inheritdoc IDotnsRegistry + function isAuthorised( + bytes32 node, + address account + ) + external + view + override + returns (bool authorisedFlag) + { + authorisedFlag = _isAuthorised(node, account); + } + + /// @notice Writes subnode registration to the owner's `LabelStore`. + /// @dev Keys the entry by `node` (full namehash) rather than labelhash so a single store + /// lookup yields the canonical full name without re-walking the parent chain. + function _writeSubnodeToStore( + address storeOwner, + bytes32 node, + string memory fullName + ) + internal + { + IStoreFactory factory = IStoreFactory(protocolRegistry.get(DotnsConstants.STORE_FACTORY)); + factory.writeNewLabel(storeOwner, node, fullName); + } + + /// @notice Computes the namehash of `parentLabel` rooted at the network's TLD node. + /// @dev Walks the label right-to-left in calldata using memory-safe assembly to avoid the + /// cost of slicing into intermediate `bytes` and to keep gas linear in the label depth. + /// Reads the TLD node from the protocol registry, so it is a view rather than pure. + function _parentNamehash(string calldata parentLabel) internal view returns (bytes32 node) { + bytes calldata labels = bytes(parentLabel); + uint256 end = labels.length; + require(end != 0, ParentLabelMismatch()); + + node = protocolRegistry.tldNode(); + + while (true) { + uint256 start = end; + while (start > 0 && labels[start - 1] != bytes1(0x2e)) { + unchecked { + --start; + } + } + + require(start != end, ParentLabelMismatch()); + + bytes32 labelhash; + assembly ("memory-safe") { + let pointer := mload(0x40) + let len := sub(end, start) + calldatacopy(pointer, add(labels.offset, start), len) + labelhash := keccak256(pointer, len) + mstore(pointer, node) + mstore(add(pointer, 0x20), labelhash) + node := keccak256(pointer, 0x40) + } + + if (start == 0) return node; + end = start - 1; + } + } + + /// @notice Internal authorisation check for node ownership. + /// @dev Reverts with NotAuthorised when `msg.sender` is not authorised for `node`. + function _authorised(bytes32 node) internal view { + require(_isAuthorised(node, msg.sender), NotAuthorised()); + } + + /// @notice Canonical authorisation rule for a node, parameterised by `account`. + /// @dev Honours the sentinel-zero pattern: if the registry has no explicit owner, fall back + /// to the registrar's ERC-721 owner / approved / operator-for-all chain. This is the + /// single source of truth `_authorised` and `isAuthorised` both delegate to. + function _isAuthorised(bytes32 node, address account) internal view returns (bool) { + Record storage record = records[node]; + + // Read `owner` first: a non-zero stored owner means this is a subnode with an explicit + // owner and the existence flag is implied. Skipping the `exists` SLOAD on the common + // subnode path saves one slot read. + address storedOwner = record.owner; + if (storedOwner != address(0)) { + return storedOwner == account; + } + + if (!record.exists) return false; + + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + uint256 tokenId = uint256(node); + address tokenOwner = registrar.ownerOf(tokenId); + if (account == tokenOwner) return true; + // Operator-for-all is the common marketplace / escrow delegation path; check it before + // the single-token approval so the common case terminates on one STATICCALL. + if (registrar.isApprovedForAll(tokenOwner, account)) return true; + return registrar.getApproved(tokenId) == account; + } + + /// @notice Internal check for registrar-authorised controller privileges. + /// @dev The registry trusts every controller the registrar trusts. Routing controller + /// authorisation through the registrar's `controllers` mapping keeps the trust list + /// in one place and lets commit-reveal and PoP controllers coexist without registry + /// reconfiguration on each addition. + function _onlyRegistrarController() internal view { + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + require(registrar.controllers(IDotnsController(msg.sender)), NotAuthorised()); + } + + /// @notice Returns the release this network declares it runs, read live from the protocol + /// registry so every DotNS contract reports one synchronised value. + /// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical + /// `version()` selector for ABI compatibility. It reports the network's declaration, + /// not this contract's build; per-contract identity is the codehash declared on the + /// registry. + /// @return versionString Declared release as bare semver, empty when never declared. + function version() external view virtual returns (string memory versionString) { + versionString = protocolRegistry.protocolVersion(); + } + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/resolvers/DotnsPopResolverOld.sol b/contracts/resolvers/DotnsPopResolverOld.sol new file mode 100644 index 000000000..23c49c813 --- /dev/null +++ b/contracts/resolvers/DotnsPopResolverOld.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { + OwnableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; + +import {IDotnsPopResolverOld} from "./IDotnsPopResolverOld.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {DotnsConstants} from "../utils/DotnsConstants.sol"; + +/// @title DotnsPopResolverOld +/// @notice Per-node resolver holding records produced by the PoP username flow. +/// @dev Writes are gated on the protocol-registered `POP_CONTROLLER` rather +/// than on node ownership. PoP records are issued by the gateway as part +/// of identity issuance, not curated by the holder, so authority lives +/// with the controller and not the user. +/// @custom:security-contact admin@parity.io +contract DotnsPopResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsPopResolverOld +{ + /// @notice Protocol-level address registry used to resolve the authorised writer. + IDotnsProtocolRegistry public protocolRegistry; + + /// @notice Stored chat-key bytes keyed by node. + mapping(bytes32 node => bytes chatKey) private _chatKeys; + + /// @notice Stored lite-person labelhash keyed by full-person node. + /// @dev Forward direction (full => lite): maps a full-person node to the + /// labelhash of the lite username it was claimed from. + mapping(bytes32 fullNode => bytes32 liteLabelhash) private _liteLinks; + + /// @notice Reverse index mapping a lite labelhash to the full-person node + /// it was promoted to. + /// @dev Written alongside `_liteLinks` on every claim so consumers that look + /// up by lite username resolve the full name without scanning events. + /// Zero when the lite label has never been linked to a full claim. + mapping(bytes32 liteLabelhash => bytes32 fullNode) private _fullClaims; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts writes to the address registered as `POP_CONTROLLER`. + modifier onlyPopController() { + _onlyPopController(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the PoP resolver. + /// @dev Called once through the UUPS proxy; `_disableInitializers` on the implementation + /// makes direct calls revert and any repeat call on the proxy reverts with + /// @custom:reverts InvalidInitialization. The registry pointer is the only storage this + /// setup needs because the authorised writer is resolved dynamically through + /// `POP_CONTROLLER`. Emits @custom:emits OwnershipTransferred when `msg.sender` is + /// recorded as the initial owner and @custom:emits Initialized once setup completes. + /// @param initialOwner Address that owns the contract once initialised. + /// @param registry Protocol-level address registry used for writer resolution. + function initialize( + address initialOwner, + IDotnsProtocolRegistry registry + ) + external + initializer + { + __Ownable_init(initialOwner); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsPopResolverOld + function setChatKey( + bytes32 node, + bytes calldata chatKeyBytes + ) + external + override + onlyPopController + { + require(chatKeyBytes.length == 65, InvalidChatKeyLength(chatKeyBytes.length)); + _chatKeys[node] = chatKeyBytes; + emit ChatKeyUpdated(node, chatKeyBytes); + } + + /// @inheritdoc IDotnsPopResolverOld + function setLiteLink( + bytes32 fullNode, + bytes32 liteLabelhash + ) + external + override + onlyPopController + { + bytes32 oldLite = _liteLinks[fullNode]; + bytes32 oldFull = _fullClaims[liteLabelhash]; + if (oldLite != bytes32(0) && oldLite != liteLabelhash) { + delete _fullClaims[oldLite]; + } + if (oldFull != bytes32(0) && oldFull != fullNode) { + delete _liteLinks[oldFull]; + } + _liteLinks[fullNode] = liteLabelhash; + _fullClaims[liteLabelhash] = fullNode; + emit LiteLinkUpdated(fullNode, liteLabelhash); + } + + /// @inheritdoc IDotnsPopResolverOld + function chatKey(bytes32 node) external view override returns (bytes memory) { + return _chatKeys[node]; + } + + /// @inheritdoc IDotnsPopResolverOld + function liteLink(bytes32 fullNode) external view override returns (bytes32) { + return _liteLinks[fullNode]; + } + + /// @inheritdoc IDotnsPopResolverOld + function fullClaim(bytes32 liteLabelhash) external view override returns (bytes32) { + return _fullClaims[liteLabelhash]; + } + + /// @notice Returns the release this network declares it runs, read live from the protocol + /// registry so every DotNS contract reports one synchronised value. + /// @dev Mirror of `IDotnsProtocolRegistry.protocolVersion`, kept under the historical + /// `version()` selector for ABI compatibility. It reports the network's declaration, + /// not this contract's build; per-contract identity is the codehash declared on the + /// registry. + /// @return versionString Declared release as bare semver, empty when never declared. + function version() external view virtual returns (string memory versionString) { + versionString = protocolRegistry.protocolVersion(); + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return interfaceId == type(IDotnsPopResolverOld).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Internal check enforcing PoP-controller-only access. + function _onlyPopController() internal view { + address popController = protocolRegistry.get(DotnsConstants.POP_CONTROLLER); + require(msg.sender == popController, NotPopController(msg.sender)); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/resolvers/IDotnsPopResolverOld.sol b/contracts/resolvers/IDotnsPopResolverOld.sol new file mode 100644 index 000000000..f75aaa025 --- /dev/null +++ b/contracts/resolvers/IDotnsPopResolverOld.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsPopResolverOld +/// @notice Resolver for per-name records produced by the PoP username flow. +/// @dev Holds three record kinds: +/// - Chat key: ECDH public-key bytes used for end-to-end encrypted messaging. +/// - Lite link: for a full-person node, the labelhash of the lite-person +/// username it was minted from (when the link was made). +/// - Full claim: reverse index mapping a lite labelhash to the full-person +/// node it was promoted to. Mirrors `liteLink` on every write so a +/// caller that holds a lite labelhash can resolve the full-person node +/// without scanning events. +/// +/// Lives separately from the per-user `LabelStore` so that the store can remain +/// a labels-only, protocol-write / user-read surface, and follows the project's +/// resolver-per-record-category convention used by @custom:contract IDotnsContentResolver and +/// @custom:contract IDotnsReverseResolver. +/// +/// Write authorisation is delegated to the address registered as +/// `DotnsProtocolRegistry.POP_CONTROLLER` at call time, so rotating the PoP +/// controller is a single `set` on the protocol registry with no resolver +/// upgrade required. +/// @custom:security-contact admin@parity.io +interface IDotnsPopResolverOld { + /// @notice Emitted when a node's chat key is set or updated. + /// @param node The node whose chat key was written. + /// @param chatKey The new chat key bytes. + event ChatKeyUpdated(bytes32 indexed node, bytes chatKey); + + /// @notice Emitted when a full-person node's lite link is set or updated. + /// @param fullNode The full-person node carrying the link. + /// @param liteLabelhash The labelhash of the linked lite-person username. + event LiteLinkUpdated(bytes32 indexed fullNode, bytes32 indexed liteLabelhash); + + /// @notice Thrown when the caller is not the authorised PoP controller. + /// @param caller The address that attempted the write. + error NotPopController(address caller); + + /// @notice Thrown when the provided chat key does not match the expected 65-byte length. + /// @param length The length of the payload that was rejected. + error InvalidChatKeyLength(uint256 length); + + /// @notice Sets the chat key for `node`. + /// @dev Callable only by the address registered under `DotnsProtocolRegistry.POP_CONTROLLER`, + /// otherwise @custom:reverts NotPopController. Overwrites any previous value. The payload + /// must be exactly 65 bytes: the uncompressed secp256k1 public key encoding (1 prefix + /// byte followed by the 32-byte X and 32-byte Y affine coordinates); any other length + /// reverts with @custom:reverts InvalidChatKeyLength. Emits @custom:emits ChatKeyUpdated + /// on every successful write. + /// @param node The node whose chat key is being written. + /// @param chatKey ECDH public key bytes (pallet-side type is `[u8; 65]`). + function setChatKey(bytes32 node, bytes calldata chatKey) external; + + /// @notice Sets the lite-person link for a full-person `node`. + /// @dev Callable only by the authorised PoP controller, otherwise + /// @custom:reverts NotPopController. Overwrites any previous link. When overwriting, the + /// stale inverse entry is nulled so both the forward (`liteLink`) and reverse + /// (`fullClaim`) indices remain consistent: re-linking the same `fullNode` to a new + /// `liteLabelhash` clears `fullClaim(oldLite)`, and re-linking the same `liteLabelhash` + /// to a new `fullNode` clears `liteLink(oldFull)`. The invariant + /// `fullClaim(liteLink(node)) == node` always holds after the call. Emits + /// @custom:emits LiteLinkUpdated on every successful write. + /// @param fullNode The full-person node carrying the link. + /// @param liteLabelhash The labelhash of the linked lite-person username. + function setLiteLink(bytes32 fullNode, bytes32 liteLabelhash) external; + + /// @notice Returns the chat key associated with a node. + /// @param node The node to query. + /// @return chatKey The stored chat key bytes, or empty if unset. + function chatKey(bytes32 node) external view returns (bytes memory chatKey); + + /// @notice Returns the lite-person labelhash linked to a full-person node. + /// @param fullNode The full-person node to query. + /// @return liteLabelhash The linked lite-person labelhash, or zero if unset. + function liteLink(bytes32 fullNode) external view returns (bytes32 liteLabelhash); + + /// @notice Returns the full-person node a given lite label has claimed. + /// @dev Reverse of @custom:function liteLink. Written by the same `setLiteLink` call so the + /// two directions stay in lockstep. Returns zero when the lite label + /// has never been linked to a full claim. + /// @param liteLabelhash The labelhash of the lite-person username to query. + /// @return fullNode The full-person node claimed from this lite label, or + /// zero if unset. + function fullClaim(bytes32 liteLabelhash) external view returns (bytes32 fullNode); +} diff --git a/contracts/store/UserStore.sol b/contracts/store/UserStore.sol index 987ee689e..4a18f6bd1 100644 --- a/contracts/store/UserStore.sol +++ b/contracts/store/UserStore.sol @@ -42,8 +42,14 @@ contract UserStore is Initializable, IUserStore { address private _protocolRegistry; /// @dev Reserved storage space to allow for layout changes in future beacon upgrades. + /// `_protocolRegistry` consumes one of the reserved slots, so the gap holds 49 and the + /// contract keeps the footprint the stores behind the deployed beacon already use. + /// Sized this way rather than left at 50 because the gap would otherwise start one slot + /// later than on those stores, which makes a beacon rotation onto this implementation + /// put every later appended field on a slot they do not expect. `LabelStore` needs no + /// such change: it added no storage in this release. // forge-lint: disable-next-line(mixed-case-variable) - uint256[50] private __gap; + uint256[49] private __gap; /// @notice Restricts writes to the bound owner. modifier onlyOwner() { diff --git a/contracts/utils/StringUtilsOld.sol b/contracts/utils/StringUtilsOld.sol new file mode 100644 index 000000000..1ffafbe2e --- /dev/null +++ b/contracts/utils/StringUtilsOld.sol @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +/// @title String Utilities Library +/// @notice Provides string manipulation utilities for DotNS contracts. +/// @dev Extends OpenZeppelin's Strings library with additional UTF-8 and conversion helpers. +/// @custom:security-contact admin@parity.io +library StringUtilsOld { + using Strings for uint256; + using Strings for int256; + using Strings for address; + + /// @notice Number of digits in a lite-person PoP label's suffix. + /// @dev The count the gateway emits, and an exact requirement here: the separator sits at a + /// fixed offset from the end, so a one or three digit suffix is rejected. The gateway + /// reads its own minimum, so widening it there does not widen this. + uint256 internal constant LITE_SUFFIX_DIGITS = 2; + + /// @notice Maximum number of octets in a single DNS label. + /// @dev RFC 1035 caps each label at 63 octets. Enforced inside @custom:function _isDnsLabel so + /// every public validator (@custom:function isSingleLabel, @custom:function isNamePath, + /// @custom:function isLitePersonLabel) inherits the bound and oversized labels never + /// reach the registrar. A lite label bounds its stem rather than the whole string, so + /// it reaches `MAX_DNS_LABEL_OCTETS + LITE_SUFFIX_DIGITS + 1` octets. Its stem is + /// bounded here but checked in @custom:function _isLitePersonLabel, which is stricter + /// on charset than a DNS label: letters only. + uint256 internal constant MAX_DNS_LABEL_OCTETS = 63; + + /// @notice ASCII full stop separating a lite label's stem from its digit suffix. + /// @dev A lite label is the only label shape in DotNS that carries a separator; + /// @custom:function _isDnsLabel rejects it everywhere else. + bytes1 internal constant LABEL_SEPARATOR = 0x2e; + + /// @notice Computes the character length of a UTF-8 encoded string. + /// @dev Counts Unicode code points, not bytes. Handles multi-byte UTF-8 sequences: + /// - 1 byte: 0x00-0x7F (ASCII) + /// - 2 bytes: 0xC0-0xDF + /// - 3 bytes: 0xE0-0xEF + /// - 4 bytes: 0xF0-0xF7 + /// - 5 bytes: 0xF8-0xFB (rare, outside Unicode standard) + /// - 6 bytes: 0xFC-0xFD (rare, outside Unicode standard) + /// @param value The UTF-8 encoded string to measure. + /// @return len The number of Unicode characters in the string. + function strlen(string memory value) internal pure returns (uint256 len) { + uint256 i = 0; + uint256 bytelength = bytes(value).length; + for (len = 0; i < bytelength; len++) { + bytes1 byteValue = bytes(value)[i]; + if (byteValue < 0x80) i += 1; + else if (byteValue < 0xE0) i += 2; + else if (byteValue < 0xF0) i += 3; + else if (byteValue < 0xF8) i += 4; + else if (byteValue < 0xFC) i += 5; + else i += 6; + } + } + + /// @notice Validates that `s` is a single canonical DNS label. + /// @dev Lowercase ASCII letters, digits, and hyphen only; hyphen may not be the first or last + /// character; length must be in `(0, MAX_DNS_LABEL_OCTETS]`. No dots allowed; use + /// @custom:function isNamePath for dotted forms. Mirrors the label rules enforced at the + /// registrar. + /// @param value Candidate label. + /// @return isValid True if `value` is a canonical DNS label. + function isSingleLabel(string calldata value) internal pure returns (bool isValid) { + bytes memory label = bytes(value); + return _isDnsLabel(label, 0, label.length); + } + + /// @notice Memory-location helper for @custom:function isSingleLabel, used where the + /// candidate label is produced by an upstream string transformation (e.g. the output of + /// @custom:function stripDigits) so callers do not need a calldata round-trip. + /// @param value Candidate label held in memory. + /// @return isValid True if `value` is a canonical DNS label. + function isSingleLabelMemory(string memory value) internal pure returns (bool isValid) { + bytes memory label = bytes(value); + return _isDnsLabel(label, 0, label.length); + } + + /// @notice Validates the lite-person PoP label format: `.`. + /// @dev A lite-person label is a stem of lowercase ASCII letters, one + /// @custom:constant LABEL_SEPARATOR, then exactly + /// @custom:constant LITE_SUFFIX_DIGITS digits (e.g. `joseph.42`). Letters only, + /// because the stem is the name a person chose, which is restricted to letters. How short + /// a stem may be is policy rather than format, so it is left to the governance-reserved band in + /// @custom:function IPopRulesOld.classifyName. + /// It is the only label shape in DotNS permitted to carry a separator, which is what + /// reserves the dotted space to the gateway. A digit suffix is not exclusive: an + /// ordinary label may end in digits, but it is measured as written and so classifies by + /// its full length. + /// Cross-flow priority is arbitrated on the stem, not on the whole label: a lite + /// label's stem is reserved as a base name through + /// @custom:function IPopRulesOld.reserveBaseNameForPop, so `joseph.42` contends with + /// `joseph`. There is no flat spelling of a lite label for it to contend with. + /// @param value Candidate label. + /// @return isValid True if `value` is a stem of lowercase ASCII letters followed by a + /// separator and exactly @custom:constant LITE_SUFFIX_DIGITS digits. + function isLitePersonLabel(string calldata value) internal pure returns (bool isValid) { + return _isLitePersonLabel(bytes(value)); + } + + /// @notice Memory-location helper for @custom:function isLitePersonLabel. + /// @dev For callers holding the label in memory rather than calldata: the controller reads + /// it back from a struct before deriving the node, and the lens reads it out of a + /// store. Same predicate, different data location. + /// @param value Candidate label held in memory. + /// @return isValid True if `value` is a stem of lowercase ASCII letters followed by a + /// separator and exactly @custom:constant LITE_SUFFIX_DIGITS digits. + function isLitePersonLabelMemory(string memory value) internal pure returns (bool isValid) { + return _isLitePersonLabel(bytes(value)); + } + + function _isLitePersonLabel(bytes memory raw) private pure returns (bool isValid) { + uint256 length = raw.length; + // One stem letter, the separator, then the digits is the shortest accepted shape. The + // stem is not bounded below here: how short a name may be is policy, and PopRulesOld + // already holds it as the governance-reserved band. Enforcing a minimum here would + // duplicate the governance-reserved band and drift from it. + if (length < LITE_SUFFIX_DIGITS + 2) return false; + + // Fixing the separator's position is what enforces the exact digit count: a third + // digit, a missing separator and a trailing separator all land a non-separator byte + // here. The letters-only stem then admits no second separator, so exactly one is + // possible without scanning for it. + uint256 separator = length - LITE_SUFFIX_DIGITS - 1; + if (raw[separator] != LABEL_SEPARATOR) return false; + + // The stem is a name a person chose, so it follows the same letters-only rule as a + // full-person label. Uppercase is excluded for the reason it is everywhere else here: + // a single name must have a single spelling, and so a single node. + if (!_isPersonLabel(raw, 0, separator)) return false; + + for (uint256 i = separator + 1; i < length; ++i) { + bytes1 char = raw[i]; + if (char < bytes1(0x30) || char > bytes1(0x39)) return false; + } + + return true; + } + + /// @notice Validates that `value` is a name a person chose: lowercase ASCII letters only. + /// @dev Matches the gateway's full-person label rule, which admits no digits + /// and no hyphens, so a label outside this shape cannot have been issued. Stricter + /// than @custom:function isSingleLabel, and it is the same rule + /// @custom:function isLitePersonLabel applies to a lite stem. How short a name may be + /// is policy rather than format, so no floor is applied here. + /// @param value Candidate label. + /// @return isValid True if every octet of `value` is a lowercase ASCII letter. + function isPersonLabel(string calldata value) internal pure returns (bool isValid) { + bytes memory raw = bytes(value); + return _isPersonLabel(raw, 0, raw.length); + } + + function _isPersonLabel( + bytes memory raw, + uint256 start, + uint256 end + ) + private + pure + returns (bool isValid) + { + if (end <= start) return false; + if (end - start > MAX_DNS_LABEL_OCTETS) return false; + + for (uint256 i = start; i < end; ++i) { + bytes1 char = raw[i]; + if (char < bytes1(0x61) || char > bytes1(0x7a)) return false; + } + + return true; + } + + /// @notice Splits a lite-person label `.` into its stem and digit suffix. + /// @dev Splits at the first @custom:constant LABEL_SEPARATOR. A lite label carries exactly one + /// separator, so the caller is expected to have run @custom:function + /// isLitePersonLabelMemory first; a label with no separator returns the whole input as the stem + /// and an empty + /// suffix, which the caller's later label checks reject. + /// @param value Lite label held in memory, for example `alice.01`. + /// @return stem The label before the separator, for example `alice`. + /// @return suffix The digit suffix after the separator, for example `01`. + function splitLiteLabel(string memory value) + internal + pure + returns (string memory stem, string memory suffix) + { + bytes memory raw = bytes(value); + uint256 length = raw.length; + + uint256 separator = length; + for (uint256 i; i < length; ++i) { + if (raw[i] == LABEL_SEPARATOR) { + separator = i; + break; + } + } + + bytes memory stemBytes = new bytes(separator); + for (uint256 i; i < separator; ++i) { + stemBytes[i] = raw[i]; + } + + uint256 suffixLength = separator == length ? 0 : length - separator - 1; + bytes memory suffixBytes = new bytes(suffixLength); + for (uint256 i; i < suffixLength; ++i) { + suffixBytes[i] = raw[separator + 1 + i]; + } + + stem = string(stemBytes); + suffix = string(suffixBytes); + } + + /// @notice Validates that `s` is a dot-separated path of canonical DNS labels. + /// @dev Each segment between dots must satisfy @custom:function isSingleLabel. Empty + /// segments (leading, trailing, or consecutive dots) fail. Used when + /// callers submit multi-label paths (e.g. `alice.dot`) rather than + /// bare labels. + /// @param value Candidate name path. + /// @return isValid True if every dot-separated segment is a canonical DNS label. + function isNamePath(string calldata value) internal pure returns (bool isValid) { + bytes memory path = bytes(value); + uint256 length = path.length; + if (length == 0) return false; + + uint256 start; + for (uint256 i = 0; i < length; ++i) { + if (path[i] != bytes1(0x2e)) continue; + if (!_isDnsLabel(path, start, i)) return false; + start = i + 1; + } + + return _isDnsLabel(path, start, length); + } + + function _isDnsLabel( + bytes memory label, + uint256 start, + uint256 end + ) + private + pure + returns (bool isValid) + { + if (end <= start) return false; + if (end - start > MAX_DNS_LABEL_OCTETS) return false; + if (label[start] == bytes1(0x2d) || label[end - 1] == bytes1(0x2d)) { + return false; + } + + for (uint256 i = start; i < end; ++i) { + bytes1 char = label[i]; + bool isLowercase = char >= 0x61 && char <= 0x7a; + bool isDigit = char >= 0x30 && char <= 0x39; + if (!(isLowercase || isDigit || char == bytes1(0x2d))) return false; + } + + return true; + } + + /// @notice Converts a uint256 to its decimal string representation. + /// @dev Wraps OpenZeppelin's Strings.toString(). + /// @param value The unsigned integer to convert. + /// @return The decimal string representation. + function uintToString(uint256 value) internal pure returns (string memory) { + return value.toString(); + } + + /// @notice Converts an address to its checksummed hexadecimal string representation. + /// @dev Wraps OpenZeppelin's Strings.toHexString(). Returns lowercase hex with "0x" prefix. + /// @param account The address to convert. + /// @return The hexadecimal string representation (42 characters including "0x"). + function addressToHex(address account) internal pure returns (string memory) { + return account.toHexString(); + } + + /// @notice Converts a bytes32 value to a string, treating it as a null-terminated ASCII string. + /// @dev Reads bytes until the first null byte (0x00) or end of bytes32. + /// Useful for converting short strings stored in bytes32 back to string type. + /// @param _bytes32 The bytes32 value containing a null-terminated ASCII string. + /// @return The extracted string (up to 32 characters). + function bytes32ToString(bytes32 _bytes32) internal pure returns (string memory) { + uint8 i = 0; + while (i < 32 && _bytes32[i] != 0) { + i++; + } + bytes memory bytesArray = new bytes(i); + for (uint8 j = 0; j < i; j++) { + bytesArray[j] = _bytes32[j]; + } + return string(bytesArray); + } +} diff --git a/contracts/utils/SubnodeUtilsOld.sol b/contracts/utils/SubnodeUtilsOld.sol new file mode 100644 index 000000000..f9fd0b62b --- /dev/null +++ b/contracts/utils/SubnodeUtilsOld.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsRegistry} from "../registry/IDotnsRegistry.sol"; +import {IDotnsRegistrar} from "../registrars/IDotnsRegistrar.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {LabelUtils} from "./LabelUtils.sol"; +import {StringUtilsOld} from "./StringUtilsOld.sol"; +import {DotnsConstants} from "./DotnsConstants.sol"; + +/// @title DotNS Subnode Utilities Library +/// @notice General-purpose helpers for registering names that live as subnodes of another name, +/// rather than as tokenised second-level registrations. +/// @dev A subname has no token: its ownership lives in the registry record, not in the registrar's +/// ERC-721 ledger. So it is registered through @custom:function IDotnsRegistry.setSubnodeOwner +/// here, rather than through the tokenised mint triad of @custom:contract RegistrationUtils. +/// @custom:security-contact admin@parity.io +library SubnodeUtilsOld { + /// @notice Inputs describing a single subname registration. + /// @dev Passed as a struct so call sites name each field rather than thread a positional + /// argument list, mirroring @custom:struct RegistrationUtils.RegistrationContext. + /// @param protocolRegistry Protocol-level address registry used to resolve the registry and + /// TLD. + /// @param parentLabel Second-level parent label, e.g. `01`. + /// @param subLabel Subname label to register, e.g. `alice`. + /// @param owner Address to record as the subname owner. + /// @param persist Whether the registry should index the subnode into the owner's `LabelStore`. + struct SubnameContext { + IDotnsProtocolRegistry protocolRegistry; + string parentLabel; + string subLabel; + address owner; + bool persist; + } + + /// @notice Derives the subnode `subLabel.parentLabel.tld`. + /// @dev The single source of truth for how a two-level name maps to a node: walk `parentLabel` + /// under `tldNode`, then `subLabel` under that. Consumers that need the node without + /// writing it (readers, validators) call this so they agree with the write path. + /// @param tldNode The TLD node. + /// @param parentLabel Second-level parent label, e.g. `01`. + /// @param subLabel Subname label, e.g. `alice`. + /// @return subnode Namehash of `subLabel` under `parentLabel.tld`. + function subnodeOf( + bytes32 tldNode, + string memory parentLabel, + string memory subLabel + ) + internal + pure + returns (bytes32 subnode) + { + bytes32 parentNode = + LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(parentLabel)); + subnode = LabelUtils.namehashUnder(parentNode, LabelUtils.labelhashMemory(subLabel)); + } + + /// @notice Derives the subnode for a lite label `.`, splitting it on the + /// separator first. + /// @dev The single place a lite label is turned into a node, shared by the write path and every + /// reader of a lite name so the issuer and its readers agree on where a lite name lives. + /// Callers gate on @custom:function StringUtilsOld.isLitePersonLabelMemory beforehand, so + /// the label is known to carry the separator this splits on. + /// @param tldNode The TLD node. + /// @param liteLabel Lite label, e.g. `alice.01`. + /// @return subnode Namehash of the stem beneath its numeric container beneath the TLD. + function liteSubnodeOf( + bytes32 tldNode, + string memory liteLabel + ) + internal + pure + returns (bytes32 subnode) + { + (string memory stem, string memory suffix) = StringUtilsOld.splitLiteLabel(liteLabel); + subnode = subnodeOf(tldNode, suffix, stem); + } + + /// @notice Registers `subLabel` beneath the second-level name `parentLabel`, minting the parent + /// if it does not exist yet. + /// @dev Derives the parent node `parentLabel.tld`; when no name is registered there yet it is + /// minted through the registrar with the calling contract as owner, so the caller holds + /// the parent authority @custom:function IDotnsRegistry.setSubnodeOwner requires. The + /// calling contract must therefore be a registrar controller, otherwise the registrar + /// @custom:reverts NotController. When a name already exists at the parent it must be + /// owned by the caller, otherwise @custom:reverts NotAuthorised, so a name someone else holds + /// is + /// never treated as the caller's parent. Ownership of the subname is then recorded through + /// @custom:function IDotnsRegistry.setSubnodeOwner. `persist` is forwarded to the + /// registry: when false the ownership and resolver record is written but the owner's + /// `LabelStore` is + /// not, and the caller writes the label into the store separately. + /// @dev The parent is owned by the calling contract's address. A caller that migrates to a new + /// address rather than upgrading in place strands every parent it minted and can no longer + /// register subnames beneath them; the caller must upgrade in place, or hold parents under + /// an owner whose address is stable across migrations. + /// @dev `parentLabel` is a single label registered directly under the TLD, so the parent node + /// is derived as `namehash(tldNode, keccak(parentLabel))`; deeper parents are out of scope for + /// this helper. + /// @param context Subname registration inputs. See @custom:struct SubnameContext. + /// @return subnode Namehash of the registered subname. + function registerSubname(SubnameContext memory context) internal returns (bytes32 subnode) { + IDotnsProtocolRegistry protocolRegistry = context.protocolRegistry; + + bytes32 parentNode = LabelUtils.namehashUnder( + protocolRegistry.tldNode(), LabelUtils.labelhashMemory(context.parentLabel) + ); + + IDotnsRegistrar registrar = IDotnsRegistrar(protocolRegistry.get(DotnsConstants.REGISTRAR)); + IDotnsRegistry registry = IDotnsRegistry(protocolRegistry.get(DotnsConstants.REGISTRY)); + + // Mint the parent on first use, owned by the caller, and pass an empty label so no + // `LabelStore` is written for it. When it already exists it must belong to the caller, + // otherwise a name someone else registered would be treated as this caller's parent, so the + // ownership is checked locally rather than assumed from an out-of-contract gate. Subsequent + // subnames under a parent the caller already owns skip straight to the subnode write. + if (!registrar.exists(uint256(parentNode))) { + registrar.register(uint256(parentNode), address(this), ""); + registry.setOwner(parentNode, address(this)); + } else { + require(registry.owner(parentNode) == address(this), IDotnsRegistry.NotAuthorised()); + } + + subnode = registry.setSubnodeOwner( + IDotnsRegistry.SubnodeRecord({ + parentNode: parentNode, + subLabel: context.subLabel, + parentLabel: context.parentLabel, + owner: context.owner, + persist: context.persist + }) + ); + } +} diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol index 096ed7073..e9d99ef43 100644 --- a/contracts/whitelist/DotnsNameWhitelist.sol +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -49,6 +49,24 @@ contract DotnsNameWhitelist is using EnumerableSet for EnumerableSet.AddressSet; using EnumerableSet for EnumerableSet.Bytes32Set; + /// @notice One role's membership and its admin role. + /// @dev Member of the reserved AccessControl namespace, unused because gating is Root only. + /// @param hasRole Whether an account holds the role. + /// @param adminRole Admin role that manages the role. + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + /// @notice Reserved OpenZeppelin access-control namespace held at its ERC-7201 slot. + /// @dev Declared and left unused so the namespace stays present in the layout. Its slot derives + /// from the label, disjoint from the sequential slots below, so it consumes none of them. + /// @param _roles Role data keyed by role identifier. + /// @custom:storage-location erc7201:openzeppelin.storage.AccessControl + struct AccessControlStorage { + mapping(bytes32 role => RoleData) _roles; + } + /// @notice Protocol-level address registry for all dotNS contracts. IDotnsProtocolRegistry public protocolRegistry; diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index 968901c10..0952cc01f 100644 --- a/deployments/paseo-assethub/420420417.json +++ b/deployments/paseo-assethub/420420417.json @@ -1 +1 @@ -{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsCostModelRegistry":"0x8bfd1f0957e73716732e725802f13830B5682da4","DotnsFlatPricing":"0xD839B281dF72Df44fF275305E72cAEEc0fDAA648","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0xAE374b07c7e6f473CBa21d57e36AC15C631Abc51","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0x2227d9807F5A71332Aaa0640643030f2A3bf84cD","LabelStoreBeaconLegacy":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","StoreFactory":"0x99605a926FcB40aB520F659c6505E5ff862771f6","StoreFactoryLegacy":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0x3d1Ca165f7A5e387C2df02DB2FadD3149c1C72ad","UserStoreBeaconLegacy":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} +{"Create3Factory":"0x8533c79E058c5a6489CAFeCA86dc600E029D75f5","DotnsContentResolver":"0x7F74D7CD50f5a834270E2ad395a01b01891AB37d","DotnsCostModelRegistry":"0x8bfd1f0957e73716732e725802f13830B5682da4","DotnsFlatPricing":"0xD839B281dF72Df44fF275305E72cAEEc0fDAA648","DotnsNameEscrow":"0x4881Afb78e7C908cAe818168B926229D93376520","DotnsNameWhitelist":"0x420166cD67Ca0233094E492a4BbA67045eD7C38C","DotnsPopController":"0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b","DotnsPopLens":"0x309C5ff21f9082A53211500c6f33cA2a21024Ae4","DotnsPopResolver":"0xDaC984884EcA8Fc44011f1D6C49B27828390A72B","DotnsProtocolRegistry":"0xD19e3D0C97CF501125a04A97405e3e6592fa846E","DotnsRegistrar":"0x4f06E818Ba3d987704fd91cf3d868E4b019106Ab","DotnsRegistrarController":"0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30","DotnsRegistry":"0xf34054fd76BbF85f216cf9908226D5f0A72E50CA","DotnsResolver":"0xbd1165E549DF96F083c0A16f61590927bC187009","DotnsReverseResolver":"0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035","LabelStoreBeacon":"0x2227d9807F5A71332Aaa0640643030f2A3bf84cD","LabelStoreBeaconLegacy":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","StoreFactory":"0x99605a926FcB40aB520F659c6505E5ff862771f6","StoreFactoryLegacy":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0x3d1Ca165f7A5e387C2df02DB2FadD3149c1C72ad","UserStoreBeaconLegacy":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} diff --git a/deployments/paseo-assethub/README.md b/deployments/paseo-assethub/README.md new file mode 100644 index 000000000..96f3b5234 --- /dev/null +++ b/deployments/paseo-assethub/README.md @@ -0,0 +1,34 @@ +# `paseo-assethub` + +`420420417.json` is **Paseo Asset Hub Next**, reached at `https://eth-rpc-paseo-next.polkadot.io`. + +The note lives here rather than in the manifest because the manifest is address-only by +contract: `BaseDeployer.initDeployment` parses every key in it as an address, and so does +`release-metadata.mjs`. A text field added there fails the deploy pipeline on its first read. + +## The chain id does not identify the network + +Chain id 420420417 is shared with other Paseo-style environments, including the public Polkadot +Hub TestNet gateway at `https://services.polkadothub-rpc.com/testnet`, which answers on that id +and has no code at any of these addresses. An adapter or fork pointed at the wrong one resolves +every address here and finds all of them empty, so calls revert for reasons that look like +anything but the real cause. The fork tests assert code is present at each address they resolve, +which turns that into a clear failure instead of a confusing one. + +That gateway also answers `eth_getLogs` with an empty result for every range, not an error, +so anything derived from a log replay against it looks like it worked and is empty. Nothing in +this repository depends on that: the store migration reads its holders from the factory's own +`getLabelStores` and each store's `owner`. Worth knowing before reaching for logs to answer a +question about this network. Use an archive node, or Blockscout at +`https://blockscout-paseo-next.polkadot.io`. + +## What is deployed here is not a release + +These proxies are upgraded in place from `dev/testnet-upgrades`, which is never merged to +`master`, so no release tag describes the code they run. `DEPLOYMENTS.md` has the detail; the +short version is that `verify --tag` reports the `registrarController` key as drift permanently +and by design, every other key verifies, and a second drifting key is a real finding. + +Before broadcasting anything against this network, run `scripts/shell/verify-snapshots.sh` +against it. It is the only check that catches a snapshot describing an implementation that was +replaced in place and stopped running months ago. diff --git a/docker-compose.yaml b/docker-compose.yaml index 3a719bd43..982ee4eed 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,8 +26,12 @@ services: # the RPC server-side and don't need CORS. - "--rpc-cors" - "http://localhost,http://127.0.0.1" + # A forge script pins its fork to a block hash fetched moments earlier, and a one-block + # window on an advancing chain prunes that hash between the two calls, killing the run at + # startup with "failed to get block hash". A thousand blocks is minutes of history, ample + # for any broadcast, and still trivial for a runner's memory. - "--eth-pruning" - - "1" + - "1000" - "--rpc-max-connections" - "500" - "--rpc-max-request-size" diff --git a/docs/PASEO-V100-RUNBOOK.md b/docs/PASEO-V100-RUNBOOK.md new file mode 100644 index 000000000..0041d96de --- /dev/null +++ b/docs/PASEO-V100-RUNBOOK.md @@ -0,0 +1,193 @@ +# Paseo Asset Hub Next: broadcast order for the v1.0.0 in-place upgrade + +Upgrades the deployment from v0.8.0 to v1.0.0. Every step is one `scripts/deploy/upgrade.sh` +invocation under the deployment owner, broadcast through CI with one label per step; see +"Broadcasting through the review PR" below. The owner key lives only in the `testnet-upgrades` +environment, so the label path is the mechanism. + +Four contracts change code and one is replaced. StoreFactory, RegistrarController, +ReverseResolver and the rest are unchanged at the bytecode level and are left alone. + +## Order + +| # | Script | Why here | +| --- | --- | --- | +| 1 | `UpgradeRegistry` | `isNamePath` bounds a name path at 255 octets. No dependency on the others. | +| 2 | `UpgradePopRules` | Renames `personhoodOf` to `popStatusOf` and the tier names. No contract calls it. | +| 3 | `UpgradePopResolverAndController` | One script, two transactions: resolver, then controller. | +| 4 | `RedeployPopLens` | Reads the resolver through its new getters, so after step 3. Writes the manifest. | +| 5 | `DeclareRelease` | Declares every codehash, then `1.0.0`. Last, always. | + +Steps 1 and 2 can go in either order. Three dependencies fix the rest: + +- **The PoP resolver and controller change together.** The new controller writes the personhood + link through `setDeviceLink`, which only the new resolver has; the old controller writes it + through `setLiteLink`, which the new resolver drops. Either one alone leaves every linked + personhood issuance reverting. Step 3 swaps both in consecutive transactions, and an issuance + that lands between them reverts without writing anything. +- **The lens goes after the pair.** It reads `deviceLabelhashOf` and `personhoodNodeOf`, which the + old resolver does not have. +- **The declaration goes last.** It is a claim about the whole deployment. An abandoned run + leaves `0.8.0` declared, which clients read as an older network. + +Between steps 1 and 5 the declared codehashes of the swapped proxies trail the chain, so a client +that verifies them sees drift for that window. Step 4 declares the lens in the same run that +rewires it, so the lens never shows drift. + +## Before anything is broadcast + +```bash +RPC_URL=https://eth-rpc-paseo-next.polkadot.io scripts/shell/verify-snapshots.sh + +PASEO_FORK_RPC=https://eth-rpc-paseo-next.polkadot.io \ + RPC_URL=https://eth-rpc-paseo-next.polkadot.io scripts/shell/fork-tests.sh +``` + +Run both on the day. Every `*Old.sol` snapshot must reproduce the implementation deployed now; +the proxies are upgraded in place, so a swap landing in between changes the answer. The fork suite +includes `PaseoV100CampaignForkTest`, which runs all five steps in order on one fork and ends on a +verified `1.0.0` declaration. + +The broadcaster owns every proxy and the protocol registry. Each script asserts this before it +broadcasts. + +## Broadcasting through the review PR + +The workflow (`.github/workflows/paseo-upgrade-step.yml`) lives only on `dev/testnet-upgrades` +and is triggered by labels on an open review PR into master. One step: + +1. Add the label `run: