diff --git a/.github/workflows/paseo-upgrade-step.yml b/.github/workflows/paseo-upgrade-step.yml new file mode 100644 index 000000000..00d800e84 --- /dev/null +++ b/.github/workflows/paseo-upgrade-step.yml @@ -0,0 +1,155 @@ +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. The rotation script skips what already moved and the twelve swaps are +# idempotent; step 13 is the exception, and its runbook section says to inspect, not re-add. +# +# Key handling. Step 0 signs with the repository-level `DOTNS_ADMIN_KEY`, because its value +# cannot be read by anyone, so it cannot be re-entered as an environment secret. That exposure +# is time-boxed: step 0's whole purpose is to make that key powerless. The fresh key the +# operator generates IS entered as an environment secret under the same name, which shadows the +# repository one for every later step, and the repository copy is then deleted. 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:}" + case "$SCRIPT" in + RotateOwnership|UpgradeProtocolRegistry|UpgradeRegistry|UpgradeRegistrar|\ + UpgradeRegistrarController|UpgradePopController|UpgradePopRules|\ + UpgradeNameEscrow|UpgradeNameWhitelist|UpgradeResolver|UpgradeReverseResolver|\ + UpgradeContentResolver|UpgradePopResolver|MigrateStoreFactory|DeclareRelease) ;; + *) + echo "::error::'$SCRIPT' is not a runbook step; see docs/PASEO-V080-RUNBOOK.md" + exit 1 + ;; + esac + echo "script=$SCRIPT" >> "$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 + + # Only before anything has been broadcast: the moment step 1 lands, the snapshots stop + # matching the chain by design, so this gate is pinned to step 0 instead of pretending to + # be a mid-flight health check. + - name: Verify snapshots against the deployed bytecode + if: steps.step.outputs.script == 'RotateOwnership' + env: + RPC_URL: http://127.0.0.1:8545 + 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. Environment + # secrets shadow repository ones for jobs that declare the environment, which is what + # switches every post-rotation step onto the fresh key without touching this file. + 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 parameters, configured as environment variables on `testnet-upgrades`: + # DOTNS_NEW_OWNER for step 0, DOTNS_RELEASE_TAG for step 14, and optionally + # DOTNS_OLD_STORE_FACTORY as the step 13 cross-check. + DOTNS_NEW_OWNER: ${{ vars.DOTNS_NEW_OWNER }} + DOTNS_RELEASE_TAG: ${{ vars.DOTNS_RELEASE_TAG }} + DOTNS_OLD_STORE_FACTORY: ${{ vars.DOTNS_OLD_STORE_FACTORY }} + 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 step 13's manifest change 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; for MigrateStoreFactory, read the runbook before retrying anything." + 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 43b76d1a6..eec0cc96d 100644 --- a/.github/workflows/push_checking.yml +++ b/.github/workflows/push_checking.yml @@ -12,11 +12,17 @@ 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/**"] paths: ["contracts/**", "test/**", "**.sol"] push: - branches: [master] + branches: [master, "dev/**"] paths: ["contracts/**", "test/**", "**.sol"] pull_request_target: types: [closed] @@ -78,15 +84,6 @@ jobs: - run: bun install - # 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: @@ -122,20 +119,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: | @@ -163,9 +146,122 @@ jobs: - if: steps.run.outputs.result && contains(steps.run.outputs.result, 'Failed') run: exit 1 + # 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, so it is kept off the shared test runner and onto the Parity XL runner. + 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] if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: ubuntu-latest steps: @@ -267,7 +363,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/CONTRIBUTING.md b/CONTRIBUTING.md index 150ae8b2d..60d6abfb7 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 1fd1c98ed..82f7daba1 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 v0.8.0 has a fixed order, and nothing in the scripts enforces it: the +protocol registry has to go first because every other contract's `version()` reads through it, +the store migration after that because it rewires a key the declaration records, and the +declaration last because it is a claim about the whole deployment. +[`docs/PASEO-V080-RUNBOOK.md`](./docs/PASEO-V080-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/README.md b/README.md index 6628429f5..e8d672fb8 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Dedicated controller for the Proof-of-Personhood gateway flow. Lives behind its Today the Pop gateway does not write a standalone user-status mapping. It materialises the PoP flow through gateway-issued labels, PoP resolver records, and reservation queue state; user tier checks for public pricing still come from the personhood precompile/context read. -The first, reserveBaseName, mints a lite-person username to a user. The gateway-facing input is a stem.suffix shape: a stem of lowercase ASCII letters, exactly one dot, then exactly two digits (for example michal.03). The stem is stricter than a DNS label, because it is the name a person chose and People Chain restricts that to letters: no digits, no hyphens, no uppercase. The same rule applies to a full-person name, which is the other name a person chooses, so `alice-bob` and `micha3l` are ordinary public names but cannot be issued as identities. How short a stem may be is not part of the shape; that is the governance-reserved band, applied by classification. The label is stored, minted and shown in that form, which is the form People Chain holds and the gateway pallet sends, so nothing is normalised at this boundary. Inputs with more than one dot, no dot, a non-digit suffix, or a suffix length other than two digits are rejected. The node is the hash of the whole string, so it can never collide with a subname built from the same characters. The stem is not limited to the 6 to 8 of the PopLite tier: a longer one is accepted, and a lite username whose stem is nine letters or more classifies as NoStatus for public pricing, so its lite status is an issuance property rather than an economic tier. A stem of five letters or fewer classifies as Reserved and is rejected on this path, which is the same governance gate that applies to short flat names. The call also persists the user's chat key on the PoP resolver and optionally enqueues a reservation for a full-person base name the user intends to claim later. +The first, reserveBaseName, mints a lite-person username to a user. The gateway-facing input is a stem.suffix shape: a stem of lowercase ASCII letters, exactly one dot, then exactly two digits (for example michal.03). The stem is stricter than a DNS label, because it is the name a person chose and People Chain restricts that to letters: no digits, no hyphens, no uppercase. The same rule applies to a full-person name, which is the other name a person chooses, so `alice-bob` and `micha3l` are ordinary public names but cannot be issued as identities. How short a stem may be is not part of the shape; that is the governance-reserved band, applied by classification. The label is stored, minted and shown in that form, which is the form People Chain holds and the gateway pallet sends, so nothing is normalised at this boundary. Inputs with more than one dot, no dot, a non-digit suffix, or a suffix length other than two digits are rejected. The name is issued as a subname: `michal.03` is the label `michal` under the numeric container `03`, so its node is the namehash of that pair rather than the hash of the whole string. A subname someone else creates under the same container at the same label is therefore the same node, which is why the container is owned by the controller and deferred writes under it are restricted to registered controllers. The stem is not limited to the 6 to 8 of the PopLite tier: a longer one is accepted, and a lite username whose stem is nine letters or more classifies as NoStatus for public pricing, so its lite status is an issuance property rather than an economic tier. A stem of five letters or fewer classifies as Reserved and is rejected on this path, which is the same governance gate that applies to short flat names. The call also persists the user's chat key on the PoP resolver and optionally enqueues a reservation for a full-person base name the user intends to claim later. The second, registerBaseName, mints a full-person username. The label is lowercase ASCII letters only, the same rule the lite stem follows, so a hyphen or an interior digit is rejected even though both are valid in a public name. Whether the call is a claim against a prior lite reservation or a fresh standalone registration is derived from on-chain reservation state; the caller does not choose. The link argument selects the chat-key source: inherit from a prior lite label, or accept a fresh one in the payload. When inheriting, the call also writes the liteLink (full => lite) and fullClaim (lite => full) records on the PoP resolver in the same transaction so downstream consumers can resolve either direction without scanning events. diff --git a/contracts/escrow/DotnsNameEscrowOld.sol b/contracts/escrow/DotnsNameEscrowOld.sol new file mode 100644 index 000000000..cfc70c71e --- /dev/null +++ b/contracts/escrow/DotnsNameEscrowOld.sol @@ -0,0 +1,819 @@ +// 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 {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { + ERC165Upgradeable +} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {IDotnsNameEscrow} from "./IDotnsNameEscrow.sol"; +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Name Escrow +/// @notice Holds refundable deposits for registered names and manages the release/reclaim +/// lifecycle. @custom:security-contact admin@parity.io +contract DotnsNameEscrowOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ReentrancyGuardTransient, + ERC165Upgradeable, + IERC721Receiver, + IDotnsNameEscrow +{ + /// @notice Maximum page size for releasedTokens pagination. + uint256 public constant MAX_RELEASED_PAGE_SIZE = 200; + + /// @notice Maximum page size for pendingRefunds pagination and batch claims. + uint256 public constant MAX_REFUND_PAGE_SIZE = 200; + + /// @notice Upper bound on the configurable release-cooldown. + /// @dev The cooldown gates only the release-to-withdraw delay, not the long-lived deposit lock + /// and not the reclaim boundary (see `redeemWindow`), so it is intentionally kept short. + /// Capping at one hour also keeps the cast to `uint64` well below the saturation point at + /// every plausible block timestamp. + uint256 public constant MAX_COOLDOWN = 1 hours; + + /// @notice Upper bound on the configurable redeem window. + /// @dev The redeem window is a different quantity from the cooldown: it is the period after + /// release in which only the previous holder may act, and it gates reclaim rather than + /// withdrawal. The bound limits how long policy can hold a released name out of + /// circulation, and keeps the cast to `uint64` in release well below saturation. + uint256 public constant MAX_REDEEM_WINDOW = 30 days; + + /// @notice Lower bound on the configurable redeem window. + /// @dev A window short enough to elapse before its holder can plausibly notice the release + /// offers no protection at all, and one of zero length turns every release into an + /// immediate hand-off to whoever is watching. The floor keeps the window long enough to + /// span a holder being asleep or away for a day, so the guarantee survives any setting + /// the owner is able to choose. + uint256 public constant MIN_REDEEM_WINDOW = 1 days; + + /// @notice The protocol registry for resolving sibling contract addresses. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @notice Delay after release before the deposit withdrawal may be credited. + /// @dev Forces a delay between `release` and `withdraw`. It does not bound reclaim: the + /// release-to-reclaim boundary is `redeemWindow`, a separate and longer quantity. Also + /// supplies the per-entry clock for time-locked refund credits, which is why raising it + /// would slow every refund path and not just the deposit one. + uint256 public cooldown; + + /// @notice Total amount of a specific asset reserved across all positions. + /// @dev Keyed by asset so future ERC20 support can track per-token liabilities independently; + /// `address(0)` represents the native token and is the only asset currently accepted. + mapping(address asset => uint256 amount) public tokenReserved; + + /// @notice Per-token escrow position storing recipient, amount, lifecycle flags and cooldown. + mapping(uint256 tokenId => ReleasePosition position) private _positions; + + /// @notice Ordered set of tokens currently in escrow custody, used for paginated enumeration. + uint256[] private _releasedTokens; + + /// @notice Reverse lookup into `_releasedTokens` (one-based) for O(1) remove-by-swap. + mapping(uint256 tokenId => uint256 indexPlusOne) private _releasedIndexPlusOne; + + /// @notice Cumulative balance of non-refundable protocol fees; only accumulates. + /// @dev Credited by cross-paid registration fees and transfer fees. Never debited: protocol + /// fees do not back refunds, which draw solely on the per-asset reserve. + uint256 public protocolFees; + + /// @notice Pull-payment ledger storing each recipient's claimable refund balance. + /// @dev Per-recipient isolation ensures a failing or reentrant receiver cannot block other + /// users' withdrawals. Used as the fallback path for registration overpayments whose + /// direct push back to `msg.sender` failed (because the caller is a contract that + /// rejects incoming value). + mapping(address recipient => uint256 amount) private _pendingWithdrawals; + + /// @notice Time-locked refund ledger keyed by entryId. + /// @dev Every credit allocates a fresh entryId so per-entry cooldowns are independent and + /// drip-feed credits cannot reset an existing entry's clock. + mapping(uint256 entryId => RefundEntry entry) private _refundEntries; + + /// @notice Per-recipient list of pending entryIds for paginated enumeration and batch claim. + mapping(address recipient => uint256[] entryIds) private _entriesByRecipient; + + /// @notice Reverse lookup into `_entriesByRecipient` (one-based) for O(1) remove-by-swap. + mapping(uint256 entryId => uint256 indexPlusOne) private _entryIndexPlusOne; + + /// @notice Monotonic counter assigning entryIds to new refund credits. + uint256 private _nextEntryId; + + /// @notice Period after release during which only the previous holder may act. + /// @dev Distinct from `cooldown`. Inside this window the holder may `redeem` the name back + /// and nobody else may take it (`available` reports false); once it elapses `reclaim` + /// becomes permissionless and any unwithdrawn deposit is credited to the recipient + /// rather than stranded. + uint256 public redeemWindow; + + /// @dev Reserved storage space to allow for layout changes in future upgrades. A variable + /// appended above must shrink this array by the same number of slots, so an upgrade never + /// moves the slots of anything already stored. + uint256[50] private __gap; + + /// @notice Restricts calls to the configured registrar controller. + modifier onlyController() { + _onlyController(); + _; + } + + /// @notice Restricts calls to the configured registrar from the protocol registry. + modifier onlyRegistrar() { + _onlyRegistrar(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the name escrow. + /// @dev Runs once behind the proxy; subsequent calls trigger @custom:reverts + /// InvalidInitialization via the `initializer` modifier. `registry` must be non-zero, + /// otherwise @custom:reverts InvalidAsset; `cooldownSeconds` is forwarded to + /// @custom:function updateCooldown, which rejects a zero value (@custom:reverts + /// InvalidCooldown) and any value above @custom:constant MAX_COOLDOWN (@custom:reverts + /// CooldownTooLong), and emits @custom:emits CooldownUpdated as part of seeding the + /// initial cooldown. `redeemWindowSeconds` is forwarded to @custom:function + /// updateRedeemWindow, which rejects any value below @custom:constant MIN_REDEEM_WINDOW + /// (@custom:reverts RedeemWindowTooShort) or above @custom:constant MAX_REDEEM_WINDOW + /// (@custom:reverts RedeemWindowTooLong), and emits @custom:emits RedeemWindowUpdated. + /// @param registry Protocol registry used to resolve registrar and controller addresses. + /// @param cooldownSeconds Delay after release before the deposit withdrawal may be credited. + /// @param redeemWindowSeconds Period after release in which only the previous holder may act. + function initialize( + IDotnsProtocolRegistryOld registry, + uint256 cooldownSeconds, + uint256 redeemWindowSeconds + ) + external + initializer + { + require(address(registry) != address(0), InvalidAsset()); + + __Ownable_init(msg.sender); + __ERC165_init(); + + protocolRegistry = registry; + updateCooldown(cooldownSeconds); + updateRedeemWindow(redeemWindowSeconds); + } + + /// @inheritdoc IDotnsNameEscrow + function updateCooldown(uint256 newCooldown) public override onlyOwner { + require(newCooldown != 0, InvalidCooldown()); + require(newCooldown <= MAX_COOLDOWN, CooldownTooLong(newCooldown, MAX_COOLDOWN)); + + uint256 currentCooldown = cooldown; + cooldown = newCooldown; + + emit CooldownUpdated(currentCooldown, newCooldown); + } + + /// @inheritdoc IDotnsNameEscrow + function updateRedeemWindow(uint256 newRedeemWindow) public override onlyOwner { + require( + newRedeemWindow >= MIN_REDEEM_WINDOW, + RedeemWindowTooShort(newRedeemWindow, MIN_REDEEM_WINDOW) + ); + require( + newRedeemWindow <= MAX_REDEEM_WINDOW, + RedeemWindowTooLong(newRedeemWindow, MAX_REDEEM_WINDOW) + ); + + uint256 currentRedeemWindow = redeemWindow; + redeemWindow = newRedeemWindow; + + emit RedeemWindowUpdated(currentRedeemWindow, newRedeemWindow); + } + + /// @inheritdoc IDotnsNameEscrow + function getReleasePosition(uint256 tokenId) + external + view + override + returns (ReleasePosition memory position) + { + position = _positions[tokenId]; + } + + /// @inheritdoc IDotnsNameEscrow + function releasedTokenCount() external view override returns (uint256 count) { + count = _releasedTokens.length; + } + + /// @inheritdoc IDotnsNameEscrow + function reserves(address asset) external view returns (uint256 amount) { + amount = tokenReserved[asset]; + } + + /// @inheritdoc IDotnsNameEscrow + function releasedTokens( + uint256 start, + uint256 limit + ) + external + view + override + returns (uint256[] memory tokenIds) + { + require(limit != 0 && limit <= MAX_RELEASED_PAGE_SIZE, InvalidPageSize(limit)); + + uint256 length = _releasedTokens.length; + if (start >= length) return new uint256[](0); + + uint256 end = start + limit; + if (end > length) end = length; + + tokenIds = new uint256[](end - start); + uint256 outIndex = 0; + for (uint256 i = start; i < end; ++i) { + tokenIds[outIndex] = _releasedTokens[i]; + ++outIndex; + } + } + + /// @inheritdoc IDotnsNameEscrow + function deposit(DepositParams calldata params) external payable override onlyController { + // Reject mismatched amount/msg.value so callers cannot under-fund a position. + require(msg.value == params.amount, InvalidAmount()); + // Only native deposits are currently supported; ERC20 support can be added in a + // future upgrade by relaxing this check and routing transfers via SafeERC20. + require(params.asset == address(0), AssetNotSupported(params.asset)); + require(params.recipient != address(0), InvalidRecipient()); + + ReleasePosition storage position = _positions[params.tokenId]; + + // Use `recipient` as the "is this slot funded?" sentinel so zero-amount + // positions (seeded by cross-paid registrations, which pay a fee rather than a deposit) + // still count as present and cannot be re-seeded with a different recipient. + require(position.recipient == address(0), PositionAlreadyFunded(params.tokenId)); + require(!position.released, AlreadyReleased(params.tokenId)); + + position.asset = params.asset; + position.amount = params.amount; + position.recipient = params.recipient; + + tokenReserved[position.asset] += params.amount; + + emit NativeDepositRecorded(params.tokenId, params.amount); + } + + /// @inheritdoc IDotnsNameEscrow + function creditOverpayment(address recipient) external payable override onlyController { + require(recipient != address(0), InvalidRecipient()); + require(msg.value != 0, InvalidAmount()); + _pendingWithdrawals[recipient] += msg.value; + emit OverpaymentRefunded(recipient, msg.value); + } + + /// @inheritdoc IDotnsNameEscrow + function depositProtocolFee(ProtocolFeeDepositParams calldata params) + external + payable + override + onlyController + { + require(msg.value > 0, InvalidAmount()); + + protocolFees += msg.value; + + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.recipient, + msg.value, + /* isRegistration */ + true + ); + } + + /// @inheritdoc IDotnsNameEscrow + function chargeTransferFee(ChargeTransferFeeParams calldata params) + external + payable + override + onlyRegistrar + returns (uint256 charged) + { + ReleasePosition storage position = _positions[params.tokenId]; + // Released positions are mid-lifecycle in escrow custody and must not be rebound; the + // recipient is the original releaser who will claim the refund. The canonical transfer + // path never reaches here for released tokens (`_update` short-circuits on escrow-touching + // transfers) but the guard hardens the contract against a divergent registrar. + require(!position.released, AlreadyReleased(params.tokenId)); + + address priorRecipient = position.recipient; + + uint256 fee = params.transferFee; + require(msg.value >= fee, InsufficientValue()); + + // Deposits follow the NFT, not the depositor. When the position is funded the locked + // deposit travels with the name; when it is a zero-amount lifecycle marker the marker + // travels with it. In both cases the position is rebound to the new holder so only + // the current holder can later release into escrow and claim the refund. + if (priorRecipient != address(0) && params.to != priorRecipient) { + position.recipient = params.to; + } + + charged = fee; + + if (fee > 0) { + protocolFees += fee; + emit CrossTierFeePaid( + params.tokenId, + params.payer, + params.to, + fee, + /* isRegistration */ + false + ); + } + + uint256 overpayment = msg.value - fee; + if (overpayment > 0) { + _creditRefund(params.payer, overpayment, params.tokenId); + } + } + + /// @inheritdoc IDotnsNameEscrow + function release(uint256 tokenId) external override nonReentrant { + IDotnsRegistrarOld registrar = _registrar(); + + address currentOwner = registrar.ownerOf(tokenId); + + ReleasePosition storage position = _positions[tokenId]; + // Recipient is the canonical "is this position present?" sentinel; zero-amount positions + // seeded for cross-paid registrations are still releasable so every minted name has a + // reachable lifecycle. + require(position.recipient != address(0), DepositNotConfigured(tokenId)); + require(!position.released, AlreadyReleased(tokenId)); + + // Position recipient mirrors the current NFT holder (rebound on every transfer), so the + // holder gate collapses to a single equality check. Approved operators cannot release on + // behalf of the holder because the recipient field is keyed to the holder, not to any + // approval set; this keeps the deposit refund flow tied to the on-chain owner. + require( + msg.sender == currentOwner && msg.sender == position.recipient, + NotRefundRecipient(msg.sender, tokenId) + ); + + bool approvedForEscrow = registrar.getApproved(tokenId) == address(this) + || registrar.isApprovedForAll(currentOwner, address(this)); + + require(approvedForEscrow, EscrowNotApproved(tokenId)); + + // Fail closed on an unseeded window rather than stamping `redeemableUntil` at the current + // timestamp, which would collapse the holder's exclusive redeem phase to zero length and + // open permissionless reclaim the instant the name is released. Only reachable on a proxy + // upgraded without pairing the upgrade with `updateRedeemWindow`. + uint256 currentRedeemWindow = redeemWindow; + require(currentRedeemWindow != 0, RedeemWindowNotConfigured()); + + // Snapshot the position fields once into stack locals so the trailing event emit reuses + // them without three extra warm SLOADs after the state mutation. Both casts to `uint64` are + // safe because `cooldown` and `redeemWindow` are bounded by @custom:constant MAX_COOLDOWN + // and @custom:constant MAX_REDEEM_WINDOW respectively. + address asset = position.asset; + uint256 amount = position.amount; + // forge-lint: disable-next-line(unsafe-typecast) + uint64 availableAt = uint64(block.timestamp + cooldown); + // forge-lint: disable-next-line(unsafe-typecast) + uint64 redeemUntil = uint64(block.timestamp + currentRedeemWindow); + + position.withdrawAvailableAt = availableAt; + position.redeemableUntil = redeemUntil; + position.released = true; + + registrar.safeTransferFrom(currentOwner, address(this), tokenId); + + _addReleasedToken(tokenId); + + emit NameReleased(tokenId, msg.sender, asset, amount, availableAt, redeemUntil); + } + + /// @inheritdoc IDotnsNameEscrow + function withdraw(uint256 tokenId) external override nonReentrant { + ReleasePosition storage position = _positions[tokenId]; + + require(position.released, NotReleased(tokenId)); + require(!position.claimed, AlreadyClaimed(tokenId)); + require(position.recipient == msg.sender, NotRefundRecipient(msg.sender, tokenId)); + require( + block.timestamp >= position.withdrawAvailableAt, + WithdrawalTooEarly(tokenId, position.withdrawAvailableAt, block.timestamp) + ); + + _settleDeposit(position, tokenId, msg.sender); + } + + /// @notice Moves a position's outstanding deposit onto the recipient's pull-payment balance. + /// @dev Shared by @custom:function withdraw, where the recipient pulls the deposit themselves, + /// and by @custom:function reclaim, where a third party takes the name and the deposit is + /// settled on the departing holder's behalf. Both credit the same ledger and neither + /// transfers value, so the accounting is identical and lives here once. The per-asset + /// `tokenReserved` pool backs the refund in full, and @custom:reverts InsufficientFunds + /// when it cannot cover the amount owed. Emits @custom:emits RefundWithdrawn. + /// A zero-amount position is a no-op: it writes nothing and emits nothing, which keeps the + /// free-registration lifecycle free of meaningless ledger entries and events. + /// @param position Storage pointer to the position being settled. + /// @param recipient Address credited with the deposit. Always the position recipient. + function _settleDeposit( + ReleasePosition storage position, + uint256 tokenId, + address recipient + ) + private + { + uint256 owed = position.amount; + address asset = position.asset; + + // Nothing to settle: return before touching `claimed`. That flag is what `redeem` reads to + // decide whether the holder has already been paid for the name, so setting it here would + // make a zero-amount `withdraw`, which pays nothing and emits nothing, silently forfeit + // the holder's right to recover their own name for no consideration at all. Free PopFull + // and PopLite registrations seed exactly these positions, and `withdraw` is the step the + // old contract required before a name could be recycled, so that is a path holders will + // take. + if (owed == 0) return; + + // Effects: from here the deposit really is being handed over, so the flag is set. + position.claimed = true; + + // The per-asset reserve backs every refundable deposit; protocol fees are non-refundable + // and never cover a refund. + require( + tokenReserved[asset] >= owed, InsufficientFunds(tokenId, owed, tokenReserved[asset]) + ); + + position.amount = 0; + tokenReserved[asset] -= owed; + + _pendingWithdrawals[recipient] += owed; + + emit RefundWithdrawn(tokenId, recipient, asset, owed); + } + + /// @inheritdoc IDotnsNameEscrow + function claimWithdrawal() external override nonReentrant returns (uint256 amount) { + amount = _pendingWithdrawals[msg.sender]; + require(amount > 0, NoPendingWithdrawal()); + + // Effects before interaction. + _pendingWithdrawals[msg.sender] = 0; + + (bool ok,) = payable(msg.sender).call{value: amount}(""); + // tokenId is not meaningful here since a single pending balance can aggregate + // multiple positions; surface 0 to keep the existing error shape. + require(ok, RefundFailed(0)); + + emit WithdrawalClaimed(msg.sender, amount); + } + + /// @inheritdoc IDotnsNameEscrow + function pendingWithdrawal(address recipient) external view override returns (uint256 amount) { + amount = _pendingWithdrawals[recipient]; + } + + /// @inheritdoc IDotnsNameEscrow + function claimRefund(uint256 entryId) external override nonReentrant returns (uint256 amount) { + // Storage pointer over memory copy: only the fields we actually need are SLOAD-ed. + RefundEntry storage entry = _refundEntries[entryId]; + amount = entry.amount; + // Check existence first so a deleted (already-claimed or unknown) entry surfaces a clear + // `NoSuchRefundEntry` rather than the recipient-mismatch revert that would otherwise fire + // against the zero-address sentinel. + require(amount > 0, NoSuchRefundEntry(entryId)); + uint256 entryTokenId = entry.tokenId; + require(entry.recipient == msg.sender, NotRefundRecipient(msg.sender, entryTokenId)); + require(block.timestamp >= entry.availableAt, RefundLocked(entryId, entry.availableAt)); + + _removeRefundEntry(entryId, msg.sender); + + (bool ok,) = payable(msg.sender).call{value: amount}(""); + require(ok, RefundFailed(entryTokenId)); + + emit RefundClaimed(msg.sender, entryId, amount); + } + + /// @inheritdoc IDotnsNameEscrow + function claimRefundsBatch(uint256[] calldata entryIds) + external + override + nonReentrant + returns (uint256 totalAmount) + { + uint256 length = entryIds.length; + require(length > 0 && length <= MAX_REFUND_PAGE_SIZE, InvalidPageSize(length)); + + for (uint256 i; i < length; ++i) { + uint256 entryId = entryIds[i]; + RefundEntry storage entry = _refundEntries[entryId]; + uint256 amount = entry.amount; + require(amount > 0, NoSuchRefundEntry(entryId)); + require(entry.recipient == msg.sender, NotRefundRecipient(msg.sender, entry.tokenId)); + require(block.timestamp >= entry.availableAt, RefundLocked(entryId, entry.availableAt)); + + totalAmount += amount; + _removeRefundEntry(entryId, msg.sender); + + emit RefundClaimed(msg.sender, entryId, amount); + } + + (bool ok,) = payable(msg.sender).call{value: totalAmount}(""); + require(ok, RefundFailed(0)); + } + + /// @inheritdoc IDotnsNameEscrow + function pendingRefundCount(address recipient) external view override returns (uint256 count) { + count = _entriesByRecipient[recipient].length; + } + + /// @inheritdoc IDotnsNameEscrow + function pendingRefundIds( + address recipient, + uint256 offset, + uint256 limit + ) + external + view + override + returns (uint256[] memory entryIds) + { + require(limit > 0 && limit <= MAX_REFUND_PAGE_SIZE, InvalidPageSize(limit)); + + uint256[] storage all = _entriesByRecipient[recipient]; + uint256 total = all.length; + if (offset >= total) return new uint256[](0); + + uint256 end = offset + limit; + if (end > total) end = total; + + entryIds = new uint256[](end - offset); + for (uint256 i = 0; i < entryIds.length; ++i) { + entryIds[i] = all[offset + i]; + } + } + + /// @inheritdoc IDotnsNameEscrow + function pendingRefunds( + address recipient, + uint256 offset, + uint256 limit + ) + external + view + override + returns (uint256[] memory entryIds, RefundEntry[] memory entries) + { + require(limit > 0 && limit <= MAX_REFUND_PAGE_SIZE, InvalidPageSize(limit)); + + uint256[] storage all = _entriesByRecipient[recipient]; + uint256 total = all.length; + if (offset >= total) { + return (new uint256[](0), new RefundEntry[](0)); + } + + uint256 end = offset + limit; + if (end > total) end = total; + + uint256 count = end - offset; + entryIds = new uint256[](count); + entries = new RefundEntry[](count); + for (uint256 i = 0; i < count; ++i) { + uint256 entryId = all[offset + i]; + entryIds[i] = entryId; + entries[i] = _refundEntries[entryId]; + } + } + + /// @inheritdoc IDotnsNameEscrow + function refundEntry(uint256 entryId) + external + view + override + returns (RefundEntry memory entry) + { + entry = _refundEntries[entryId]; + } + + /// @notice Internal helper: allocate a new entryId and credit a refund to `recipient`. + /// @dev Assigns the next monotonic entryId, stores the entry, appends to the recipient's + /// enumeration array, and emits @custom:emits RefundCredited. The cooldown is read from the + /// configured `cooldown` storage value; @custom:constant MAX_COOLDOWN bounds it so the cast to + /// `uint64` cannot truncate for any plausible block timestamp. + function _creditRefund( + address recipient, + uint256 amount, + uint256 tokenId + ) + internal + returns (uint256 entryId) + { + require(recipient != address(0), InvalidRecipient()); + require(amount > 0, InvalidAmount()); + + entryId = ++_nextEntryId; + // forge-lint: disable-next-line(unsafe-typecast) + uint64 availableAt = uint64(block.timestamp + cooldown); + + _refundEntries[entryId] = RefundEntry({ + recipient: recipient, amount: amount, availableAt: availableAt, tokenId: tokenId + }); + + uint256[] storage list = _entriesByRecipient[recipient]; + list.push(entryId); + _entryIndexPlusOne[entryId] = list.length; + + emit RefundCredited(recipient, entryId, amount, availableAt, tokenId); + } + + /// @notice Internal helper: delete a refund entry and swap-pop its slot in the recipient's + /// enumeration array. + function _removeRefundEntry(uint256 entryId, address recipient) internal { + uint256 indexPlusOne = _entryIndexPlusOne[entryId]; + // Caller is expected to have validated existence already; defensive check kept cheap. + if (indexPlusOne == 0) return; + + uint256 index = indexPlusOne - 1; + uint256[] storage list = _entriesByRecipient[recipient]; + uint256 lastIndex = list.length - 1; + + if (index != lastIndex) { + uint256 movedEntryId = list[lastIndex]; + list[index] = movedEntryId; + _entryIndexPlusOne[movedEntryId] = indexPlusOne; + } + list.pop(); + + delete _entryIndexPlusOne[entryId]; + delete _refundEntries[entryId]; + } + + /// @inheritdoc IDotnsNameEscrow + function reclaim( + uint256 tokenId, + address newOwner + ) + external + override + onlyController + nonReentrant + { + require(isReclaimable(tokenId), NotReclaimable(tokenId)); + + ReleasePosition storage position = _positions[tokenId]; + address previousRecipient = position.recipient; + + // Settle before deleting: the departing holder keeps their claim on the deposit even though + // they are losing the name. `_settleDeposit` is a no-op for a zero-amount position and for + // one already withdrawn, so the common paths cost nothing extra. + _settleDeposit(position, tokenId, previousRecipient); + + delete _positions[tokenId]; + _removeReleasedToken(tokenId); + + _registrar().safeTransferFrom(address(this), newOwner, tokenId); + + emit NameReclaimed(tokenId, previousRecipient, newOwner); + } + + /// @inheritdoc IDotnsNameEscrow + /// @dev `public` rather than `external` so `reclaim` can gate on it without a self-call, which + /// is what keeps the condition in one place instead of two. + function isReclaimable(uint256 tokenId) public view override returns (bool reclaimable) { + ReleasePosition storage position = _positions[tokenId]; + + // The gate is the elapsed redeem window, not the `claimed` flag. Gating on `claimed` would + // make recyclability depend on the previous holder choosing to withdraw, which strands the + // name whenever they have no reason to: a zero-amount position has nothing to collect, so + // "never withdraws" is the default rather than the exception. The window bounds the wait + // instead, and reclaim settles any unwithdrawn value rather than holding it hostage. + // + // Lifecycle state only. `reclaim` also settles the deposit, which can in principle revert + // `InsufficientFunds` when the reserved balance cannot cover the amount owed, so a true + // answer here is a claim about the window rather than a guarantee + // that the call is funded. The two coincide because `tokenReserved` is by construction the + // exact sum of live position amounts: only `deposit` credits it, and only `_settleDeposit` + // debits it, by exactly the amount it zeroes. `invariant_reserves_match_positions` holds + // that construction, and `invariant_reclaimable_positions_are_fundable` asserts the + // implication directly, so a change that broke the coincidence would fail the suite rather + // than surface as a name advertised and then unregisterable. + reclaimable = position.released && block.timestamp >= position.redeemableUntil; + } + + /// @inheritdoc IDotnsNameEscrow + function redeem(uint256 tokenId) external override nonReentrant { + ReleasePosition storage position = _positions[tokenId]; + + require(position.recipient == msg.sender, NotRefundRecipient(msg.sender, tokenId)); + // One error for the whole state predicate: unreleased, already withdrawn, or past the + // window are all simply "not redeemable" from the caller's point of view, and collapsing + // them avoids leaking a three-way state machine into the revert surface. + require( + position.released && !position.claimed && block.timestamp < position.redeemableUntil, + NotRedeemable(tokenId) + ); + + // Restore the pre-release state and nothing more. Recipient, asset and amount are left + // untouched so the deposit stays locked against the name; clearing the clocks means a later + // release starts a fresh pair rather than inheriting stale deadlines. + position.released = false; + position.withdrawAvailableAt = 0; + position.redeemableUntil = 0; + + _removeReleasedToken(tokenId); + + _registrar().safeTransferFrom(address(this), msg.sender, tokenId); + + emit NameRedeemed(tokenId, msg.sender); + } + + /// @inheritdoc IERC721Receiver + function onERC721Received( + address, + address, + uint256 tokenId, + bytes calldata + ) + external + view + override + returns (bytes4 selector) + { + require(msg.sender == address(_registrar()), NotAcceptedTransfer(msg.sender)); + // Only accept transfers that this contract itself initiated via `release`. A holder calling + // `registrar.safeTransferFrom(holder, escrow, tokenId)` directly would otherwise land the + // NFT in custody with no `released` position, leaving the token (and any prior deposit) + // permanently unreachable through `withdraw` / `reclaim`. + require(_positions[tokenId].released, UnsolicitedDeposit(tokenId)); + selector = IERC721Receiver.onERC721Received.selector; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool supported) { + supported = interfaceId == type(IDotnsNameEscrow).interfaceId + || interfaceId == type(IERC721Receiver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Returns the configured registrar from the protocol registry. + function _registrar() internal view returns (IDotnsRegistrarOld registrar) { + registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + } + + /// @notice Restricts calls to the configured controller from the protocol registry. + function _onlyController() internal view { + address controller = protocolRegistry.get(DotnsConstantsOld.CONTROLLER); + require(msg.sender == controller, NotController(msg.sender)); + } + + /// @notice Restricts calls to the configured registrar from the protocol registry. + function _onlyRegistrar() internal view { + address registrar = protocolRegistry.get(DotnsConstantsOld.REGISTRAR); + require(msg.sender == registrar, NotRegistrar(msg.sender)); + } + + /// @notice Adds a token to the released-token set if absent. + function _addReleasedToken(uint256 tokenId) internal { + if (_releasedIndexPlusOne[tokenId] != 0) return; + + _releasedTokens.push(tokenId); + _releasedIndexPlusOne[tokenId] = _releasedTokens.length; + } + + /// @notice Removes a token from the released-token set if present. + function _removeReleasedToken(uint256 tokenId) internal { + uint256 indexPlusOne = _releasedIndexPlusOne[tokenId]; + if (indexPlusOne == 0) return; + + uint256 index = indexPlusOne - 1; + uint256 lastIndex = _releasedTokens.length - 1; + + if (index != lastIndex) { + uint256 lastTokenId = _releasedTokens[lastIndex]; + _releasedTokens[index] = lastTokenId; + _releasedIndexPlusOne[lastTokenId] = index + 1; + } + + _releasedTokens.pop(); + delete _releasedIndexPlusOne[tokenId]; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/pop/IDotnsCostModelRegistryOld.sol b/contracts/pop/IDotnsCostModelRegistryOld.sol new file mode 100644 index 000000000..5d1d689a0 --- /dev/null +++ b/contracts/pop/IDotnsCostModelRegistryOld.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsPricingOld} from "./IDotnsPricingOld.sol"; + +/// @title DotNS Cost Model Registry +/// @notice Holds every cost model the protocol has run and names the current one. +/// @dev The address registered under `DotnsConstantsOld.COST_MODEL` points here, set once and never +/// repointed. Changing the live curve registers a new model, which adds its version and moves +/// the current pointer. Prior models stay live and priceable by version, so a registration +/// committed against an earlier curve settles at the amount it committed to. +/// @custom:security-contact admin@parity.io +interface IDotnsCostModelRegistryOld { + /// @notice Emitted when a model is registered and becomes current. + /// @param version The model's version identifier. + /// @param model The model address now serving that version. + event CostModelRegistered(uint256 indexed version, address indexed model); + + /// @notice Emitted when the current version is pointed at an already-registered model. + /// @param version The version now serving fresh pricing. + event CurrentModelSet(uint256 indexed version); + + /// @notice Thrown when registering a model whose version is already held. + /// @param version The version already registered. + error AlreadyRegistered(uint256 version); + + /// @notice Thrown when pricing against a version that was never registered. + /// @param version The version with no registered model. + error UnknownVersion(uint256 version); + + /// @notice Thrown when registering a model whose version is zero, which is the sentinel for + /// an unregistered version and so cannot name a real model. + error ZeroVersion(); + + /// @notice Thrown when a registration reveals at a different version than it committed to. + /// @dev Raised where a commit-reveal flow binds a version at commit and checks it at reveal, so + /// the version a name prices at cannot move after the commitment is made. + /// @param committed The version bound when the commitment was made. + /// @param revealed The version supplied at reveal. + error PricingVersionMismatch(uint256 committed, uint256 revealed); + + /// @notice Registers a model and makes it current. + /// @dev Owner-only. Keys the model by its own `version`, so a version can be registered once; + /// a repeat triggers @custom:reverts AlreadyRegistered. Moves the current pointer to the + /// new version and emits @custom:emits CostModelRegistered. + /// @param model The cost model to register. + function register(IDotnsPricingOld model) external; + + /// @notice Points the current version at an already-registered model. + /// @dev Owner-only. Reverts to a previously registered version without redeploying it, so + /// governance can roll fresh pricing back to an earlier curve. @custom:reverts + /// UnknownVersion when no model is registered for `version`. Emits @custom:emits + /// CurrentModelSet. + /// @param version The already-registered version to make current. + function setCurrentVersion(uint256 version) external; + + /// @notice Returns the model registered for a version, or the zero address when none. + /// @param version The version to look up. + /// @return model The model registered for that version. + function modelOf(uint256 version) external view returns (IDotnsPricingOld model); + + /// @notice Returns the version currently serving fresh pricing. + /// @return version The current version identifier. + function currentVersion() external view returns (uint256 version); + + /// @notice Returns the current model. + /// @return model The model serving the current version. + function current() external view returns (IDotnsPricingOld model); + + /// @notice Prices a base length at the current version. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei at the current version. + function priceForBaseLength(uint256 baseLength) external view returns (uint256 weiPrice); + + /// @notice Prices a base length at a specific version. + /// @dev @custom:reverts UnknownVersion when no model is registered for `version`. + /// @param version The version to price against. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei at that version. + function priceForBaseLengthAtVersion( + uint256 version, + uint256 baseLength + ) + external + view + returns (uint256 weiPrice); +} diff --git a/contracts/pop/IDotnsPricingOld.sol b/contracts/pop/IDotnsPricingOld.sol new file mode 100644 index 000000000..39b61094b --- /dev/null +++ b/contracts/pop/IDotnsPricingOld.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title DotNS Pricing Cost Model +/// @notice Prices a registration from the base length of its label alone. +/// @dev The seam between name policy and the wei amount a registration costs. `PopRulesOld` and the +/// public commit-reveal controller keep the classification, reservation, and tier rules; the +/// model owns only the amount for a given base length, so the curve can be swapped by +/// registering a new model under `DotnsConstantsOld.COST_MODEL` without touching either. Only +/// the base length crosses the seam: the model reads no personhood band or `PopStatus`. The public +/// controller prices NoStatus deposits through this same path, so the model carries no PoP +/// name. +/// @custom:security-contact admin@parity.io +interface IDotnsPricingOld { + /// @notice Thrown when a model constructor parameter breaks a pricing invariant. + /// @param reason Human-readable explanation of the failed invariant. + error PricingError(string reason); + + /// @notice Returns the registration cost in wei for a label of the given base length. + /// @dev Pure amount lookup: the caller supplies the digit-stripped base length and the model + /// returns the curve value for it. Runs on the ERC721 transfer floor read, so it stays a + /// view with no state writes. + /// @param baseLength Digit-stripped length of the label being priced. + /// @return weiPrice Registration cost in wei for that base length. + function priceForBaseLength(uint256 baseLength) external view returns (uint256 weiPrice); + + /// @notice Returns a stable identifier for this model and its parameters. + /// @dev Changes when the model shape or its parameters change, so clients and telemetry can + /// tell one live curve from another. Not consulted on the pricing path. + /// @return modelVersion Identifier derived from the model form and its parameters. + function version() external view returns (uint256 modelVersion); +} diff --git a/contracts/pop/PopRulesOld.sol b/contracts/pop/PopRulesOld.sol new file mode 100644 index 000000000..de528afd4 --- /dev/null +++ b/contracts/pop/PopRulesOld.sol @@ -0,0 +1,645 @@ +// 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 {StringUtils} from "../utils/StringUtils.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.sol"; +import {IPopRules} from "./IPopRules.sol"; +import {IDotnsCostModelRegistryOld} from "./IDotnsCostModelRegistryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IDotnsController} from "../registrars/IDotnsController.sol"; +import {DotnsRegistrarOld} from "../registrars/DotnsRegistrarOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.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 `DotnsConstantsOld.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, + IPopRules +{ + using StringUtils 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. + IDotnsProtocolRegistryOld 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 `DotnsConstantsOld.COST_MODEL`, so no price is seeded here. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistryOld registry) public initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IPopRules + 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(SystemUtilsOld.originIsRoot(), NotRoot()); + shortNamesEnabled = enabled; + emit ShortNamesEnabledUpdated(enabled); + } + + /// @inheritdoc IPopRules + function classifyName(string calldata name) + external + pure + override + returns (PopStatus requirement, string memory message) + { + _requireLabel(name); + (requirement, message,) = _classifyValidatedName(name); + } + + /// @inheritdoc IPopRules + 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 IPopRules + function isBaseName(string calldata baseName) external pure override returns (bool isBase) { + _requireLabel(baseName); + uint256 digits = _countTrailingDigits(baseName); + return digits == 0; + } + + /// @inheritdoc IPopRules + 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 IPopRules + 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 IPopRules + function priceWithCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRules + function priceWithCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithCheck(name, userAddress, true, pricingVersionValue); + } + + /// @inheritdoc IPopRules + function priceWithoutCheck( + string calldata name, + address userAddress + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, false, 0); + } + + /// @inheritdoc IPopRules + function priceWithoutCheckAtVersion( + string calldata name, + address userAddress, + uint256 pricingVersionValue + ) + external + view + override + returns (PriceWithMeta memory metadata) + { + return _priceWithoutCheck(name, userAddress, true, pricingVersionValue); + } + + /// @notice Shared body for the reservation-enforcing pricing reads. + /// @dev `atVersion` selects the amount source: the current model when false, the model for + /// `pricingVersionValue` when true. Classification, tier gating, and reservation rules are + /// the same on both paths, so they live here once. + function _priceWithCheck( + string calldata name, + address userAddress, + bool atVersion, + uint256 pricingVersionValue + ) + internal + view + returns (PriceWithMeta memory metadata) + { + _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 = IPopRules.PopStatus.Reserved; + } + + return metadata; + } + + /// @inheritdoc IPopRules + function price(string calldata name) external view override returns (uint256) { + _requireLabel(name); + return _priceValidatedName(_validatedBaseLength(name)); + } + + /// @inheritdoc IPopRules + function pricingVersion() external view override returns (uint256 modelVersion) { + return _costModelRegistry().currentVersion(); + } + + /// @inheritdoc IPopRules + 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 IPopRules + 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(DotnsConstantsOld.PERSONHOOD) + .personhoodStatus(account, DotnsConstantsOld.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 `DotnsConstantsOld.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 (IDotnsCostModelRegistryOld registry) { + address configured = protocolRegistry.get(DotnsConstantsOld.COST_MODEL); + require(configured != address(0), PopError("Cost model not configured")); + return IDotnsCostModelRegistryOld(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 - StringUtils.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(IPopRules).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /// @notice Returns implementation version. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Ensures the caller is any controller authorised on the registrar. + function _onlyRegistry() internal view { + DotnsRegistrarOld registrar = + DotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + require(registrar.controllers(IDotnsController(msg.sender)), NotRegistry()); + } + + /// @inheritdoc IPopRules + 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 IPopRules + function stripDigits(string calldata name) external pure override returns (string memory stem) { + _requireLabel(name); + return _stripDigits(name); + } + + /// @inheritdoc IPopRules + 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 IPopRules + 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 b47b48551..b0f23f519 100644 --- a/contracts/registrars/DotnsPopController.sol +++ b/contracts/registrars/DotnsPopController.sol @@ -144,8 +144,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..d6bca3a2f --- /dev/null +++ b/contracts/registrars/DotnsPopControllerOld.sol @@ -0,0 +1,940 @@ +// 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 {IDotnsPopController} from "./IDotnsPopController.sol"; +import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IDotnsPopResolver} from "../resolvers/IDotnsPopResolver.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {ILabelStore} from "../store/ILabelStore.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {RegistrationUtilsOld} from "../utils/RegistrationUtilsOld.sol"; +import {SubnodeUtilsOld} from "../utils/SubnodeUtilsOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.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 IPopRules.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 `IPopRules`. The head of this +/// controller's reservation queue is written through `IPopRules.reserveBaseNameForPop` on +/// every head transition; the slot is cleared through `IPopRules.releaseBaseName` when the +/// queue empties (claim, final relinquish, final expiry). The public commit-reveal +/// controller routes through `IPopRules.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 RegistrationUtilsOld; chat-key and +/// lite-to-full link persistence via +/// @custom:contract IDotnsPopResolver. 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, + IDotnsPopController +{ + using StringUtils 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 IDotnsPopResolver `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. + IDotnsProtocolRegistryOld 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. + function initialize( + IDotnsProtocolRegistryOld registry, + uint64 reservationDuration_ + ) + external + initializer + { + require( + reservationDuration_ >= MIN_RESERVATION_DURATION, + ReservationDurationTooLow(reservationDuration_) + ); + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + reservationDuration = reservationDuration_; + emit ReservationDurationSet(reservationDuration_); + } + + /// @inheritdoc IDotnsPopController + function isPopIssued(string calldata label) external view override returns (bool issued) { + return _popIssued[label]; + } + + /// @inheritdoc IDotnsPopController + function reserveLiteName(LiteRegistration calldata params) external override onlyRoot { + _reserveLite(_popRules(), params); + } + + /// @inheritdoc IDotnsPopController + function reserveBaseName(BaseReservation calldata params) external override onlyRoot { + IPopRules 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 IDotnsPopController + function reserveBaseNameOnly(BaseNameReservation calldata params) external override onlyRoot { + IPopRules 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(IPopRules rules, LiteRegistration calldata params) internal { + require(params.liteLabel.isLitePersonLabel(), InvalidLiteLabel()); + _requireValidChatKey(params.chatKey); + + (IPopRules.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 != IPopRules.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 IDotnsPopController + 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); + + IPopRules rules = _popRules(); + (IPopRules.PopStatus required,) = rules.classifyName(label); + require( + required != IPopRules.PopStatus.Reserved && required != IPopRules.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 IDotnsPopController + function expireReservation(string calldata reservedBaseLabel) external override { + (bytes32 labelhash,) = _validateBaseLabel(reservedBaseLabel); + _advanceExpiredHead(labelhash); + } + + /// @inheritdoc IDotnsPopController + 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 IDotnsPopController + function claimLabelStore() external override returns (bool moreRemaining) { + (, moreRemaining) = _settlePending(msg.sender, DotnsConstantsOld.MAX_PAGE_SIZE); + } + + /// @inheritdoc IDotnsPopController + 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) + { + IStoreFactoryOld 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( + IStoreFactoryOld 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 IDotnsPopController + 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 IDotnsPopController + function setReservationDuration(uint64 duration) external override onlyOwner { + require(duration >= MIN_RESERVATION_DURATION, ReservationDurationTooLow(duration)); + reservationDuration = duration; + emit ReservationDurationSet(duration); + } + + /// @inheritdoc IDotnsPopController + function reservationMeta(bytes32 labelhash) + external + view + override + returns (uint64 head, uint64 tail) + { + ReservationQueueMeta memory meta = _reservationMeta[labelhash]; + return (meta.head, meta.tail); + } + + /// @inheritdoc IDotnsPopController + 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 IDotnsPopController + function userReservation(address user) + external + view + override + returns (UserReservation memory reservation) + { + return _userReservations[user]; + } + + /// @inheritdoc IDotnsPopController + 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 > DotnsConstantsOld.MAX_PAGE_SIZE) count = DotnsConstantsOld.MAX_PAGE_SIZE; + + claims = new PendingClaim[](count); + for (uint256 i; i < count; ++i) { + claims[i] = queue[offset + i]; + } + } + + /// @inheritdoc IDotnsPopController + function pendingClaimCountOf(address user) external view override returns (uint256 count) { + return _pendingClaimQueue[user].length; + } + + /// @inheritdoc IDotnsPopController + function pendingClaimUserCount() external view override returns (uint256 count) { + return _pendingClaimUsers.length(); + } + + /// @inheritdoc IDotnsPopController + 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 > DotnsConstantsOld.MAX_PAGE_SIZE) count = DotnsConstantsOld.MAX_PAGE_SIZE; + + users = new address[](count); + for (uint256 i; i < count; ++i) { + users[i] = _pendingClaimUsers.at(offset + i); + } + } + + /// @inheritdoc IDotnsPopController + 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(IDotnsPopController).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @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 RegistrationUtilsOld.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 IDotnsPopResolver + /// 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 { + RegistrationUtilsOld.registerAndStore( + RegistrationUtilsOld.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: user, + label: "", + labelhash: labelhash, + node: node + }) + ); + } + + if (chatKeyBytes.length != 0 || liteLabelhash != bytes32(0)) { + IDotnsPopResolver 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( + IPopRules 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 StringUtils.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( + IPopRules rules, + string calldata baseLabel + ) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + (labelhash, node) = _validateBaseLabel(baseLabel); + + (IPopRules.PopStatus required,) = rules.classifyName(baseLabel); + require( + required != IPopRules.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 (IDotnsPopResolver) { + return IDotnsPopResolver(protocolRegistry.get(DotnsConstantsOld.POP_RESOLVER)); + } + + /// @notice Resolves the PopRulesOld contract via the protocol registry. + function _popRules() internal view returns (IPopRules) { + return IPopRules(protocolRegistry.get(DotnsConstantsOld.POP_RULES)); + } + + /// @notice Resolves the Store factory via the protocol registry. + function _storeFactory() internal view returns (IStoreFactoryOld) { + return IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); + } + + /// @notice Resolves the registrar via the protocol registry. + function _registrar() internal view returns (IDotnsRegistrarOld) { + return IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + } + + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistryOld) { + return IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.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]; + IPopRules 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 SystemUtilsOld.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(SystemUtilsOld.originIsRoot(), NotRoot()); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/DotnsRegistrar.sol b/contracts/registrars/DotnsRegistrar.sol index cea6760ee..9cc4822fc 100644 --- a/contracts/registrars/DotnsRegistrar.sol +++ b/contracts/registrars/DotnsRegistrar.sol @@ -61,8 +61,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 ac5672266..ce9be3945 100644 --- a/contracts/registrars/DotnsRegistrarController.sol +++ b/contracts/registrars/DotnsRegistrarController.sol @@ -50,6 +50,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; @@ -69,11 +87,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/DotnsRegistrarControllerOld.sol b/contracts/registrars/DotnsRegistrarControllerOld.sol new file mode 100644 index 000000000..afaeae211 --- /dev/null +++ b/contracts/registrars/DotnsRegistrarControllerOld.sol @@ -0,0 +1,484 @@ +// 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 {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +import {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsReverseResolver} from "../resolvers/IDotnsReverseResolver.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {IDotnsCostModelRegistryOld} from "../pop/IDotnsCostModelRegistryOld.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IDotnsRegistrarController} from "./IDotnsRegistrarController.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IDotnsNameWhitelist} from "../whitelist/IDotnsNameWhitelist.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {RegistrationUtilsOld} from "../utils/RegistrationUtilsOld.sol"; +import {StoreUtilsOld} from "../utils/StoreUtilsOld.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.sol"; + +/// @title Dotns Registrar Controller +/// @notice Allocates top-level labels using a commit reveal scheme. +/// @dev Orchestrates allocation, PoP validation, pricing enforcement, forward registry +/// wiring, default reverse resolution, and immutable store writing. +/// +/// Tokenisation: the minted ERC721 tokenId is `uint256(node)`, where +/// `node = namehash(tldNode, labelhash)`. The registry stores a sentinel owner +/// (`address(0)`) for tokenised nodes and derives ownership from the ERC721 registrar for +/// authorisation. +/// @custom:security-contact admin@parity.io +contract DotnsRegistrarControllerOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + ReentrancyGuardTransient, + IDotnsRegistrarController +{ + using StringUtils for *; + using StoreUtilsOld for IStoreFactoryOld; + + /// @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; + + /// @notice Minimum age a commitment must reach before reveal. + uint256 public minCommitmentAge; + + /// @notice Maximum age after which a commitment expires. + uint256 public maxCommitmentAge; + + /// @notice Stores Mapping of commitment hashes to timestamp committed. + mapping(bytes32 hash => uint256 timestamp) public commitments; + + /// @notice Cost-model version stamped on a commitment at commit time. + /// @dev Recorded from the registry's current version when `commit` runs, so the reveal can bind + /// a registration to the version that was current then. A caller cannot commit against an + /// arbitrary earlier, cheaper version: the reveal rejects a `pricingVersion` that differs + /// from this stamp. + mapping(bytes32 hash => uint256 version) public committedPricingVersion; + + /// @dev Reserved slot held so the sequential storage layout stays fixed across the in-place + /// upgrade. Unused: name eligibility lives in @custom:contract DotnsNameWhitelistOld. + /// @custom:oz-renamed-from whiteList + mapping(address account => bool retained) private __whiteListSlot; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @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() { + _disableInitializers(); + } + + /// @notice Initialises the registrar controller. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation revert + /// with @custom:reverts InvalidInitialization, and any nested call outside an active + /// initialiser scope reverts with @custom:reverts NotInitializing. Validates the + /// commitment window bounds: `minAge` must be strictly positive (otherwise + /// @custom:reverts MinCommitmentAgeZero) so a reveal cannot land in the same block as + /// its commit; `maxAge` must exceed `minAge` (otherwise + /// @custom:reverts MaxCommitmentAgeTooLow) and must stay within + /// `MAX_ALLOWED_COMMITMENT_AGE` (otherwise @custom:reverts MaxCommitmentAgeTooHigh) before + /// wiring the protocol registry. + function initialize( + IDotnsProtocolRegistryOld registry, + uint256 minAge, + uint256 maxAge + ) + external + initializer + { + __ERC165_init(); + __Ownable_init(msg.sender); + + require(minAge > 0, MinCommitmentAgeZero()); + require(maxAge > minAge, MaxCommitmentAgeTooLow()); + require(maxAge <= MAX_ALLOWED_COMMITMENT_AGE, MaxCommitmentAgeTooHigh()); + + protocolRegistry = registry; + + minCommitmentAge = minAge; + maxCommitmentAge = maxAge; + } + + /// @inheritdoc IDotnsRegistrarController + function available(string calldata label) public view override returns (bool) { + bytes32 node; + (, node) = _validatedLabelNode(label); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + return registrar.available(uint256(node)); + } + + /// @inheritdoc IDotnsRegistrarController + function makeCommitment(Registration calldata registration) + public + pure + override + returns (bytes32 commitment) + { + commitment = keccak256( + abi.encode( + registration.label, + registration.owner, + registration.secret, + registration.reserved, + registration.maxPrice, + registration.pricingVersion + ) + ); + } + + /// @inheritdoc IDotnsRegistrarController + function commit(bytes32 commitment) external override { + uint256 prior = commitments[commitment]; + require( + prior == 0 || prior + maxCommitmentAge <= block.timestamp, + UnexpiredCommitmentExists(commitment) + ); + + commitments[commitment] = block.timestamp; + committedPricingVersion[commitment] = _currentPricingVersion(); + emit NameCommitted(commitment); + } + + /// @notice Reads the cost model's current version through the protocol registry. + /// @dev Resolved at commit time so the stamp binds the version live then, not at reveal. + /// @return pricingVersion The current cost-model version. + function _currentPricingVersion() internal view returns (uint256 pricingVersion) { + return IDotnsCostModelRegistryOld(protocolRegistry.get(DotnsConstantsOld.COST_MODEL)) + .currentVersion(); + } + + /// @inheritdoc IDotnsRegistrarController + function register(Registration calldata registration) external payable override nonReentrant { + (IDotnsRegistrarOld registrar, bytes32 labelhash, bytes32 node) = + _requireAvailableLabel(registration.label); + _consumeCommitment(registration); + + address escrow = _escrow(); + IPopRules rules = IPopRules(protocolRegistry.get(DotnsConstantsOld.POP_RULES)); + + uint256 tokenId = uint256(node); + bool isReclaim = registrar.exists(tokenId); + + string memory stem = rules.stripDigits(registration.label); + bool stemCanonical = stem.isSingleLabelMemory(); + // Reclaim hands the name back from a prior occupant who may hold a sibling-controller's + // stem reservation, which is garbage once the name moves on, so clear it. Non-reclaim + // paths intentionally leave an existing reservation in place: the slot belongs to the + // sibling controller that wrote it (e.g. the PoP queue head stamp), and clearing it from + // here would brick that controller's release and advance paths. + if (stemCanonical && isReclaim) { + (address reservationOwner,) = rules.getBaseNameReservation(stem); + address expectedOwner = + IDotnsNameEscrow(payable(escrow)).getReleasePosition(tokenId).recipient; + if (reservationOwner != address(0) && reservationOwner == expectedOwner) { + rules.releaseReservationForReclaim(stem, expectedOwner); + } + } + + bool isDirect = msg.sender == registration.owner; + IPopRules.PriceWithMeta memory priced; + if (isDirect) { + priced = rules.priceWithCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); + } else { + priced = rules.priceWithoutCheckAtVersion( + registration.label, registration.owner, registration.pricingVersion + ); + if (priced.status == IPopRules.PopStatus.Reserved) { + (IPopRules.PopStatus required,) = rules.classifyName(registration.label); + if (required == IPopRules.PopStatus.Reserved) { + revert IPopRules.GovernanceReserved(registration.label); + } + revert IPopRules.NameReserved(registration.label); + } + require( + priced.userStatus >= priced.status, + IPopRules.OwnerStatusInsufficient( + registration.label, priced.userStatus, priced.status + ) + ); + } + + uint256 totalCharged = priced.price; + require( + totalCharged <= registration.maxPrice, + PriceExceedsMax(registration.label, totalCharged, registration.maxPrice) + ); + require(msg.value >= totalCharged, InsufficientValue()); + + IDotnsReverseResolver reverse; + bool setReverseRecord; + if (registration.reserved && isDirect) { + reverse = + IDotnsReverseResolver(protocolRegistry.get(DotnsConstantsOld.REVERSE_RESOLVER)); + setReverseRecord = bytes(reverse.nameOf(registration.owner)).length == 0; + } + + _completeRegistration( + registration, labelhash, node, priced.price, setReverseRecord, reverse, isReclaim + ); + + if (isReclaim) { + IDotnsNameEscrow(payable(escrow)).reclaim(tokenId, registration.owner); + // Reclaim hands the NFT to the new holder; rewrite the registry record so the prior + // owner's resolver pointer cannot follow the name. Must run after `escrow.reclaim` + // so the registry's `ownerOf` check sees the new holder, not the escrow. + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)) + .setOwner(node, registration.owner); + } + + _settleEscrow(escrow, tokenId, registration.owner, isDirect, totalCharged); + + if (msg.value > totalCharged) { + uint256 refund = msg.value - totalCharged; + (bool ok,) = payable(msg.sender).call{value: refund}(""); + if (ok) { + emit OverpaymentRefunded(msg.sender, refund); + } else { + IDotnsNameEscrow(payable(escrow)).creditOverpayment{value: refund}(msg.sender); + } + } + } + + /// @notice Settles every escrow side-effect of a successful registration. + /// @dev Extracted to keep `register` under the stack-depth ceiling. On a direct + /// registration the full `chargeAmount` lands in the refundable deposit position + /// keyed to `nameOwner`. On a cross-payer registration the deposit position is + /// seeded with a zero amount so the release lifecycle stays reachable, and the same + /// `chargeAmount` routes to the protocol fee pot via `depositProtocolFee` keyed to + /// `msg.sender` as the payer. + function _settleEscrow( + address escrow, + uint256 tokenId, + address nameOwner, + bool isDirect, + uint256 chargeAmount + ) + internal + { + uint256 depositAmount = isDirect ? chargeAmount : 0; + IDotnsNameEscrow(payable(escrow)).deposit{value: depositAmount}( + IDotnsNameEscrow.DepositParams({ + tokenId: tokenId, asset: address(0), amount: depositAmount, recipient: nameOwner + }) + ); + + if (!isDirect && chargeAmount > 0) { + IDotnsNameEscrow(payable(escrow)).depositProtocolFee{value: chargeAmount}( + IDotnsNameEscrow.ProtocolFeeDepositParams({ + tokenId: tokenId, payer: msg.sender, recipient: nameOwner + }) + ); + } + } + + /// @inheritdoc IDotnsRegistrarController + function registerReserved(Registration calldata registration) external override nonReentrant { + // Read Root once, up front. Everything below must stay callable under a substrate Root + // origin, which has no account, so no branch may read `msg.sender`: the grant is checked + // against `registration.owner`, the commitment is keyed on its own hash, and the mint + // targets the owner. + bool isRoot = SystemUtilsOld.originIsRoot(); + IDotnsNameWhitelist whitelist; + if (!isRoot) { + whitelist = _nameWhitelist(); + require( + whitelist.isGrantedTo(registration.label, registration.owner), + NameNotGranted(registration.label, registration.owner) + ); + } + + (, bytes32 labelhash, bytes32 node) = _requireAvailableLabel(registration.label); + _consumeCommitment(registration); + + // Spend the grant before minting so a grant in the wrong state fails before any name is + // issued. Root skips it: a governance mint must not consume a grant held by someone else. + // + // A consequence worth knowing: if Root mints a label that is `Claimed` or `Reserved` on + // the whitelist, that record survives the mint. The beneficiary's own `registerReserved` + // then fails `NameNotAvailable`, and the node stays in the whitelist's active set until + // governance calls `revokeName`. Nothing is lost, but the grant is stranded. + if (!isRoot) { + whitelist.consume(registration.label, registration.owner); + } + + // No reverse record. `setReverseName` overwrites unconditionally, and the gate above lets + // anyone submit for the beneficiary, so writing here would let a third party relabel + // another address. The owner claims their own record through `claimReverseRecord`, which + // checks ownership and writes only their own key. + _completeRegistration( + registration, labelhash, node, 0, false, IDotnsReverseResolver(address(0)), false + ); + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable, IERC165) + returns (bool) + { + return interfaceId == type(IDotnsRegistrarController).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Validates label shape and derives `(labelhash, node)`. + /// @dev Delegates hashing to @custom:contract LabelUtils so the assembly sequence lives in + /// exactly one place across the codebase. Error ownership stays on this interface: shape + /// violations revert with `InvalidLabel()`; labels below the minimum length revert with + /// `LabelTooShort(label)` so off-chain consumers can distinguish "shape-valid but below + /// the policy minimum" from "shape-valid but already minted". + function _validatedLabelNode(string calldata label) + internal + view + returns (bytes32 labelhash, bytes32 node) + { + require(label.isSingleLabel(), InvalidLabel()); + require(bytes(label).length >= 3, LabelTooShort(label)); + (labelhash, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + function _requireAvailableLabel(string calldata label) + internal + view + returns (IDotnsRegistrarOld registrar, bytes32 labelhash, bytes32 node) + { + (labelhash, node) = _validatedLabelNode(label); + registrar = IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + require(registrar.available(uint256(node)), NameNotAvailable(label)); + } + + function _consumeCommitment(Registration calldata registration) internal { + bytes32 commitment = makeCommitment(registration); + uint256 committedAt = commitments[commitment]; + + require(committedAt != 0, CommitmentNotFound(commitment)); + require( + committedAt + minCommitmentAge <= block.timestamp, + CommitmentTooNew(commitment, committedAt + minCommitmentAge, block.timestamp) + ); + require( + committedAt + maxCommitmentAge > block.timestamp, + CommitmentTooOld(commitment, committedAt + maxCommitmentAge, block.timestamp) + ); + + uint256 stamped = committedPricingVersion[commitment]; + require( + registration.pricingVersion == stamped, + IDotnsCostModelRegistryOld.PricingVersionMismatch(stamped, registration.pricingVersion) + ); + + delete commitments[commitment]; + delete committedPricingVersion[commitment]; + } + + /// @notice Completes a commit-reveal registration: mints (or skips when reclaiming), + /// wires forward registry, optionally sets the reverse record, and writes the owner's + /// Store. + /// @dev On a fresh mint the triad of mint + forward-registry + store-write is delegated + /// to @custom:function RegistrationUtilsOld.registerAndStore, the single canonical + /// implementation shared across every DotNS registration flow. On a reclaim the mint step is + /// skipped (the + /// escrow has already moved custody) and only the registry wiring and store write run. + /// Reverse-record setting and the priced-registration event stay here because they are + /// commit-reveal-specific policy. + function _completeRegistration( + Registration calldata registration, + bytes32 labelhash, + bytes32 node, + uint256 baseCost, + bool setReverseRecord, + IDotnsReverseResolver reverse, + bool isReclaim + ) + internal + { + address labelStore; + if (!isReclaim) { + labelStore = RegistrationUtilsOld.registerAndStore( + RegistrationUtilsOld.RegistrationContext({ + protocolRegistry: protocolRegistry, + user: registration.owner, + label: registration.label, + labelhash: labelhash, + node: node + }) + ); + } else { + // Registry reset on reclaim is deferred until after `escrow.reclaim` runs (see + // @custom:function register) so the registry's `ownerOf` check sees the new holder. + IStoreFactoryOld factory = + IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); + string memory fullName = string.concat(registration.label, protocolRegistry.tld()); + labelStore = factory.writeLabel(registration.owner, node, fullName); + } + + if (setReverseRecord) { + reverse.setReverseName( + registration.owner, string.concat(registration.label, protocolRegistry.tld()) + ); + } + + emit NameRegistered(registration.label, labelhash, registration.owner, baseCost, labelStore); + } + + /// @notice Returns the configured name escrow from the protocol registry. + function _escrow() internal view returns (address escrow) { + escrow = protocolRegistry.get(DotnsConstantsOld.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @notice Returns the configured name whitelist from the protocol registry. + function _nameWhitelist() internal view returns (IDotnsNameWhitelist whitelist) { + address configured = protocolRegistry.get(DotnsConstantsOld.NAME_WHITELIST); + require(configured != address(0), WhitelistNotConfigured()); + whitelist = IDotnsNameWhitelist(configured); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/DotnsRegistrarOld.sol b/contracts/registrars/DotnsRegistrarOld.sol new file mode 100644 index 000000000..d0391ca7d --- /dev/null +++ b/contracts/registrars/DotnsRegistrarOld.sol @@ -0,0 +1,473 @@ +// 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 {IDotnsRegistrarOld} from "./IDotnsRegistrarOld.sol"; +import {IDotnsController} from "./IDotnsController.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; + +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {ILabelStore} from "../store/ILabelStore.sol"; +import {StoreUtilsOld} from "../utils/StoreUtilsOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {IDotnsNameEscrow} from "../escrow/IDotnsNameEscrow.sol"; +import {IPopRules} from "../pop/IPopRules.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.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 IPopRules. 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, + IDotnsRegistrarOld +{ + using StoreUtilsOld for IStoreFactoryOld; + using StringUtils 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. + IDotnsProtocolRegistryOld 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 `DotnsConstantsOld.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. + function initialize( + string calldata name, + string calldata symbol, + IDotnsProtocolRegistryOld registry + ) + external + initializer + { + require(address(registry) != address(0), ProtocolRegistryRequired()); + __Ownable_init(msg.sender); + __ERC721_init(name, symbol); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistrarOld + function addController(IDotnsController controller) external onlyOwner { + controllers[controller] = true; + emit ControllerAdded(controller); + } + + /// @inheritdoc IDotnsRegistrarOld + function removeController(IDotnsController controller) external onlyOwner { + controllers[controller] = false; + emit ControllerRemoved(controller); + } + + /// @inheritdoc IDotnsRegistrarOld + function available(uint256 id) public view override returns (bool isAvailable) { + address holder = _ownerOf(id); + if (holder == address(0)) return true; + + address escrow = protocolRegistry.get(DotnsConstantsOld.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 IDotnsRegistrarOld + 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(DotnsConstantsOld.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 IDotnsPopController.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(DotnsConstantsOld.POP_CONTROLLER); + if (soulbound) _soulbound[id] = true; + if (bytes(label).length != 0) _writeOwnerLabel(owner, id, label); + emit NameRegistered(id, owner, soulbound); + } + + /// @inheritdoc IDotnsRegistrarOld + 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 IDotnsRegistrarOld + 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 IDotnsRegistrarOld + function transferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.transferFrom(from, to, tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IDotnsRegistrarOld + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) + public + payable + override(ERC721Upgradeable, IDotnsRegistrarOld) + { + super.safeTransferFrom(from, to, tokenId, data); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc IDotnsRegistrarOld + function exists(uint256 tokenId) external view override returns (bool tokenExists) { + tokenExists = _exists(tokenId); + } + + /// @inheritdoc IDotnsRegistrarOld + 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. + IDotnsProtocolRegistryOld registry = protocolRegistry; + address escrow = registry.get(DotnsConstantsOld.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + IStoreFactoryOld factory = IStoreFactoryOld(registry.get(DotnsConstantsOld.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( + IStoreFactoryOld 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 `StoreUtilsOld.ensureLabelStore`. + return; + } + factory.writeLabel(to, bytes32(tokenId), fullName); + } + + /// @notice Reads the full name (`label.tld`) for `tokenId` from `holder`'s `LabelStore` using + /// a caller-supplied factory. + function _readLabelFor( + IStoreFactoryOld 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(DotnsConstantsOld.NAME_ESCROW); + } + + /// @notice Resolves the configured PoP rules contract from the protocol registry. + function _popRules() private view returns (IPopRules rules) { + rules = IPopRules(protocolRegistry.get(DotnsConstantsOld.POP_RULES)); + } + + /// @notice Resolves the configured store factory from the protocol registry. + function _storeFactory() private view returns (IStoreFactoryOld factory) { + factory = IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.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() + .writeLabel(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); + + IDotnsProtocolRegistryOld registry = protocolRegistry; + escrow = registry.get(DotnsConstantsOld.NAME_ESCROW); + require(escrow != address(0), EscrowNotConfigured()); + + bool isEscrowTouching = to == escrow || from == escrow; + IStoreFactoryOld factory = IStoreFactoryOld(registry.get(DotnsConstantsOld.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( + IDotnsProtocolRegistryOld registry, + IStoreFactoryOld 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 = + IPopRules(registry.get(DotnsConstantsOld.POP_RULES)).transferFloor(label, from, to); + requiredFee = transferFee; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registrars/IDotnsRegistrarOld.sol b/contracts/registrars/IDotnsRegistrarOld.sol new file mode 100644 index 000000000..d6b120146 --- /dev/null +++ b/contracts/registrars/IDotnsRegistrarOld.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IERC721} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {IDotnsController} from "./IDotnsController.sol"; + +/// @title Dotns Registrar +/// @notice ERC721-backed ownership for DotNS names with controller-gated registration. +/// @dev Intentionally minimal and policy-free. Provides ERC721 ownership for registered name +/// token IDs and controller-gated registration; pricing, PoP enforcement, and flow-specific +/// policy live in the controllers. +/// @custom:security-contact admin@parity.io +interface IDotnsRegistrarOld is IERC721 { + /// @notice Thrown when a name is already registered. + error NameNotAvailable(uint256 tokenId); + + /// @notice Thrown when the caller is not an authorised controller. + error NotController(address caller); + + /// @notice Thrown when the protocol registry has no escrow address configured. + error EscrowNotConfigured(); + + /// @notice Thrown when a standard ERC721 transfer is attempted but the recipient + /// tier requires a non-zero transfer fee and the caller forwarded no `msg.value`. + error TransferFeeRequired(uint256 tokenId, address to, uint256 requiredFee); + + /// @notice Thrown when @custom:function initialize is called with the zero address as + /// the protocol registry. + error ProtocolRegistryRequired(); + + /// @notice Thrown when a mint, burn, or self-transfer carries `msg.value`. None of those + /// paths forward value onward, so attached value would be permanently trapped. + error UnexpectedValue(); + + /// @notice Thrown when @custom:function register is called with the escrow address as + /// `owner`, which would mint directly into escrow custody with no @custom:struct + /// ReleasePosition recorded. + error InvalidOwner(); + + /// @notice Thrown when @custom:function register receives an empty or non-canonical + /// label. + error InvalidLabel(); + + /// @notice Thrown when a transfer or a transfer-fee quote targets a soulbound name. + /// @dev Soulbound names are minted through the PoP gateway and are permanently + /// non-transferable. Raised by the `_update` transfer gate and by + /// @custom:function quoteTransferFee. + error NameSoulbound(uint256 tokenId); + + /// @notice Emitted when a name is registered. + /// @param id The token id (namehash node) that was minted. + /// @param owner The address that received the name. + /// @param soulbound True when the name is soulbound: PoP-gateway minted and non-transferable. + /// Lets indexers classify registrations without a per-token @custom:function isSoulbound read. + event NameRegistered(uint256 indexed id, address indexed owner, bool soulbound); + + /// @notice Emitted when a controller is added. + /// @dev Typed as the shared baseline @custom:contract IDotnsController so the commit-reveal + /// controller and the PoP controller (and any future controller) all fit the same signature + /// without + /// the registrar depending on any specific controller interface. + event ControllerAdded(IDotnsController indexed controller); + + /// @notice Emitted when a controller is removed. + event ControllerRemoved(IDotnsController indexed controller); + + /// @notice Returns whether a registration call may proceed for `id`. + /// @dev Signals two distinct paths to the controller. Returns `true` when the owner slot is + /// empty (a fresh @custom:function register call may mint) OR when the current owner is + /// the configured escrow and the released position's redeem window has elapsed (the + /// controller must then route through @custom:function IDotnsNameEscrow.reclaim instead of + /// @custom:function register, because `register` calls `_mint` which rejects existing + /// tokens). All other holders return `false`. The controller distinguishes the two `true` + /// cases via @custom:function exists. + /// Escrow custody inside the redeem window returns `false`: that window belongs to the + /// previous holder, who may still @custom:function IDotnsNameEscrow.redeem the name, and + /// reclaim would revert until it elapses. Clients wanting the exact moment a released name + /// becomes registrable should read `redeemableUntil` from + /// @custom:function IDotnsNameEscrow.getReleasePosition. + function available(uint256 id) external view returns (bool isAvailable); + + /// @notice Registers a name permanently. + /// @dev Permanence is by construction: there is no `expire`, `renew`, or `release` path on + /// the registrar. Custody only moves via ERC721 transfers (which the registrar polices via + /// the fee-on-transfer hook) or via escrow reclaim. Restricted to authorised controllers + /// (otherwise @custom:reverts NotController) and rejects ids that are not available + /// (otherwise @custom:reverts NameNotAvailable). Emits @custom:emits NameRegistered on + /// success. + /// @dev When the caller is the address registered under `DotnsConstantsOld.POP_CONTROLLER`, the + /// name is marked soulbound and becomes permanently non-transferable (see + /// @custom:function isSoulbound). Provenance is read from the protocol registry at mint time, + /// so no other authorised controller can mint a soulbound name and the PoP controller cannot + /// mint an unlocked one. Public registrations from any other controller stay transferable. + /// @param label The human-readable label string (e.g. "alice"). + function register(uint256 id, address owner, string calldata label) external; + + /// @notice Returns whether a token is soulbound (PoP-gateway minted and non-transferable). + /// @dev Durable on-chain marker set once at mint by @custom:function register and never + /// cleared. A `true` result means every transfer overload reverts with + /// @custom:reverts NameSoulbound and @custom:function quoteTransferFee reverts likewise. + /// @param tokenId The name's token id. + /// @return soulbound True when the name was minted through the PoP gateway. + function isSoulbound(uint256 tokenId) external view returns (bool soulbound); + + /// @notice Returns whether a given token id has been minted. + function exists(uint256 tokenId) external view returns (bool tokenExists); + + /// @notice Adds an authorised controller. + /// @dev Typed against the baseline `IDotnsController` (not a concrete subtype) so a single + /// authorisation surface accepts every controller flavour (commit-reveal, PoP gateway, + /// future variants) without per-flavour setters. Owner-gated (otherwise + /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerAdded on + /// success. + function addController(IDotnsController controller) external; + + /// @notice Removes an authorised controller. + /// @dev Mirrors the @custom:function addController baseline-typed signature so any registered + /// controller can + /// be revoked through the same entry point. Owner-gated (otherwise + /// @custom:reverts OwnableUnauthorizedAccount); emits @custom:emits ControllerRemoved on + /// success. + function removeController(IDotnsController controller) external; + + /// @notice Returns whether `controller` is currently authorised to call + /// @custom:function register. + /// @param controller Candidate controller. + /// @return authorised True when `controller` was added via @custom:function addController and + /// has not been removed. + function controllers(IDotnsController controller) external view returns (bool authorised); + + /// @notice Returns the human-readable label a token was registered with. + /// @dev Canonical state source for the label string; any client that holds a node or + /// tokenId can resolve the original label in one view call without scanning registration + /// events. Returns the empty string when the token does not exist. + function labelOf(uint256 tokenId) external view returns (string memory label); + + /// @notice Quotes the additional native fee required to transfer a token to `to`. + /// @dev Returns the fee from @custom:function PopRulesOld.transferFloor: the name's own price + /// as the maximum of (i) the reach component charged when the recipient does not meet + /// the label's required tier and (ii) the downgrade component charged when the + /// recipient tier is strictly below the sender tier. Self-transfers and + /// escrow-touching transfers (release into escrow, reclaim out of escrow) return + /// zero. A token whose sender has no stored label also returns zero because there + /// is no label-derived price to charge against. Soulbound names are non-transferable + /// and have no transfer price, so a soulbound `tokenId` reverts with + /// @custom:reverts NameSoulbound rather than returning zero. Rejects a zero `to` with + /// @custom:reverts ERC721InvalidReceiver, an unminted `tokenId` with + /// @custom:reverts ERC721NonexistentToken via the underlying `ownerOf`, and requires + /// the protocol registry to have an escrow configured (otherwise + /// @custom:reverts EscrowNotConfigured). Returns zero when the protocol registry + /// has no `STORE_FACTORY` configured because no label-derived price is reachable. + function quoteTransferFee( + uint256 tokenId, + address to + ) + external + view + returns (uint256 requiredFee); + + /// @inheritdoc IERC721 + /// @dev The registrar's `_update` hook consults @custom:function PopRulesOld.transferFloor + /// to compute the required transfer fee; if the caller does not forward at least that + /// amount as `msg.value`, the transfer reverts with @custom:reverts TransferFeeRequired. + /// A soulbound name is non-transferable and reverts with @custom:reverts NameSoulbound. + /// The `payable` modifier on every transfer overload exists so the fee can be forwarded + /// in the same call. + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes calldata data + ) + external + payable + override; + + /// @inheritdoc IERC721 + /// @dev Subject to the same fee-on-transfer gate as the four-argument overload; reverts with + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and + /// the caller has not forwarded it as `msg.value`, and with @custom:reverts NameSoulbound when + /// the token is soulbound. + function safeTransferFrom(address from, address to, uint256 tokenId) external payable override; + + /// @inheritdoc IERC721 + /// @dev Subject to the same fee-on-transfer gate as the safe overloads; reverts with + /// @custom:reverts TransferFeeRequired when the recipient owes a non-zero transfer fee and + /// the caller has not forwarded it as `msg.value`, and with @custom:reverts NameSoulbound when + /// the token is soulbound. + function transferFrom(address from, address to, uint256 tokenId) external payable override; +} diff --git a/contracts/registry/DotnsProtocolRegistry.sol b/contracts/registry/DotnsProtocolRegistry.sol index 4af2517c4..fdae168ac 100644 --- a/contracts/registry/DotnsProtocolRegistry.sol +++ b/contracts/registry/DotnsProtocolRegistry.sol @@ -49,7 +49,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/DotnsProtocolRegistryOld.sol b/contracts/registry/DotnsProtocolRegistryOld.sol new file mode 100644 index 000000000..35f91d2ee --- /dev/null +++ b/contracts/registry/DotnsProtocolRegistryOld.sol @@ -0,0 +1,112 @@ +// 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 {IDotnsProtocolRegistryOld} from "./IDotnsProtocolRegistryOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; + +/// @title Dotns Protocol Registry +/// @author Parity +/// @notice Upgradeable address registry for all DotNS protocol contracts, and the authority for +/// the network's top-level domain. +/// @dev Single source of truth for sibling-contract lookups. All siblings resolve each other via +/// well-known `bytes32` constants in `DotnsConstantsOld` rather than holding direct addresses, +/// so an upgrade or rewire only mutates this contract. The TLD node and suffix are set once +/// at initialisation and read live by every consumer, so a network runs one TLD without +/// recompiling its contracts. +/// @custom:security-contact admin@parity.io +contract DotnsProtocolRegistryOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + IDotnsProtocolRegistryOld +{ + using StringUtils for string; + + /// @notice Address stored for each well-known protocol key. + mapping(bytes32 key => address addr) private _addresses; + + /// @notice Reference count per address, incremented for every key it is registered under. + /// @dev Lets `isRegisteredAddress` answer in O(1) and survive a contract being mapped to + /// multiple keys without being treated as deregistered when only one key is rewired. + mapping(address addr => uint256 refcount) private _registeredRefcount; + + /// @notice Namehash of the TLD node, `namehash(0, keccak256(bytes(tldLabel)))`. + bytes32 private _tldNode; + + /// @notice TLD suffix including the leading dot, e.g. `.dot`. + string private _tld; + + uint256[50] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the protocol registry and fixes the network's TLD. + /// @dev Callable exactly once via `Initializable`, otherwise + /// @custom:reverts InvalidInitialization. Sets the deployer as owner. `tldLabel` is the + /// bare label without a dot (e.g. `dot`, `paseo`); it must be a single DNS label, + /// otherwise @custom:reverts InvalidTld. The TLD is fixed here because changing it after + /// names exist would reroot every node. + /// @param tldLabel Bare TLD label, without the leading dot. + function initialize(string calldata tldLabel) external initializer { + __Ownable_init(msg.sender); + + require(tldLabel.isSingleLabel(), InvalidTld()); + _tldNode = LabelUtils.namehashUnder(bytes32(0), LabelUtils.labelhash(tldLabel)); + _tld = string.concat(".", tldLabel); + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function get(bytes32 key) external view override returns (address addr) { + return _addresses[key]; + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function set(bytes32 key, address addr) external override onlyOwner { + require(addr != address(0), ZeroAddress()); + + address previousAddress = _addresses[key]; + if (previousAddress == addr) return; + + if (previousAddress != address(0)) { + --_registeredRefcount[previousAddress]; + } + ++_registeredRefcount[addr]; + + _addresses[key] = addr; + emit AddressUpdated(key, addr); + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function isRegisteredAddress(address addr) external view override returns (bool registered) { + return addr != address(0) && _registeredRefcount[addr] > 0; + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function tldNode() external view override returns (bytes32 node) { + return _tldNode; + } + + /// @inheritdoc IDotnsProtocolRegistryOld + function tld() external view override returns (string memory suffix) { + return _tld; + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registry/DotnsRegistryOld.sol b/contracts/registry/DotnsRegistryOld.sol new file mode 100644 index 000000000..0acef37cc --- /dev/null +++ b/contracts/registry/DotnsRegistryOld.sol @@ -0,0 +1,310 @@ +// 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 {IDotnsRegistryOld} from "./IDotnsRegistryOld.sol"; +import {IDotnsController} from "../registrars/IDotnsController.sol"; +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {StoreUtilsOld} from "../utils/StoreUtilsOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {IDotnsProtocolRegistryOld} from "./IDotnsProtocolRegistryOld.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.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 +/// `IDotnsRegistrarOld.ownerOf`; subnodes carry an explicit owner address in `records`. +/// @custom:security-contact admin@parity.io +contract DotnsRegistryOld is Initializable, UUPSUpgradeable, OwnableUpgradeable, IDotnsRegistryOld { + using StoreUtilsOld for IStoreFactoryOld; + using StringUtils for *; + + /// @notice Mapping of node identifiers to records. + mapping(bytes32 node => Record record) private records; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld 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 registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + + require(address(registry) != address(0), NotAllowed()); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsRegistryOld + function setSubnodeOwner(SubnodeRecord calldata record) + external + override + authorised(record.parentNode) + returns (bytes32 subnode) + { + address newOwner = record.owner; + require(newOwner != address(0), NotAllowed()); + + 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(DotnsConstantsOld.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 IDotnsRegistryOld + function setOwner(bytes32 node, address newOwner) external override onlyRegistrarController { + require(newOwner != address(0), NotAllowed()); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.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(DotnsConstantsOld.REVERSE_RESOLVER), + exists: true + }); + + emit NodeTransferred(node, newOwner); + } + + /// @inheritdoc IDotnsRegistryOld + function setResolver(bytes32 node, address newResolver) external override authorised(node) { + records[node].resolver = newResolver; + emit NewResolver(node, newResolver); + } + + /// @inheritdoc IDotnsRegistryOld + 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 IDotnsRegistryOld + 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); + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + return registrar.ownerOf(uint256(node)); + } + + /// @inheritdoc IDotnsRegistryOld + function resolver(bytes32 node) external view override returns (address) { + return records[node].resolver; + } + + /// @inheritdoc IDotnsRegistryOld + function recordExists(bytes32 node) external view override returns (bool) { + return records[node].exists; + } + + /// @inheritdoc IDotnsRegistryOld + 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 + { + IStoreFactoryOld factory = + IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)); + factory.writeLabel(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; + + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.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 { + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + require(registrar.controllers(IDotnsController(msg.sender)), NotAuthorised()); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/registry/IDotnsProtocolRegistryOld.sol b/contracts/registry/IDotnsProtocolRegistryOld.sol new file mode 100644 index 000000000..16c4eb847 --- /dev/null +++ b/contracts/registry/IDotnsProtocolRegistryOld.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsProtocolRegistryOld +/// @author Parity +/// @notice Interface for the DotNS protocol-level address registry. +/// @dev Single source of truth for sibling lookups. Contracts resolve each other via well-known +/// `bytes32` constants in `DotnsConstantsOld` so an upgrade or rewire only mutates the +/// registry, never the consumers. The registry also holds the network's top-level domain, +/// so every consumer reads one TLD rather than compiling its own. +/// @custom:security-contact admin@parity.io +interface IDotnsProtocolRegistryOld { + /// @notice Emitted when a protocol address is set or updated. + event AddressUpdated(bytes32 indexed key, address indexed addr); + + /// @notice Thrown when a zero address is provided where one is not allowed. + error ZeroAddress(); + + /// @notice Thrown when the TLD label supplied at initialisation is not a single DNS label. + error InvalidTld(); + + /// @notice Returns the address stored for a given key. + /// @dev Returns `address(0)` when the key is unset; callers must validate when non-zero is + /// required. + function get(bytes32 key) external view returns (address addr); + + /// @notice Sets or updates the address for a given key. + /// @dev Owner-restricted, otherwise @custom:reverts OwnableUnauthorizedAccount. `addr` + /// must be non-zero, otherwise @custom:reverts ZeroAddress. Idempotent when the new + /// value matches the stored one (no event emitted in that case). Maintains a + /// per-address refcount so the same contract can occupy multiple keys without losing + /// its registered status until every key is rewired. Emits + /// @custom:emits AddressUpdated on each effective change. + function set(bytes32 key, address addr) external; + + /// @notice Returns true iff `addr` is currently registered under at least one well-known key. + /// @dev O(1) refcount-backed lookup. Canonical peer-trust check consumed by `LabelStore` + /// writes and `StoreFactory` deploys; only addresses governance has actively + /// registered return true. Treats `address(0)` as never registered regardless of + /// refcount. + function isRegisteredAddress(address addr) external view returns (bool registered); + + /// @notice Returns the namehash of the network's TLD node. + /// @dev `namehash(0, keccak256(bytes(tldLabel)))`, fixed at initialisation. Consumers use it + /// as the root parent when deriving a name's node. + function tldNode() external view returns (bytes32 node); + + /// @notice Returns the network's TLD suffix, including the leading dot (e.g. `.dot`). + /// @dev Fixed at initialisation. Consumers append it when rendering a label as a full name. + function tld() external view returns (string memory suffix); +} diff --git a/contracts/registry/IDotnsRegistryOld.sol b/contracts/registry/IDotnsRegistryOld.sol new file mode 100644 index 000000000..892ae3edb --- /dev/null +++ b/contracts/registry/IDotnsRegistryOld.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IDotnsRegistryOld +/// @author Parity +/// @notice Minimal on-chain registry for hierarchical name ownership and resolution. +/// @dev Tokenised second-level nodes are owned through the registrar (ERC-721); subnodes are +/// owned directly by the address stored in `Record.owner`. +/// @custom:security-contact admin@parity.io +interface IDotnsRegistryOld { + /// @notice Record describing a subnode creation request. + /// @param subLabel Human readable subnode label e.g "alice". + /// @param parentLabel Canonical parent name without the TLD suffix e.g. bob or child.bob. + /// @param owner Address to assign as owner of the created subnode. + /// @param persist Whether to index the subnode into the owner's `LabelStore`, deploying it on + /// demand. When false the ownership and resolver record is still written, but the store + /// is left untouched. The store write is gated to protocol store writers (the registrar + /// and its controllers), not the name owner, so a deferring writer indexes the label + /// itself by deploying the owner's store and writing to it. The registry writes the + /// store only on creation or on a reassignment to a new owner, so a later same-owner re-call + /// with `persist` true does not backfill it; the authorised writer backfills it + /// directly. + struct SubnodeRecord { + bytes32 parentNode; + string subLabel; + string parentLabel; + address owner; + bool persist; + } + + /// @notice Record describing the state of a node. + /// @param owner Address that owns the node, or address(0) sentinel for tokenised nodes. + /// @param resolver Address of the resolver associated with the node. + /// @param exists Whether the node has been explicitly created. + struct Record { + address owner; + address resolver; + bool exists; + } + + /// @notice Emitted when a new subnode owner is set. + /// @param node Parent node. + /// @param label Labelhash of the created subnode. + event NewOwner(bytes32 indexed node, bytes32 indexed label, address owner); + + /// @notice Emitted when ownership of a node is transferred. + event NodeTransferred(bytes32 indexed node, address owner); + + /// @notice Emitted when a resolver is set or updated. + event NewResolver(bytes32 indexed node, address resolver); + + /// @notice Thrown when an invalid (zero) address is provided. + error NotAllowed(); + + /// @notice Thrown when the caller is not authorised. + error NotAuthorised(); + + /// @notice Thrown when the caller is not the registry controller. + error NotRegistryController(); + + /// @notice Thrown when attempting to create a node that already exists. + error NodeAlreadyOwned(bytes32 node); + + /// @notice Thrown when a sublabel is not a canonical lowercase ASCII DNS label. + error InvalidLabel(); + + /// @notice Thrown when the supplied parent label does not match the parent node. + error ParentLabelMismatch(); + + /// @notice Record describing a subnode resolver update request. + /// @param subLabel Human-readable subnode label e.g "alice". + /// @param parentLabel Canonical parent name without the TLD suffix e.g bob or child.bob. + /// @param resolver Resolver contract address (zero clears). + struct SubnodeResolverRecord { + bytes32 parentNode; + string subLabel; + string parentLabel; + address resolver; + } + + /// @notice Creates or reassigns a subnode and assigns its owner. + /// @dev Callable only by the current owner of `record.parentNode`, otherwise + /// @custom:reverts NotAuthorised. The new owner address must be non-zero, otherwise + /// @custom:reverts NotAllowed. `record.subLabel` must be a single canonical DNS label + /// (otherwise @custom:reverts InvalidLabel) and `record.parentLabel` must be a name + /// path whose namehash matches `record.parentNode` (otherwise + /// @custom:reverts ParentLabelMismatch). Subnodes are parent-sovereign: the current + /// `record.parentNode` owner may reassign or rotate a subnode's resolver at any time + /// without the prior subnode owner's consent. On reassignment the resolver pointer is + /// reset to the protocol-registered default reverse resolver so a prior subnode + /// owner's resolver cannot be inherited by the next holder (records on other resolver + /// contracts are keyed by node and are not cleared by this function; downstream + /// consumers should gate resolver reads on current ownership). Indexes the subnode + /// under the new owner's `LabelStore` keyed by the namehashed `subnode` so off-chain + /// consumers can enumerate names per address. Indexing into the owner's `LabelStore` is + /// governed by `record.persist` (see @custom:struct SubnodeRecord); the ownership and + /// resolver record is written either way. Emits @custom:emits NewOwner on each successful + /// assignment. + function setSubnodeOwner(SubnodeRecord calldata record) external returns (bytes32 subnode); + + /// @notice Sets the resolver for an existing subnode. + /// @dev Callable only by the current owner of `record.parentNode`, otherwise + /// @custom:reverts NotAuthorised. The subnode owner can still update the resolver + /// directly via `setResolver`. Both entry points emit the same `NewResolver(subnode, + /// ...)` event, so the parent can silently override a subnode owner's chosen resolver: + /// this is the parent-sovereign hierarchy applied to resolution. Off-chain consumers + /// that surface trust signals to subnode owners should treat any resolver rotation as + /// a re-attestation prompt. `record.subLabel` must be a single canonical DNS label + /// (otherwise @custom:reverts InvalidLabel) and `record.parentLabel` must be a name + /// path whose namehash matches `record.parentNode` (otherwise + /// @custom:reverts ParentLabelMismatch). The resulting subnode must already exist, + /// otherwise @custom:reverts NotAuthorised. Emits @custom:emits NewResolver on + /// success. + function setSubnodeResolver(SubnodeResolverRecord calldata record) external; + + /// @notice Creates or resets a node record for a tokenised base registration. + /// @dev Restricted to the registrar's controllers, otherwise @custom:reverts NotAuthorised. + /// `newOwner` must be non-zero (otherwise @custom:reverts NotAllowed) and must match + /// the ERC-721 owner reported by the registrar (otherwise + /// @custom:reverts NotAuthorised). The function is callable both on a fresh + /// registration and on every reclaim from escrow: each call rewrites + /// `records[node].resolver` to the protocol-registered default reverse resolver so a + /// prior owner's resolver pointer (and the records keyed under it) cannot be inherited + /// by the next holder across that recycle. A secondary-market ERC-721 `transferFrom` does + /// not call the registry, so a name sold directly keeps the seller's resolver pointer + /// until the buyer overwrites it. Stores `owner = address(0)` as a sentinel so reads + /// delegate to `IDotnsRegistrarOld.ownerOf` and ERC-721 transfers remain authoritative. + /// Emits + /// @custom:emits NodeTransferred on success. + function setOwner(bytes32 node, address newOwner) external; + + /// @notice Sets or clears the resolver for a node. + /// @dev Callable only by the current node owner, otherwise @custom:reverts NotAuthorised. + /// For tokenised nodes, authorisation falls back to ERC-721 owner / approved / + /// operator-for-all via the registrar. The registry does not validate + /// `resolverAddr` against any interface or code presence; off-chain consumers must + /// verify resolver shape before trusting reads. Emits @custom:emits NewResolver on + /// success. + /// @param resolverAddr Resolver contract address (zero clears). + function setResolver(bytes32 node, address resolverAddr) external; + + /// @notice Returns the owner of a node. + /// @dev For tokenised nodes the stored owner is the zero sentinel; the implementation falls + /// back to `IDotnsRegistrarOld.ownerOf(uint256(node))`. + function owner(bytes32 node) external view returns (address); + + /// @notice Returns the resolver of a node. + function resolver(bytes32 node) external view returns (address); + + /// @notice Returns whether a node exists. + function recordExists(bytes32 node) external view returns (bool); + + /// @notice Returns whether `account` is authorised to manage `node`. + /// @dev For subnodes, authority is the explicit stored owner. For tokenised nodes it is the + /// ERC-721 owner, an address approved for the token, or an operator approved for all of + /// the owner's tokens via the registrar. This is the canonical authorisation check the + /// registry enforces on owner-gated entry points; sibling contracts may consult it so a + /// single registrar-level approval delegates management across the protocol. Returns + /// false for a node that does not exist. + /// @param node Node identifier. + /// @param account Address whose authority is being checked. + /// @return authorisedFlag True when `account` may manage `node`. + function isAuthorised(bytes32 node, address account) external view returns (bool authorisedFlag); +} diff --git a/contracts/resolvers/DotnsContentResolverOld.sol b/contracts/resolvers/DotnsContentResolverOld.sol new file mode 100644 index 000000000..2a59ba308 --- /dev/null +++ b/contracts/resolvers/DotnsContentResolverOld.sol @@ -0,0 +1,152 @@ +// 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 {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsContentResolver} from "./IDotnsContentResolver.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Content Resolver +/// @notice Implements `IDotnsContentResolver` interface with content hash, text records, and +/// operator approvals. +/// @dev Writes are gated on the registry's authorisation for the node (owner or registrar-level +/// approval) or on a resolver-local operator the owner has approved, rather than on a +/// privileged writer address. Content records are user-managed metadata, so write authority +/// follows the node owner across transfers and honours the same delegates the registry +/// recognises. +/// @custom:security-contact admin@parity.io +contract DotnsContentResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsContentResolver +{ + /// @notice Stores all content hash mappings + mapping(bytes32 node => bytes contentHash) private contenthashes; + + /// @notice Stores all text records + mapping(bytes32 node => mapping(string key => string value)) private textRecords; + + /// @notice Store all approval mapping + mapping(address owner => mapping(address operator => bool approved)) private operators; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the content resolver. + /// @dev Runs once through the UUPS proxy; a repeat call reverts with + /// @custom:reverts InvalidInitialization. Emits @custom:emits OwnershipTransferred when + /// `msg.sender` is recorded as the initial owner and @custom:emits Initialized once + /// setup completes. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsContentResolver + function setContenthash(bytes32 node, bytes calldata hash) external override { + _requireNodeOwnerOrOperator(node); + contenthashes[node] = hash; + emit ContentHashUpdated(node, hash); + } + + /// @inheritdoc IDotnsContentResolver + function contenthash(bytes32 node) external view override returns (bytes memory hash) { + return contenthashes[node]; + } + + /// @inheritdoc IDotnsContentResolver + function setText(bytes32 node, string calldata key, string calldata value) external override { + _requireNodeOwnerOrOperator(node); + textRecords[node][key] = value; + emit TextUpdated(node, key, value); + } + + /// @inheritdoc IDotnsContentResolver + function text( + bytes32 node, + string calldata key + ) + external + view + override + returns (string memory value) + { + return textRecords[node][key]; + } + + /// @inheritdoc IDotnsContentResolver + function setApprovalForAll(address operator, bool approved) external override { + operators[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + /// @inheritdoc IDotnsContentResolver + function isApprovedForAll( + address owner, + address operator + ) + external + view + override + returns (bool) + { + return operators[owner][operator]; + } + + /// @notice Ensures the caller may write records for `node`. + /// @dev Authority is granted to the node owner, to a resolver-local operator the owner has + /// approved for all of their records, or to any address the registry deems authorised + /// for the node. Delegating through the registry means a single registrar-level + /// approval (ERC-721 owner / approved / operator-for-all) also confers record-write + /// authority, while the resolver-local operator mapping remains a narrower record-only + /// delegation that grants no power over ownership or transfers. The cheap owner and + /// local-operator checks run before the cross-contract registry call. + /// @param node Node identifier. + function _requireNodeOwnerOrOperator(bytes32 node) internal view { + IDotnsRegistryOld _registry = + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + address nodeOwner = _registry.owner(node); + require( + msg.sender == nodeOwner || operators[nodeOwner][msg.sender] + || _registry.isAuthorised(node, msg.sender), + NotAuthorised(node, msg.sender) + ); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return interfaceId == type(IDotnsContentResolver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @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..490a92472 --- /dev/null +++ b/contracts/resolvers/DotnsPopResolverOld.sol @@ -0,0 +1,151 @@ +// 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 {IDotnsPopResolver} from "./IDotnsPopResolver.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.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, + IDotnsPopResolver +{ + /// @notice Protocol-level address registry used to resolve the authorised writer. + IDotnsProtocolRegistryOld 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 registry Protocol-level address registry used for writer resolution. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsPopResolver + 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 IDotnsPopResolver + 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 IDotnsPopResolver + function chatKey(bytes32 node) external view override returns (bytes memory) { + return _chatKeys[node]; + } + + /// @inheritdoc IDotnsPopResolver + function liteLink(bytes32 fullNode) external view override returns (bytes32) { + return _liteLinks[fullNode]; + } + + /// @inheritdoc IDotnsPopResolver + function fullClaim(bytes32 liteLabelhash) external view override returns (bytes32) { + return _fullClaims[liteLabelhash]; + } + + /// @notice Returns implementation version. + /// @dev Bumped on every upgrade. Used by deployment scripts as a + /// post-upgrade assertion target. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return + interfaceId == type(IDotnsPopResolver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Internal check enforcing PoP-controller-only access. + function _onlyPopController() internal view { + address popController = protocolRegistry.get(DotnsConstantsOld.POP_CONTROLLER); + require(msg.sender == popController, NotPopController(msg.sender)); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/resolvers/DotnsResolverOld.sol b/contracts/resolvers/DotnsResolverOld.sol new file mode 100644 index 000000000..59661989e --- /dev/null +++ b/contracts/resolvers/DotnsResolverOld.sol @@ -0,0 +1,100 @@ +// 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 {IDotnsResolver} from "./IDotnsResolver.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; + +/// @title Dotns Resolver +/// @notice Stores forward-resolution address records for DotNS nodes +/// @dev Writes are gated on node ownership in the forward registry, not on a +/// privileged writer address. Address records describe where a name points +/// and only the current node owner has the authority to set that target. +/// @custom:security-contact admin@parity.io +contract DotnsResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsResolver +{ + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @notice Node => resolved address. + mapping(bytes32 node => address owner) private addresses; + + /// @notice Restricts access to the owner of `node` as recorded in the registry. + /// @param node Node identifier. + modifier onlyNodeOwner(bytes32 node) { + _onlyNodeOwner(node); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the resolver. + /// @dev Runs once through the UUPS proxy; a repeat call reverts with + /// @custom:reverts InvalidInitialization. Emits @custom:emits OwnershipTransferred when + /// `msg.sender` is recorded as the initial owner and @custom:emits Initialized once + /// setup completes. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsResolver + function setAddress(bytes32 node, address value) external override onlyNodeOwner(node) { + addresses[node] = value; + emit AddressSet(node, value); + } + + /// @inheritdoc IDotnsResolver + function addressOf(bytes32 node) external view override returns (address value) { + return addresses[node]; + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + return + interfaceId == type(IDotnsResolver).interfaceId || super.supportsInterface(interfaceId); + } + + /// @notice Internal ownership check for a registry node. + /// @dev Resolves the registry lazily through `protocolRegistry` so a registry + /// upgrade or rewire is picked up automatically without a resolver upgrade. + /// @param node Node identifier. + function _onlyNodeOwner(bytes32 node) internal view { + IDotnsRegistryOld _registry = + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + require(_registry.owner(node) == msg.sender, NotAuthorised(node, msg.sender)); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/resolvers/DotnsReverseResolverOld.sol b/contracts/resolvers/DotnsReverseResolverOld.sol new file mode 100644 index 000000000..11fbeb698 --- /dev/null +++ b/contracts/resolvers/DotnsReverseResolverOld.sol @@ -0,0 +1,146 @@ +// 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 {IDotnsReverseResolver} from "./IDotnsReverseResolver.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {SubnodeUtilsOld} from "../utils/SubnodeUtilsOld.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; + +/// @title Dotns Reverse Resolver +/// @notice Resolves an address to its associated name under the network TLD. +/// @dev Writes are gated on a fixed writer address resolved from the protocol +/// registry (the registrar or its controller), not on node ownership. +/// Reverse records bind to an EOA rather than a registry node, so authority +/// is delegated to the contract that mints names on the user's behalf. +/// @custom:security-contact admin@parity.io +contract DotnsReverseResolverOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsReverseResolver +{ + /// @dev Mapping from address to its reverse name. An empty string indicates + /// that no reverse name is set. + mapping(address owner => string name) private reverseNames; + + /// @notice Protocol-level address registry for all DotNS contracts. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @dev Reserved storage space to allow for layout changes in the future. + // forge-lint: disable-next-line(mixed-case-variable) + uint256[50] private __gap; + + /// @notice Restricts access to the configured registrar. + modifier onlyRegistrar() { + _onlyRegistrar(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the reverse resolver. + /// @dev May only be called once per proxy; a repeat call reverts with + /// @custom:reverts InvalidInitialization. Emits @custom:emits OwnershipTransferred when + /// `msg.sender` is recorded as the initial owner and @custom:emits Initialized once + /// setup completes. + /// @param registry Protocol-level address registry used to resolve sibling contracts. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __Ownable_init(msg.sender); + __ERC165_init(); + protocolRegistry = registry; + } + + /// @inheritdoc IDotnsReverseResolver + function setReverseName(address addr, string calldata name) external override onlyRegistrar { + reverseNames[addr] = name; + emit ReverseNameSet(addr, name); + } + + /// @inheritdoc IDotnsReverseResolver + function claimReverseRecord(string calldata label) external override { + bytes32 node = _nodeOf(label); + require(_registry().owner(node) == msg.sender, NotNameOwner(msg.sender, uint256(node))); + + string memory fullName = string.concat(label, protocolRegistry.tld()); + reverseNames[msg.sender] = fullName; + emit ReverseNameSet(msg.sender, fullName); + } + + /// @inheritdoc IDotnsReverseResolver + function nameOf(address addr) external view override returns (string memory name) { + string memory stored = reverseNames[addr]; + if (bytes(stored).length == 0) return ""; + + // Strip the TLD suffix and validate against current ownership so a transferred-away + // name never resolves under a stale reverse record. + string memory label = LabelUtils.stripTld(protocolRegistry.tld(), stored); + if (bytes(label).length == 0) return ""; + + if (_registry().owner(_nodeOf(label)) != addr) return ""; + return stored; + } + + /// @notice Resolves the node a name maps to, whether tokenised or a lite subname. + /// @dev A lite name is `stem` beneath its numeric container, so it hashes as a subnode; any + /// other name hashes as a second-level label under the TLD. Ownership of either is read + /// through the registry, which delegates a tokenised name to the registrar and holds a + /// subname directly. + /// @param label Bare label without the TLD, e.g. `alice` or `alice.01`. + /// @return node The node the name resolves to. + function _nodeOf(string memory label) internal view returns (bytes32 node) { + bytes32 tldNode = protocolRegistry.tldNode(); + if (StringUtils.isLitePersonLabelMemory(label)) { + return SubnodeUtilsOld.liteSubnodeOf(tldNode, label); + } + node = LabelUtils.namehashUnder(tldNode, LabelUtils.labelhashMemory(label)); + } + + /// @notice Resolves the registry via the protocol registry. + function _registry() internal view returns (IDotnsRegistryOld) { + return IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)); + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable) + returns (bool supported) + { + return interfaceId == type(IDotnsReverseResolver).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Internal check enforcing registrar-only access. + function _onlyRegistrar() internal view { + address controller = protocolRegistry.get(DotnsConstantsOld.CONTROLLER); + address registrar = protocolRegistry.get(DotnsConstantsOld.REGISTRAR); + require( + msg.sender == controller || msg.sender == registrar, NotRegistrarController(msg.sender) + ); + } + + /// @notice Returns implementation version. + /// @return versionString Current version string. + function version() external pure virtual returns (string memory versionString) { + versionString = "1.0.0"; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/store/IStoreFactoryOld.sol b/contracts/store/IStoreFactoryOld.sol new file mode 100644 index 000000000..690925391 --- /dev/null +++ b/contracts/store/IStoreFactoryOld.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title IStoreFactoryOld +/// @notice Interface for the DotNS per-user store factory. +/// @dev Owns two `UpgradeableBeacon` instances; one for `LabelStore` (protocol-managed), +/// one for `UserStore` (user-claimed). Each user may acquire at most one of each, +/// forever. There is no transfer, no redeploy, no additional store type. +/// @custom:security-contact admin@parity.io +interface IStoreFactoryOld { + /// @notice Emitted when a `LabelStore` beacon-proxy is deployed for `user`. + /// @param user The user the store is bound to. + /// @param store The deployed store address. + event LabelStoreDeployed(address indexed user, address indexed store); + + /// @notice Emitted when `user` claims their `UserStore` beacon-proxy. + /// @param user The user the store is bound to. + /// @param store The deployed store address. + event UserStoreClaimed(address indexed user, address indexed store); + + /// @notice Emitted when the `LabelStore` implementation behind the label beacon is upgraded. + /// @param newImplementation The new implementation address. + event LabelStoreImplementationUpgraded(address indexed newImplementation); + + /// @notice Emitted when the `UserStore` implementation behind the user beacon is upgraded. + /// @param newImplementation The new implementation address. + event UserStoreImplementationUpgraded(address indexed newImplementation); + + /// @notice Thrown when attempting to deploy or claim a store that already exists. + /// @param user The user for whom the store exists. + /// @param existingStore The already-deployed store address. + error AlreadyDeployed(address user, address existingStore); + + /// @notice Thrown when a zero user address is supplied. + /// @param user The invalid user argument. + error InvalidUser(address user); + + /// @notice Thrown when a zero protocol registry address is supplied to the constructor. + /// @param protocolRegistry The invalid registry argument. + error InvalidProtocolRegistry(address protocolRegistry); + + /// @notice Thrown when a zero implementation address is supplied to the constructor or an + /// upgrade. @param implementation The invalid implementation argument. + error InvalidImplementation(address implementation); + + /// @notice Thrown when an unauthorised address attempts to deploy a label store. + /// @param caller The unauthorised msg.sender. + error NotAuthorised(address caller); + + /// @notice Thrown when a freshly deployed proxy does not report the expected owner. + error ImplementationBindingMismatch(); + + /// @notice Returns the `UpgradeableBeacon` address backing all `LabelStore` proxies. + /// @return beacon Address of the beacon contract. + function labelStoreBeacon() external view returns (address beacon); + + /// @notice Returns the `UpgradeableBeacon` address backing all `UserStore` proxies. + /// @return beacon Address of the beacon contract. + function userStoreBeacon() external view returns (address beacon); + + /// @notice Returns the protocol registry address used for writer authorisation. + /// @return registry Address of the protocol registry. + function protocolRegistry() external view returns (address registry); + + /// @notice Deploys a `LabelStore` beacon-proxy bound to `user`. + /// @dev Callable by the factory owner or a component named in + /// @custom:function StoreAuth.isStoreWriter; any other caller + /// @custom:reverts NotAuthorised. `user` must be non-zero, + /// otherwise @custom:reverts InvalidUser. The user must not already have a + /// `LabelStore`, otherwise @custom:reverts AlreadyDeployed. After deployment the + /// freshly initialised proxy must report `user` as its owner, otherwise + /// @custom:reverts ImplementationBindingMismatch. Emits + /// @custom:emits LabelStoreDeployed on success. + /// @param user The user the store is bound to forever. + /// @return store The deployed store address. + function deployLabelStoreFor(address user) external returns (address store); + + /// @notice Returns the `LabelStore` address bound to `user`, or the zero address if none. + /// @param user The user to look up. + /// @return store The bound store address, or zero. + function getLabelStore(address user) external view returns (address store); + + /// @notice Returns the total number of `LabelStore` proxies ever deployed. + /// @return count Length of the deployment list. + function getLabelStoreCount() external view returns (uint256 count); + + /// @notice Paginated enumeration over every `LabelStore` proxy ever deployed. + /// @dev Insertion order of `deployLabelStoreFor` calls. `offset >= getLabelStoreCount()` + /// returns an empty array; result length is `min(limit, count - offset)`. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return stores Slice of label-store addresses. + function getLabelStores( + uint256 offset, + uint256 limit + ) + external + view + returns (address[] memory stores); + + /// @notice Upgrades the `LabelStore` implementation for every existing and future proxy. + /// @dev Callable by the factory owner only, otherwise + /// @custom:reverts OwnableUnauthorizedAccount. `newImplementation` must be non-zero, + /// otherwise @custom:reverts InvalidImplementation. The candidate is sentinel-probed by + /// calling `ILabelStore.protocolRegistry` on it before the beacon is rotated; if the + /// address does not implement that selector the probe reverts and the upgrade does not + /// land (deliberate fail-fast guard, no named error). Delegates to + /// `UpgradeableBeacon.upgradeTo` and emits + /// @custom:emits LabelStoreImplementationUpgraded on success. + /// @param newImplementation The new implementation address. + function upgradeLabelStoreImplementation(address newImplementation) external; + + /// @notice Caller claims their `UserStore` beacon-proxy. + /// @dev Self-claim only; `_owner` on the resulting store is always `msg.sender`, + /// regardless of who pays gas. One store per caller, forever: a caller who already + /// has a `UserStore` @custom:reverts AlreadyDeployed. After deployment the freshly + /// initialised proxy must report `msg.sender` as its owner, otherwise + /// @custom:reverts ImplementationBindingMismatch. Emits + /// @custom:emits UserStoreClaimed on success. + /// @return store The deployed store address. + function claimUserStore() external returns (address store); + + /// @notice Returns the `UserStore` address bound to `user`, or the zero address if none. + /// @param user The user to look up. + /// @return store The bound store address, or zero. + function getUserStore(address user) external view returns (address store); + + /// @notice Returns the total number of `UserStore` proxies ever claimed. + /// @return count Length of the claim list. + function getUserStoreCount() external view returns (uint256 count); + + /// @notice Paginated enumeration over every `UserStore` proxy ever claimed. + /// @dev Insertion order of `claimUserStore` calls. `offset >= getUserStoreCount()` + /// returns an empty array; result length is `min(limit, count - offset)`. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return stores Slice of user-store addresses. + function getUserStores( + uint256 offset, + uint256 limit + ) + external + view + returns (address[] memory stores); + + /// @notice Upgrades the `UserStore` implementation for every existing and future proxy. + /// @dev Callable by the factory owner only, otherwise + /// @custom:reverts OwnableUnauthorizedAccount. `newImplementation` must be non-zero, + /// otherwise @custom:reverts InvalidImplementation. The candidate is sentinel-probed by + /// calling `IUserStore.getKeyCount` on it before the beacon is rotated; if the address + /// does not implement that selector the probe reverts and the upgrade does not land + /// (deliberate fail-fast guard, no named error). Delegates to + /// `UpgradeableBeacon.upgradeTo` and emits + /// @custom:emits UserStoreImplementationUpgraded on success. + /// @param newImplementation The new implementation address. + function upgradeUserStoreImplementation(address newImplementation) external; +} diff --git a/contracts/store/StoreFactoryMigrator.sol b/contracts/store/StoreFactoryMigrator.sol new file mode 100644 index 000000000..d3e0e8123 --- /dev/null +++ b/contracts/store/StoreFactoryMigrator.sol @@ -0,0 +1,366 @@ +// 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 {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; + +import {IStoreFactory} from "./IStoreFactory.sol"; +import {IDotnsStore} from "./IDotnsStore.sol"; +import {ILabelStore} from "./ILabelStore.sol"; +import {IUserStore} from "./IUserStore.sol"; +import {LabelStore} from "./LabelStore.sol"; +import {UserStore} from "./UserStore.sol"; +import {IDotnsProtocolRegistry} from "../registry/IDotnsProtocolRegistry.sol"; +import {StoreAuth} from "../utils/StoreAuth.sol"; + +/// @title StoreFactoryMigrator +/// @notice The shipped `StoreFactory`, with the per-user bindings reachable and a paged import +/// that copies them from the factory a network used before this one. +/// @dev PR-scoped migration tooling, upgraded into the proxy for the import transactions and +/// upgraded straight back out. It exists because a `StoreFactory` cannot be moved: the +/// bindings are proxy storage and the shipped contract offers no way to write one except by +/// deploying a new store, so a network that re-points `STORE_FACTORY` at a fresh factory +/// starts with an empty directory and hands every existing user a second, empty store the +/// next time one is needed. +/// +/// The contract body is the shipped factory verbatim, with four fields widened from +/// `private` to `internal` and `importStores` added. Keeping it a copy rather than a +/// subclass or a set of raw slot writes is what makes the layout identical by construction: +/// the upgrade in and the upgrade back both diff against a layout that cannot have drifted. +/// +/// What it does not do: the imported stores stay on the beacons the old factory minted, and +/// those beacons answer to the old factory. Their implementations remain upgradeable there, +/// by the same owner, and are not reachable from this factory's beacons. A `BeaconProxy` +/// holds its beacon address in an immutable, so no migration can change that. +/// @custom:security-contact admin@parity.io +contract StoreFactoryMigrator is Initializable, UUPSUpgradeable, OwnableUpgradeable, IStoreFactory { + /// @notice Beacon backing every `LabelStore` proxy. + /// @dev Public getter name is interface-constrained by @custom:contract IStoreFactory. + address public override labelStoreBeacon; + + /// @notice Beacon backing every `UserStore` proxy. + /// @dev Public getter name is interface-constrained by @custom:contract IStoreFactory. + address public override userStoreBeacon; + + /// @notice Protocol registry used to authorise `deployLabelStoreFor` callers. + /// @dev Public getter name is interface-constrained by @custom:contract IStoreFactory. + address public override protocolRegistry; + + /// @dev user => their permanent `LabelStore`. Set once per user, forever. + mapping(address user => address store) internal _labelStores; + + /// @dev user => their permanent `UserStore`. Set once per user, forever. + mapping(address user => address store) internal _userStores; + + /// @dev Insertion-order list of every `LabelStore` proxy ever deployed. Append-only. + address[] internal _labelStoreList; + + /// @dev Insertion-order list of every `UserStore` proxy ever claimed. Append-only. + address[] internal _userStoreList; + + /// @dev Reserved storage space to allow for layout changes in future upgrades. + uint256[50] private __gap; + + /// @notice A binding was adopted from the previous factory. + /// @param user Address the store belongs to. + /// @param store The `LabelStore` now bound to `user` on this factory. + event StoresImported(address indexed user, address indexed store); + + /// @notice The old factory's store list does not have the length the factory reports. + /// @dev Reading the count and the list are two calls, so they can disagree: a truncated + /// enumeration would import a prefix and leave the rest unbound, and unbound users are + /// handed an empty store on their next registration rather than the one they have. + /// @param expected Count the old factory reports. + /// @param actual Number of entries actually seen. + error ImportCountMismatch(uint256 expected, uint256 actual); + + /// @notice A store's owner is not the user the old factory has it bound to. + /// @dev The store list and the per-user mapping are separate state. Importing on the store's + /// word alone would let a store whose owner no longer matches the mapping bind a user + /// the old factory does not consider its holder. + /// @param user Owner the store reports. + /// @param store The store in the old factory's list. + error ImportBindingMismatch(address user, address store); + + /// @notice A page of zero bindings was requested. + /// @dev A zero limit would import nothing while looking like progress, so it is a caller bug + /// worth naming rather than a no-op worth allowing. + error ImportPageEmpty(); + + /// @notice Restricts `deployLabelStoreFor` to the owner or a component named in + /// @custom:function StoreAuth.isStoreWriter. + modifier onlyOwnerOrProtocol() { + _onlyOwnerOrProtocol(); + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the factory together with both store implementations and beacons. + /// @dev Callable exactly once via `Initializable`, otherwise + /// @custom:reverts InvalidInitialization. A single initialiser call wires everything: + /// - Deploys a fresh `LabelStore` implementation. + /// - Deploys a fresh `UserStore` implementation. + /// - Constructs both `UpgradeableBeacon` instances, owned by `address(this)`, which + /// under the proxy is the proxy itself, so `upgrade*Implementation` can delegate to + /// `beacon.upgradeTo` and the beacons outlive any implementation swap. + /// The implementations are deployed here rather than accepted as parameters, so the call + /// carries no ordering dependency on a prior deploy and exposes no argument through which + /// a mismatched implementation could reach a beacon. `protocolRegistry_` must be + /// non-zero, otherwise @custom:reverts InvalidProtocolRegistry. + /// @param initialOwner Account that owns this factory and can upgrade it and the store + /// implementations. + /// @param protocolRegistry_ The protocol registry for writer auth on label stores. + function initialize(address initialOwner, address protocolRegistry_) external initializer { + __Ownable_init(initialOwner); + + require(protocolRegistry_ != address(0), InvalidProtocolRegistry(protocolRegistry_)); + // Probing the registry rejects a wrong address here rather than at the first store deploy, + // and is what catches the two address arguments being passed the wrong way round. + IDotnsProtocolRegistry(protocolRegistry_).isRegisteredAddress(address(0)); + + protocolRegistry = protocolRegistry_; + labelStoreBeacon = address(new UpgradeableBeacon(address(new LabelStore()), address(this))); + userStoreBeacon = address(new UpgradeableBeacon(address(new UserStore()), address(this))); + } + + /// @inheritdoc IStoreFactory + function deployLabelStoreFor(address user) + external + override + onlyOwnerOrProtocol + returns (address store) + { + require(user != address(0), InvalidUser(user)); + require(_labelStores[user] == address(0), AlreadyDeployed(user, _labelStores[user])); + + bytes memory initData = abi.encodeCall(ILabelStore.initialize, (user, protocolRegistry)); + store = address(new BeaconProxy(labelStoreBeacon, initData)); + require(IDotnsStore(store).owner() == user, ImplementationBindingMismatch()); + _labelStores[user] = store; + _labelStoreList.push(store); + + emit LabelStoreDeployed(user, store); + } + + /// @inheritdoc IStoreFactory + function getLabelStore(address user) external view override returns (address store) { + return _labelStores[user]; + } + + /// @inheritdoc IStoreFactory + function getLabelStoreCount() external view override returns (uint256 count) { + return _labelStoreList.length; + } + + /// @inheritdoc IStoreFactory + function getLabelStores( + uint256 offset, + uint256 limit + ) + external + view + override + returns (address[] memory stores) + { + stores = _paginateAddresses(_labelStoreList, offset, limit); + } + + /// @inheritdoc IStoreFactory + function upgradeLabelStoreImplementation(address newImplementation) + external + override + onlyOwner + { + require(newImplementation != address(0), InvalidImplementation(newImplementation)); + ILabelStore(newImplementation).protocolRegistry(); + UpgradeableBeacon(labelStoreBeacon).upgradeTo(newImplementation); + emit LabelStoreImplementationUpgraded(newImplementation); + } + + /// @inheritdoc IStoreFactory + function claimUserStore() external override returns (address store) { + require( + _userStores[msg.sender] == address(0), + AlreadyDeployed(msg.sender, _userStores[msg.sender]) + ); + + bytes memory initData = + abi.encodeCall(IUserStore.initialize, (msg.sender, protocolRegistry)); + store = address(new BeaconProxy(userStoreBeacon, initData)); + require(IDotnsStore(store).owner() == msg.sender, ImplementationBindingMismatch()); + _userStores[msg.sender] = store; + _userStoreList.push(store); + + emit UserStoreClaimed(msg.sender, store); + } + + /// @inheritdoc IStoreFactory + function getUserStore(address user) external view override returns (address store) { + return _userStores[user]; + } + + /// @inheritdoc IStoreFactory + function getUserStoreCount() external view override returns (uint256 count) { + return _userStoreList.length; + } + + /// @inheritdoc IStoreFactory + function getUserStores( + uint256 offset, + uint256 limit + ) + external + view + override + returns (address[] memory stores) + { + stores = _paginateAddresses(_userStoreList, offset, limit); + } + + /// @inheritdoc IStoreFactory + function upgradeUserStoreImplementation(address newImplementation) external override onlyOwner { + require(newImplementation != address(0), InvalidImplementation(newImplementation)); + IUserStore(newImplementation).protocolRegistry(); + UpgradeableBeacon(userStoreBeacon).upgradeTo(newImplementation); + emit UserStoreImplementationUpgraded(newImplementation); + } + + /// @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 = IDotnsProtocolRegistry(protocolRegistry).protocolVersion(); + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /// @notice Internal authorisation check deferred from the `onlyOwnerOrProtocol` modifier. + function _onlyOwnerOrProtocol() internal view { + if (msg.sender == owner()) return; + require(StoreAuth.isStoreWriter(protocolRegistry, msg.sender), NotAuthorised(msg.sender)); + } + + /// @notice Copies one page of `oldFactory`'s per-user `LabelStore` bindings into this factory. + /// @dev Owner-only, and reads everything it needs from `oldFactory` itself: the count, the + /// store list, and each store's owner. Nothing is supplied by the caller but the page + /// bounds, so there is no window between an operator reading the set and this executing. + /// That window is not hypothetical: the count on the network being migrated moved while + /// this was being written, and a list captured a moment early imports every entry it + /// holds, rewires, and leaves the newest holder to be handed a second empty store on + /// their next registration. + /// + /// Paged because of where this runs. pallet-revive meters transactions in more + /// dimensions than gas, and a single call importing all 63 live bindings exceeded what + /// one block admits on Paseo Asset Hub Next: estimation died mid-loop around the 44th + /// store, at any gas limit, with the weight exhaustion surfacing as an empty revert. + /// Nothing off-chain models that ceiling, so the import is sized to stay far under it + /// instead of proven against it. + /// + /// Each binding is checked back through `getLabelStore` before it is written. A store's + /// `owner` is the user it was deployed for, and the factory's mapping is the authority + /// on that pairing; requiring the two to agree rejects a store whose owner has been + /// changed out from under the mapping, which is the one shape that would bind a user to + /// a store the old factory does not consider theirs. + /// + /// A pair this factory already holds is skipped, so an interrupted import is finished by + /// running the same pages again and a page replayed whole is a no-op. Rebinding stays + /// impossible: a user bound to a DIFFERENT store is rejected, because bindings are + /// permanent here as everywhere else in the factory. + /// + /// The page that reaches the end of the list asserts the count equality; until then the + /// factory legitimately holds a prefix and no key points at it. + /// + /// `UserStore` bindings are deliberately not imported. They are claimed by users + /// themselves and the network being migrated from has none; a migration that does have + /// them needs this extended, not reused. + /// @param oldFactory Factory whose bindings are being adopted. + /// @param offset Index into the old factory's store list where this page starts. + /// @param limit Maximum number of bindings this page carries. Must be non-zero. + function importStores(address oldFactory, uint256 offset, uint256 limit) external onlyOwner { + require(oldFactory != address(0), InvalidUser(oldFactory)); + require(limit != 0, ImportPageEmpty()); + + uint256 total = IStoreFactory(oldFactory).getLabelStoreCount(); + address[] memory stores = IStoreFactory(oldFactory).getLabelStores(offset, limit); + // The shipped pagination truncates at the end of the list, so a short page is only legal + // on the last one. Anything else is a truncated read, and importing it would leave a gap + // the closing count check could not attribute. + uint256 expected = offset >= total ? 0 : (total - offset < limit ? total - offset : limit); + require(stores.length == expected, ImportCountMismatch(expected, stores.length)); + + for (uint256 i; i < stores.length; ++i) { + address store = stores[i]; + require(store != address(0), InvalidImplementation(store)); + + address user = IDotnsStore(store).owner(); + require(user != address(0), InvalidUser(user)); + require( + IStoreFactory(oldFactory).getLabelStore(user) == store, + ImportBindingMismatch(user, store) + ); + + address existing = _labelStores[user]; + if (existing == store) { + // Carried by an earlier run of this same page. Skipping it is what makes an + // interrupted import finishable by replaying pages. + continue; + } + require(existing == address(0), AlreadyDeployed(user, existing)); + + _labelStores[user] = store; + _labelStoreList.push(store); + + emit StoresImported(user, store); + } + + // The page that reaches the end of the list closes the books: every binding the old + // factory reports must now be held here, once. A mismatch means a store appeared in the + // list twice, or the list disagreed with the mapping. + if (offset + stores.length == total) { + require( + _labelStoreList.length == total, ImportCountMismatch(total, _labelStoreList.length) + ); + } + } + + /// @notice Shared pagination helper used by `getLabelStores` and `getUserStores`. + /// @dev Single canonical slicer so both enumerations bound-check and copy identically. + /// @param source Storage array to slice. + /// @param offset Start index. + /// @param limit Maximum entries to return. + /// @return slice Result slice; empty when `offset >= source.length`. + function _paginateAddresses( + address[] storage source, + uint256 offset, + uint256 limit + ) + internal + view + returns (address[] memory slice) + { + uint256 total = source.length; + if (offset >= total) return new address[](0); + + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + + slice = new address[](count); + for (uint256 i; i < count; ++i) { + slice[i] = source[offset + i]; + } + } +} diff --git a/contracts/store/UserStore.sol b/contracts/store/UserStore.sol index 3a2c30010..fb9fbb227 100644 --- a/contracts/store/UserStore.sol +++ b/contracts/store/UserStore.sol @@ -41,8 +41,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/DotnsConstantsOld.sol b/contracts/utils/DotnsConstantsOld.sol new file mode 100644 index 000000000..e6bd98606 --- /dev/null +++ b/contracts/utils/DotnsConstantsOld.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +/// @title DotNS Constants +/// @notice Protocol-level invariants shared across DotNS contracts. +/// @dev Centralises the well-known protocol-registry keys that every contract uses to discover +/// its siblings (registrar, controller, registry, resolvers, etc.). Each key is a +/// role address resolved at call time, so rotating an implementation is a +/// single `set` on the protocol registry without redeploying consumers. The TLD is not a +/// constant here: it is set per network on the protocol registry and read by every consumer. +/// @custom:security-contact admin@parity.io +library DotnsConstantsOld { + /// @notice Address of revive's System precompile, exposed by every revive runtime + /// that opts the precompile in. + /// @dev Mirrors the upstream `SYSTEM_ADDR` constant in + /// `substrate/frame/revive/uapi/sol/ISystem.sol`. Consumed by + /// `DotnsPopControllerOld` and `DotnsNameWhitelistOld` to authenticate + /// Root-origin dispatches via `ISystem.originIsRoot()`. + address internal constant REVIVE_SYSTEM = address(0x0900); + + /// @notice Address of the Proof-of-Personhood precompile backed by the + /// alias-accounts pallet on Asset Hub. + /// @dev Consumed by `PopRulesOld` to read each account's personhood tier + /// (`None` / `Lite` / `Full`) and the dotns-scoped `contextAlias`. + address internal constant PERSONHOOD = address(0x000000000000000000000000000000000a010000); + + /// @notice Application identifier passed to @custom:function IPersonhood.personhoodStatus. + /// @dev Fixed per project so the same person receives a stable, dotns-only + /// `contextAlias` and no cross-application linkability is exposed. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant PERSONHOOD_CONTEXT = bytes32("dotns"); + + /// @notice Launch deposit passed into the `DotnsFlatPricing` constructor. + /// @dev 10 DOT under revive's 18-decimal Asset Hub convention. A new amount is a fresh model + /// deployment registered under @custom:constant COST_MODEL, so this constant seeds the + /// model rather than being read afterwards. Single source of truth for deploy scripts and + /// tests so the seed cannot drift between call sites. + uint256 internal constant BASE_DEPOSIT = 10 ether; + + /// @notice Price floor F passed into the `DotnsScarcityPricing` candidate's constructor. + /// @dev Below `BASE_DEPOSIT` so that curve falls above nine characters. Seeds the candidate + /// constructor; a new floor is a fresh model deployment. + uint256 internal constant MIN_PRICE = 0.1 ether; + + /// @notice Well-known key for the cost model pricing registrations by base length. + /// @dev Role: single authority for the wei amount a registration costs. `PopRulesOld` resolves + /// it here on every pricing read, so swapping the model is one `set` on the protocol + /// registry without redeploying `PopRulesOld` or its consumers. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant COST_MODEL = bytes32("costModel"); + + /// @notice Default release cooldown seeded on `DotnsNameEscrowOld.initialize`. + /// @dev Single source of truth for deploy scripts and tests so the value cannot drift between + /// call sites. Bounded on-chain by `DotnsNameEscrowOld.MAX_COOLDOWN`. Live deployments + /// rotate the runtime value via `updateCooldown` rather than rebuilding consumers. + uint256 internal constant ESCROW_COOLDOWN = 15 minutes; + + /// @notice Default redeem window seeded on `DotnsNameEscrowOld.initialize`. + /// @dev Single source of truth for deploy scripts and tests. Bounded on-chain by + /// `DotnsNameEscrowOld.MAX_REDEEM_WINDOW`. Live deployments rotate the runtime value via + /// `updateRedeemWindow`. + uint256 internal constant ESCROW_REDEEM_WINDOW = 1 days; + /// @notice Maximum entries a paginated view returns in a single page. + /// @dev Shared ceiling for paginated reads: a view clamps its returned array to this figure, + /// and callers page through larger sets with `offset`. + uint256 internal constant MAX_PAGE_SIZE = 200; + + /// @notice Default per-name live-claim cap the name whitelist starts with. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 internal constant WHITELIST_DEFAULT_MAX_CLAIMANTS = 64; + + /// @notice Default claim-reason byte cap the name whitelist starts with. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_REASON_LIMIT`. + uint256 internal constant WHITELIST_DEFAULT_MAX_REASON_BYTES = 256; + + /// @notice Upper bound on the whitelist live-claim cap. Caps the claim clear-loop below the + /// block gas limit. + uint16 internal constant WHITELIST_MAX_CLAIMANTS_LIMIT = 128; + + /// @notice Upper bound on the whitelist reason byte cap. + uint256 internal constant WHITELIST_MAX_REASON_LIMIT = 256; + + /// @notice Default cap on labels granted in one `grantNames` call. + /// @dev Governance retunes it on the whitelist within `WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 internal constant WHITELIST_DEFAULT_MAX_GRANT_BATCH = 100; + + /// @notice Upper bound on the `grantNames` batch cap. Bounds one call below the block gas + /// limit. + uint16 internal constant WHITELIST_MAX_GRANT_BATCH_LIMIT = 256; + + /// @notice Well-known key for the ERC721 registrar backing name ownership. + /// @dev Role: token-of-record for registered names. Mints, burns, and tracks the + /// `tokenId => label` mapping consumed by the forward registry on + /// transfer. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant REGISTRAR = bytes32("registrar"); + + /// @notice Well-known key for the registrar controller orchestrating commit-reveal + /// registration. @dev Role: commit-reveal entry point for the public registration flow. + /// Calls `register` on the registrar after pricing and validation. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant CONTROLLER = bytes32("controller"); + + /// @notice Well-known key for the forward registry storing node ownership and resolver. + /// @dev Role: source of truth for `(node => owner, resolver)`. Read by every + /// resolver gate that defers authority to the node owner. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant REGISTRY = bytes32("registry"); + + /// @notice Well-known key for the reverse resolver for address-to-name mapping. + /// @dev Role: stores `address => name` reverse records. Writer is the + /// registrar/controller, not the address holder. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant REVERSE_RESOLVER = bytes32("reverseResolver"); + + /// @notice Well-known key for the PoP oracle enforcing eligibility and pricing. + /// @dev Role: arbiter of PoP cross-flow priority and pricing. Consulted by + /// both the public commit-reveal controller and the PoP controller. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_RULES = bytes32("popRules"); + + /// @notice Well-known key for the factory deploying per-user Store instances. + /// @dev Role: deploy-on-demand provisioning of user `LabelStore` proxies and + /// authorisation gate for protocol writes into them. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant STORE_FACTORY = bytes32("storeFactory"); + + /// @notice Well-known key for the forward resolver storing address records. + /// @dev Role: `node => address` records. Writes gated on node ownership. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant RESOLVER = bytes32("resolver"); + + /// @notice Well-known key for the content resolver storing content hashes and text records. + /// @dev Role: `node => contenthash`/`text` records and ERC721-style operator + /// approvals. Writes gated on node ownership or operator approval. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant CONTENT_RESOLVER = bytes32("contentResolver"); + + /// @notice Well-known key for the dedicated PoP controller orchestrating lite/full-person + /// username issuance on behalf of the PoP gateway. + /// @dev Kept distinct from `CONTROLLER` (commit-reveal public controller) so the + /// two can coexist per `DotnsRegistrarOld`'s multi-controller affordance. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_CONTROLLER = bytes32("popController"); + + /// @notice Well-known key for the PoP resolver holding per-name records produced + /// by the PoP username flow (chat keys, lite => full links). + /// @dev Role: `node => chatKey` and bidirectional `lite <=> full` link index. + /// Writer is the `POP_CONTROLLER`, not the node owner. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_RESOLVER = bytes32("popResolver"); + + /// @notice Well-known key for the read-only lens over PoP identity data. + /// @dev Role: off-chain query surface. Composes the account name listings, the per-name + /// record, and the account summary from the controller, registrar, store factory, PoP + /// resolver, and PopRulesOld. Holds no authority and is consumed by clients, not by other + /// contracts. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant POP_LENS = bytes32("popLens"); + + /// @notice Well-known key for the name escrow holding refundable deposits and + /// driving the release lifecycle for registered names. + /// @dev Role: custodial vault for registration deposits and the state machine + /// that drives the name release lifecycle. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant NAME_ESCROW = bytes32("nameEscrow"); + + /// @notice Well-known key for the generic Multicall3 batching helper. + /// @dev Role: unauthorised arbitrary-target multicall utility used by + /// clients and tooling. Target contracts still enforce their own + /// permissions and observe Multicall3 as `msg.sender`. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant MULTICALL3 = bytes32("multicall3"); + + /// @notice Well-known key for the CREATE3 factory backing the deterministic + /// deploy pipeline. + /// @dev Role: permissionless CREATE3 deployer. The first deploy stage + /// bootstraps the factory, records it under this key, and every later + /// stage resolves it from here, so deterministic addresses never depend + /// on an environment variable. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant CREATE3_FACTORY = bytes32("create3Factory"); + + /// @notice Well-known key for the pre-launch name whitelist that binds a label to the + /// one address permitted to register it. + /// @dev Role: authority for label-bound registration grants. The public controller resolves + /// it here and reads it on the reserved path, requiring a grant naming the intended owner + /// unless the dispatch is Root, and consuming the grant on a successful mint. The PoP + /// controller does not consult it. + /// forge-lint: disable-next-line(unsafe-typecast) + bytes32 internal constant NAME_WHITELIST = bytes32("nameWhitelist"); +} diff --git a/contracts/utils/RegistrationUtilsOld.sol b/contracts/utils/RegistrationUtilsOld.sol new file mode 100644 index 000000000..cd9760192 --- /dev/null +++ b/contracts/utils/RegistrationUtilsOld.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; +import {StoreUtilsOld} from "./StoreUtilsOld.sol"; +import {DotnsConstantsOld} from "./DotnsConstantsOld.sol"; + +/// @title DotNS Registration Utilities Library +/// @notice Single canonical implementation of the "mint + forward-registry + store-write" +/// triad used by every DotNS registration flow. +/// @dev Exists so that every controller (public commit-reveal, PoP gateway, future +/// privileged flows) calls the same sequence. Without this library each controller +/// re-implements the sequence, and the implementations drift. +/// @dev Scope: this library is deliberately minimal. It only performs the steps +/// that every registration flow needs regardless of policy: +/// 1. Mint the ERC721 name token on the base registrar. +/// 2. Write the forward registry entry (node => owner + default resolver). +/// 3. Resolve the per-user `LabelStore` (deploying on demand) so the caller +/// can pass it back. The registrar writes the labels-only store entry +/// internally. +/// Flow-specific concerns (pricing, reverse-record setting, chat-key persistence, +/// reservation queue mutation) stay inside the calling controller. +/// @custom:security-contact admin@parity.io +library RegistrationUtilsOld { + using StoreUtilsOld for IStoreFactoryOld; + + /// @notice Inputs describing a single name registration. + /// @dev Passed as a struct so callers do not have to thread a growing positional + /// argument list, and so future additions (e.g. a subname parent node) can + /// be made additively without breaking call sites. + /// @param protocolRegistry The protocol-level address registry for sibling lookups. + /// @param user Address receiving the name. + /// @param label Human-readable label (without the TLD). + /// @param labelhash `keccak256(bytes(label))`. + /// @param node `namehash(tldNode, labelhash)`. + struct RegistrationContext { + IDotnsProtocolRegistryOld protocolRegistry; + address user; + string label; + bytes32 labelhash; + bytes32 node; + } + + /// @notice Resolved sibling contracts for a registration call. + /// @dev Held as a struct internally so the helper can pass a single value to the + /// downstream steps rather than three separate locals. Never returned to + /// callers; kept in memory for the lifetime of one `registerAndStore`. + struct Siblings { + IDotnsRegistrarOld registrar; + IDotnsRegistryOld registry; + IStoreFactoryOld storeFactory; + } + + /// @notice Performs the canonical mint + forward-registry + store-write sequence. + /// @dev Callable by any authorised controller. Emits no events; each controller + /// emits its own flow-level event after this returns, so behavioural drift + /// between flows stays contained at the emission layer rather than at the + /// underlying state-transition layer. Store authorisation is resolved against the + /// protocol registry on every write (@custom:function StoreAuth.isStoreWriter), so no + /// per-store allowlist bookkeeping is needed here. + /// @dev The registrar writes the `LabelStore` entry directly inside `register` + /// so this helper deliberately does not call `StoreUtilsOld.writeLabel`. + /// Doing it twice would deploy or touch the store on every flow and + /// could conflict with the registrar's locked-entry semantics. + /// @param context Registration inputs. See @custom:struct RegistrationContext. + /// @return labelStore The resolved or newly deployed `LabelStore` address for `context.user`. + function registerAndStore(RegistrationContext memory context) + internal + returns (address labelStore) + { + Siblings memory siblings = _resolveSiblings(context.protocolRegistry); + + siblings.registrar.register(uint256(context.node), context.user, context.label); + siblings.registry.setOwner(context.node, context.user); + + labelStore = siblings.storeFactory.getLabelStore(context.user); + } + + /// @notice Resolves sibling contracts via the protocol registry. + /// @dev Exists so that resolution is one round-trip through a single helper and + /// not duplicated inline at every call site. If protocol-registry key + /// conventions change, the change lands here. + function _resolveSiblings(IDotnsProtocolRegistryOld protocolRegistry) + private + view + returns (Siblings memory siblings) + { + siblings = Siblings({ + registrar: IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)), + registry: IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.REGISTRY)), + storeFactory: IStoreFactoryOld(protocolRegistry.get(DotnsConstantsOld.STORE_FACTORY)) + }); + } +} diff --git a/contracts/utils/StoreUtilsOld.sol b/contracts/utils/StoreUtilsOld.sol new file mode 100644 index 000000000..2e1bbb3b1 --- /dev/null +++ b/contracts/utils/StoreUtilsOld.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {ILabelStore} from "../store/ILabelStore.sol"; +import {IStoreFactoryOld} from "../store/IStoreFactoryOld.sol"; + +/// @title DotNS Store Utilities Library +/// @notice Canonical helpers for protocol writes into per-user `LabelStore` instances. +/// @dev One auth rule, one write path. Every DotNS consumer (controller, registrar, +/// registry, PoP controller) funnels label writes through `writeLabel` so +/// authorisation and deploy-on-first-use semantics are identical across flows. +/// @custom:security-contact admin@parity.io +library StoreUtilsOld { + /// @notice Returns the `LabelStore` for `user`, deploying one via the factory if absent. + /// @dev Deploy-on-demand: a user's store is created on their first protocol write so + /// unused accounts never pay the deployment cost. The deploy path is gated by the + /// factory, so callers that are not the factory owner and not a store writer + /// @custom:reverts NotAuthorised when a deployment is required. + /// @param factory The store factory. + /// @param user The user whose label store is being resolved. + /// @return store The resolved or newly deployed store address. + function ensureLabelStore( + IStoreFactoryOld factory, + address user + ) + internal + returns (address store) + { + store = factory.getLabelStore(user); + if (store == address(0)) { + store = factory.deployLabelStoreFor(user); + } + } + + /// @notice Writes `label` under `labelhash` for `user`, deploying their `LabelStore` if needed. + /// @dev Idempotent on locked entries: once a label is locked the call is a no-op rather + /// than a revert, so retried protocol flows (e.g. an ERC721 transfer back to a prior + /// owner) pass through without failing on the existing lock. Inherits the factory's + /// writer authorisation: callers that are not the factory owner and not + /// a store writer @custom:reverts NotAuthorised when the user has no store yet. + /// @param factory The store factory. + /// @param user The label store owner. + /// @param labelhash The labelhash key. + /// @param label The label string (typically the full name, e.g. "alice.dot"). + /// @return store The resolved or newly deployed store address. + function writeLabel( + IStoreFactoryOld factory, + address user, + bytes32 labelhash, + string memory label + ) + internal + returns (address store) + { + store = ensureLabelStore(factory, user); + if (!ILabelStore(store).isLocked(labelhash)) { + ILabelStore(store).storeLabel(labelhash, label); + } + } +} diff --git a/contracts/utils/SubnodeUtilsOld.sol b/contracts/utils/SubnodeUtilsOld.sol new file mode 100644 index 000000000..2f380731d --- /dev/null +++ b/contracts/utils/SubnodeUtilsOld.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {IDotnsRegistryOld} from "../registry/IDotnsRegistryOld.sol"; +import {IDotnsRegistrarOld} from "../registrars/IDotnsRegistrarOld.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {LabelUtils} from "./LabelUtils.sol"; +import {StringUtils} from "./StringUtils.sol"; +import {DotnsConstantsOld} from "./DotnsConstantsOld.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 +/// IDotnsRegistryOld.setSubnodeOwner here, rather than through the tokenised mint triad of +/// @custom:contract RegistrationUtilsOld. +/// @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 RegistrationUtilsOld.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 { + IDotnsProtocolRegistryOld 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 StringUtils.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) = StringUtils.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 IDotnsRegistryOld.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 IDotnsRegistryOld.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) { + IDotnsProtocolRegistryOld protocolRegistry = context.protocolRegistry; + + bytes32 parentNode = LabelUtils.namehashUnder( + protocolRegistry.tldNode(), LabelUtils.labelhashMemory(context.parentLabel) + ); + + IDotnsRegistrarOld registrar = + IDotnsRegistrarOld(protocolRegistry.get(DotnsConstantsOld.REGISTRAR)); + IDotnsRegistryOld registry = + IDotnsRegistryOld(protocolRegistry.get(DotnsConstantsOld.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), IDotnsRegistryOld.NotAuthorised()); + } + + subnode = registry.setSubnodeOwner( + IDotnsRegistryOld.SubnodeRecord({ + parentNode: parentNode, + subLabel: context.subLabel, + parentLabel: context.parentLabel, + owner: context.owner, + persist: context.persist + }) + ); + } +} diff --git a/contracts/utils/SystemUtilsOld.sol b/contracts/utils/SystemUtilsOld.sol new file mode 100644 index 000000000..131e6d3ff --- /dev/null +++ b/contracts/utils/SystemUtilsOld.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.34; + +import {ISystem} from "../external/revive/ISystem.sol"; +import {DotnsConstantsOld} from "./DotnsConstantsOld.sol"; + +/// @title SystemUtilsOld +/// @notice Shared access to revive's System precompile for DotNS contracts. +/// @dev Canonical wrapper around `ISystem` at `DotnsConstantsOld.REVIVE_SYSTEM`, so the precompile +/// address and interface are wired in one place rather than duplicated per consumer. +/// @custom:security-contact admin@parity.io +library SystemUtilsOld { + /// @notice Returns whether the transaction-level origin is substrate Root. + /// @dev Reads the stack origin through `ISystem.originIsRoot`, which holds through a UUPS + /// proxy's delegatecall frame where `callerIsRoot` returns false, and returns false + /// rather than reverting on a non-Root origin. + /// @return root True when the transaction origin is Root. + function originIsRoot() internal view returns (bool root) { + return ISystem(DotnsConstantsOld.REVIVE_SYSTEM).originIsRoot(); + } +} diff --git a/contracts/whitelist/DotnsNameWhitelist.sol b/contracts/whitelist/DotnsNameWhitelist.sol index 4c4e10f09..b59715cc4 100644 --- a/contracts/whitelist/DotnsNameWhitelist.sol +++ b/contracts/whitelist/DotnsNameWhitelist.sol @@ -48,6 +48,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/contracts/whitelist/DotnsNameWhitelistOld.sol b/contracts/whitelist/DotnsNameWhitelistOld.sol new file mode 100644 index 000000000..3bcfa248d --- /dev/null +++ b/contracts/whitelist/DotnsNameWhitelistOld.sol @@ -0,0 +1,512 @@ +// 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 {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IDotnsNameWhitelist} from "./IDotnsNameWhitelist.sol"; +import {IDotnsProtocolRegistryOld} from "../registry/IDotnsProtocolRegistryOld.sol"; +import {LabelUtils} from "../utils/LabelUtils.sol"; +import {StringUtils} from "../utils/StringUtils.sol"; +import {DotnsConstantsOld} from "../utils/DotnsConstantsOld.sol"; +import {SystemUtilsOld} from "../utils/SystemUtilsOld.sol"; + +/// @title DotnsNameWhitelistOld +/// @notice Pre-launch name whitelist. A name is Open until governance reserves it or a claim is +/// accepted for it. Several beneficiaries may claim the same Open name, each with a +/// reason, and governance accepts one as the winner. +/// @dev Lives behind its own UUPS proxy with its own storage. Callers pass bare labels only; the +/// contract derives the node from the label and the TLD in the protocol registry, so a +/// caller cannot supply a mismatched hash. Claims are keyed by the beneficiary `user`, not +/// the submitter, so a relayer or a cross-chain sovereign account can submit on a user's +/// behalf and the name binds to that user. All state is on-chain and queryable through views; +/// no event indexing is required. A name holds at most `maxClaimants` live claims, which +/// bounds the loop that clears them on resolution. Resolving a name deletes its claims, +/// refunding their storage deposit, so only reserved or won names persist. The entire admin +/// surface is substrate Root: `SystemUtilsOld.originIsRoot` is true through the proxy's +/// delegatecall frame, and no gate reads `msg.sender`, so Root's lack of an address is not a +/// problem. No signed account grants, revokes, reserves, or retunes a cap; the owner's +/// authority is upgrade only. The public and PoP controllers hold only the `consume` hook. +/// Entries are keyed by the node under the active TLD, which the deployment holds immutable +/// for the whitelist's lifetime. +/// @custom:security-contact admin@parity.io +contract DotnsNameWhitelistOld is + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ERC165Upgradeable, + IDotnsNameWhitelist +{ + using StringUtils for string; + 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. + IDotnsProtocolRegistryOld public protocolRegistry; + + /// @notice Live-claim cap per name, tunable by governance within + /// `DotnsConstantsOld.WHITELIST_MAX_CLAIMANTS_LIMIT`. + uint16 public maxClaimants; + + /// @notice Cap on labels per `grantNames` call, tunable by governance within + /// `DotnsConstantsOld.WHITELIST_MAX_GRANT_BATCH_LIMIT`. + uint16 public maxGrantBatch; + + /// @notice Reason byte cap, tunable by governance within + /// `DotnsConstantsOld.WHITELIST_MAX_REASON_LIMIT`. + uint256 public maxReasonBytes; + + /// @notice Resolved state per name. + mapping(bytes32 node => NameRecord record) private _names; + + /// @notice Claims per name, keyed by beneficiary. + mapping(bytes32 node => mapping(address user => Claim claim)) private _claims; + + /// @notice Beneficiaries with a live claim per name. + mapping(bytes32 node => EnumerableSet.AddressSet claimants) private _claimants; + + /// @notice Names holding reserved, claimed or claim-holding state, kept enumerable for review. + EnumerableSet.Bytes32Set private _activeNodes; + + /// @notice Timestamp requests start being accepted. + uint64 private _requestOpen; + + /// @notice Timestamp requests stop being accepted. + uint64 private _requestClose; + + /// @dev Reserved storage space to allow for layout changes in the future. + uint256[50] private __gap; + + /// @notice Restricts a call to a substrate Root dispatch. + /// @dev The whole admin surface is governance-only: no key grants, revokes, reserves, or + /// retunes a cap. The owner's authority is deployment and upgrade, not allocation, so no + /// signed account can hand out a name. `msg.sender` is never read here, which is also what + /// keeps every gated entry point callable under a Root origin, since Root has no account. + modifier onlyGovernance() { + _onlyGovernance(); + _; + } + + /// @notice Restricts a call to a registrar controller resolved through the registry. + modifier onlyController() { + require( + msg.sender == protocolRegistry.get(DotnsConstantsOld.CONTROLLER) + || msg.sender == protocolRegistry.get(DotnsConstantsOld.POP_CONTROLLER), + NotController(msg.sender) + ); + _; + } + + /// @notice Internal check enforcing the substrate Root gate. + function _onlyGovernance() internal view { + require(SystemUtilsOld.originIsRoot(), NotGovernance()); + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialises the whitelist. + /// @dev Callable once through the UUPS proxy; direct calls on the implementation + /// @custom:reverts InvalidInitialization. Sets the deployer as owner and wires the + /// protocol registry the node derivation reads the TLD from. + /// @param registry Protocol registry all DotNS contracts resolve through. + function initialize(IDotnsProtocolRegistryOld registry) external initializer { + __ERC165_init(); + __Ownable_init(msg.sender); + protocolRegistry = registry; + maxClaimants = DotnsConstantsOld.WHITELIST_DEFAULT_MAX_CLAIMANTS; + maxGrantBatch = DotnsConstantsOld.WHITELIST_DEFAULT_MAX_GRANT_BATCH; + maxReasonBytes = DotnsConstantsOld.WHITELIST_DEFAULT_MAX_REASON_BYTES; + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxClaimants(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstantsOld.WHITELIST_MAX_CLAIMANTS_LIMIT, + MaxClaimantsOutOfRange() + ); + maxClaimants = newMax; + emit MaxClaimantsSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxReasonBytes(uint256 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstantsOld.WHITELIST_MAX_REASON_LIMIT, + MaxReasonBytesOutOfRange() + ); + maxReasonBytes = newMax; + emit MaxReasonBytesSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function setMaxGrantBatch(uint16 newMax) external override onlyGovernance { + require( + newMax > 0 && newMax <= DotnsConstantsOld.WHITELIST_MAX_GRANT_BATCH_LIMIT, + MaxGrantBatchOutOfRange() + ); + maxGrantBatch = newMax; + emit MaxGrantBatchSet(newMax); + } + + /// @inheritdoc IDotnsNameWhitelist + function requestName( + string calldata label, + string calldata reason, + address user + ) + external + override + { + require(_isWindowOpen(), WindowClosed()); + require(user != address(0), ZeroUser()); + require(bytes(reason).length <= maxReasonBytes, ReasonTooLong()); + require(label.isSingleLabel(), InvalidLabel()); + + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + require(_claims[node][user].status == ClaimStatus.None, AlreadyClaimed(node, user)); + require(_claimants[node].length() < maxClaimants, TooManyClaimants(node)); + + _claims[node][user] = Claim({ + user: user, + status: ClaimStatus.Requested, + requestedAt: uint64(block.timestamp), + submitter: msg.sender, + reason: reason + }); + _claimants[node].add(user); + _activate(node, label); + emit NameRequested(node, user, label, reason); + } + + /// @inheritdoc IDotnsNameWhitelist + function accept(string calldata label, address user) external override onlyGovernance { + bytes32 node = _nodeOf(label); + require(_claims[node][user].status == ClaimStatus.Requested, NotRequested(node, user)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @inheritdoc IDotnsNameWhitelist + function reject(string calldata label, address user) external override onlyGovernance { + bytes32 node = _nodeOf(label); + Claim storage claim = _claims[node][user]; + require(claim.status == ClaimStatus.Requested, NotRequested(node, user)); + // Free the claimant slot either way. Keep a sticky Rejected record only for a self-filed + // claim, so the beneficiary cannot re-request; a claim filed on their behalf is + // deleted and never binds them. + _claimants[node].remove(user); + if (claim.submitter == user) { + claim.status = ClaimStatus.Rejected; + } else { + delete _claims[node][user]; + } + emit NameRejected(node, user, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function grantName(string calldata label, address user) external override onlyGovernance { + _grant(label, user); + } + + /// @inheritdoc IDotnsNameWhitelist + function grantNames(string[] calldata labels, address user) external override onlyGovernance { + require(labels.length <= maxGrantBatch, TooManyLabels()); + for (uint256 i = 0; i < labels.length; i++) { + _grant(labels[i], user); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function revokeName(string calldata label) external override onlyGovernance { + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed || _claimants[node].length() != 0, + NothingToRevoke(node) + ); + address winner = record.winner; + _clearClaimants(node, address(0), label); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameRevoked(node, winner, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function setReserved(string calldata label, bool reserved) external override onlyGovernance { + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + if (reserved) { + require(record.status == NameStatus.Open, NameNotOpen(node)); + // Clear any pending claims the way a grant does, so a permissionless requestName + // cannot force governance to revokeName before it can reserve. + _clearClaimants(node, address(0), label); + record.status = NameStatus.Reserved; + _activate(node, label); + emit NameReserved(node, label); + } else { + require(record.status == NameStatus.Reserved, NotReserved(node)); + record.status = NameStatus.Open; + emit NameUnreserved(node, label); + _deactivate(node); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function consume(string calldata label, address registrant) external override onlyController { + bytes32 node = _nodeOf(label); + NameRecord storage record = _names[node]; + require( + record.status == NameStatus.Claimed && record.winner == registrant, + NotWinner(registrant, node) + ); + record.status = NameStatus.Open; + record.winner = address(0); + emit NameConsumed(node, registrant, label); + _deactivate(node); + } + + /// @inheritdoc IDotnsNameWhitelist + function setWindow(uint64 startsIn, uint64 duration) external override onlyGovernance { + require(duration > 0, BadWindow()); + uint64 openAt = uint64(block.timestamp) + startsIn; + uint64 closeAt = openAt + duration; + _requestOpen = openAt; + _requestClose = closeAt; + emit WindowSet(openAt, closeAt); + } + + /// @inheritdoc IDotnsNameWhitelist + function statusOf(string calldata label) external view override returns (NameStatus status) { + return _names[_nodeOf(label)].status; + } + + /// @inheritdoc IDotnsNameWhitelist + function isReserved(string calldata label) external view override returns (bool reserved) { + return _names[_nodeOf(label)].status == NameStatus.Reserved; + } + + /// @inheritdoc IDotnsNameWhitelist + function granteeOf(string calldata label) external view override returns (address winner) { + NameRecord storage record = _names[_nodeOf(label)]; + return record.status == NameStatus.Claimed ? record.winner : address(0); + } + + /// @inheritdoc IDotnsNameWhitelist + function isGrantedTo( + string calldata label, + address account + ) + external + view + override + returns (bool granted) + { + NameRecord storage record = _names[_nodeOf(label)]; + return + account != address(0) && record.status == NameStatus.Claimed && record.winner == account; + } + + /// @inheritdoc IDotnsNameWhitelist + function claimOf( + string calldata label, + address user + ) + external + view + override + returns (Claim memory claim) + { + return _claims[_nodeOf(label)][user]; + } + + /// @inheritdoc IDotnsNameWhitelist + function claimantCount(string calldata label) external view override returns (uint256 count) { + return _claimants[_nodeOf(label)].length(); + } + + /// @inheritdoc IDotnsNameWhitelist + function claims( + string calldata label, + uint256 offset, + uint256 limit + ) + external + view + override + returns (Claim[] memory page) + { + bytes32 node = _nodeOf(label); + EnumerableSet.AddressSet storage set = _claimants[node]; + uint256 total = set.length(); + if (offset >= total) { + return new Claim[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new Claim[](count); + for (uint256 i; i < count; ++i) { + page[i] = _claims[node][set.at(offset + i)]; + } + } + + /// @inheritdoc IDotnsNameWhitelist + function nameCount() external view override returns (uint256 count) { + return _activeNodes.length(); + } + + /// @inheritdoc IDotnsNameWhitelist + function names( + uint256 offset, + uint256 limit + ) + external + view + override + returns (NameView[] memory page) + { + uint256 total = _activeNodes.length(); + if (offset >= total) { + return new NameView[](0); + } + uint256 available = total - offset; + uint256 count = limit < available ? limit : available; + page = new NameView[](count); + for (uint256 i; i < count; ++i) { + bytes32 node = _activeNodes.at(offset + i); + NameRecord storage record = _names[node]; + page[i] = NameView({ + node: node, label: record.label, status: record.status, winner: record.winner + }); + } + } + + /// @inheritdoc IDotnsNameWhitelist + function window() external view override returns (uint64 openAt, uint64 closeAt) { + return (_requestOpen, _requestClose); + } + + /// @inheritdoc IDotnsNameWhitelist + function isWindowOpen() external view override returns (bool open) { + return _isWindowOpen(); + } + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC165Upgradeable) + returns (bool supported) + { + return interfaceId == type(IDotnsNameWhitelist).interfaceId + || super.supportsInterface(interfaceId); + } + + /// @notice Grants `label` to `user` directly, clearing any pending claims. + /// @param label Bare label to grant. + /// @param user Beneficiary the name binds to. + function _grant(string calldata label, address user) internal { + require(user != address(0), ZeroUser()); + require(label.isSingleLabel(), InvalidLabel()); + bytes32 node = _nodeOf(label); + require(_names[node].status == NameStatus.Open, NameNotOpen(node)); + emit NameAccepted(node, user, label); + _settle(node, user, label); + } + + /// @notice Marks a name claimed for `winner` and clears its claims, rejecting the losers. + /// @param node Namehash of the label under the active TLD. + /// @param winner Beneficiary the name binds to. + /// @param label Bare label, stored for review. + function _settle(bytes32 node, address winner, string calldata label) internal { + NameRecord storage record = _names[node]; + record.status = NameStatus.Claimed; + record.winner = winner; + _activate(node, label); + _clearClaimants(node, winner, label); + } + + /// @notice Deletes every claim on a name, rejecting each claimant that is not `winner`. + /// @param node Namehash of the label under the active TLD. + /// @param winner Claimant spared a rejection event; the zero address rejects every claimant. + /// @param label Bare label emitted with each rejection. + function _clearClaimants(bytes32 node, address winner, string calldata label) internal { + address[] memory current = _claimants[node].values(); + for (uint256 i; i < current.length; ++i) { + address claimant = current[i]; + delete _claims[node][claimant]; + _claimants[node].remove(claimant); + if (claimant != winner) { + emit NameRejected(node, claimant, label); + } + } + } + + /// @notice Records a name as active and stores its label the first time it is seen. + /// @param node Namehash of the label under the active TLD. + /// @param label Bare label stored on first activation. + function _activate(bytes32 node, string calldata label) internal { + NameRecord storage record = _names[node]; + if (bytes(record.label).length == 0) { + record.label = label; + } + _activeNodes.add(node); + } + + /// @notice Drops a name from the active set once it is Open with no claims. + /// @param node Namehash of the label under the active TLD. + function _deactivate(bytes32 node) internal { + NameRecord storage record = _names[node]; + if (record.status == NameStatus.Open && _claimants[node].length() == 0) { + _activeNodes.remove(node); + delete record.label; + } + } + + /// @notice Derives the namehash of `label` under the active TLD read from the registry. + /// @param label Bare label to hash. + /// @return node Namehash of the label under the active TLD. + function _nodeOf(string calldata label) internal view returns (bytes32 node) { + (, node) = LabelUtils.deriveNode(protocolRegistry.tldNode(), label); + } + + /// @notice Returns whether the current time is within the open window. + /// @return open True when the current time is within the window. + function _isWindowOpen() internal view returns (bool open) { + return block.timestamp >= _requestOpen && block.timestamp < _requestClose; + } + + /// @inheritdoc UUPSUpgradeable + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/deployments/paseo-assethub/420420417.json b/deployments/paseo-assethub/420420417.json index bbb35225e..968901c10 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":"0xb57Ebc2e7085616d4906D1fE49af1cE13f7dffeF","Multicall3":"0xB4468000abD87D3c56cbFBd153161223D7b109e5","PopRules":"0x747B456bE03aec0b42bd85C51513730FBD45DA31","StoreFactory":"0x709A027F446a9e2a4BB9cb9a9c754435b19e32B7","UserStoreBeacon":"0xb7C995601679840d36F37E86DB2d7dF30797eC5C","_seed":"0x0000000000000000000000000000000000000000"} +{"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"} 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-V080-RUNBOOK.md b/docs/PASEO-V080-RUNBOOK.md new file mode 100644 index 000000000..b106b21a7 --- /dev/null +++ b/docs/PASEO-V080-RUNBOOK.md @@ -0,0 +1,287 @@ +# Paseo Asset Hub Next: broadcast order for the v0.8.0 in-place upgrade + +Every step is one `scripts/deploy/upgrade.sh` invocation under the deployment owner, and the +order is not a preference. Broadcasts run through CI, one label per step; see "Broadcasting +through the review PR" below. Running locally instead needs the key material, which nobody +holds, so the label path is not a convenience but the mechanism. Three dependencies make it the only order that works: + +- **The protocol registry goes first.** Every upgraded contract's `version()` reads + `protocolRegistry.protocolVersion()`, and `MigrateStoreFactory` calls `setExpectedCodehash`. + Neither entrypoint exists on the implementation the network starts on, so anything that runs + before the registry swap either reverts or reads a contract that cannot answer. +- **The store migration goes after the registry and before the declaration.** It rewires the + `storeFactory` key, and the declaration records the codehash of whatever that key resolves to. + Declaring first would record the old factory and then quietly stop being true. +- **The declaration goes last.** It is a claim about the whole deployment. Made early, an + abandoned run leaves a false claim standing; made last, the same abandoned run leaves the + previous declaration, which clients read as an older network. + +Nothing in the scripts enforces this. Each is independently runnable by design, so the ordering +lives here and in the fork tests that reproduce it. + +## 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 +``` + +The second form needs no Docker. `bun run test:fork` brings up the local ETH-RPC adapter, which +only translates for the same node the hosted endpoint already fronts, so both exercise the same +state. Use whichever is available; the suite is 15 tests and takes under a minute either way. + +The first is the check that matters most and takes seconds. Every `*Old.sol` snapshot must +reproduce the implementation currently deployed; a snapshot that has drifted makes every layout +diff meaningless while leaving the build green. Re-run it on the day, not from an earlier run: +these proxies are upgraded in place, so a swap landing in between changes the answer. + +Confirm the broadcaster owns the proxies. All of them, and the deployed store factory, answer to +the same account; each script asserts this before it swaps anything, so a wrong signer fails fast +instead of reverting inside an upgrade call. + +## Order + +| # | Script | Why here | +| --- | --- | --- | +| 0 | `RotateOwnership` | Moves every contract to a fresh key before anything else is broadcast. | +| 1 | `UpgradeProtocolRegistry` | Adds `protocolVersion` and `setExpectedCodehash`, which steps 2 to 14 depend on. | +| 2 | `UpgradeRegistry` | Adds the deferred-write gate. Reads `registrar.controllers` live. | +| 3 | `UpgradeRegistrar` | Holds the controller authorisations the gate reads. | +| 4 | `UpgradeRegistrarController` | Carries the retained slot that keeps `protocolRegistry` readable. | +| 5 | `UpgradePopController` | | +| 6 | `UpgradePopRules` | | +| 7 | `UpgradeNameEscrow` | Custodies deposits; check `redeemWindow()` is non-zero afterwards. | +| 8 | `UpgradeNameWhitelist` | | +| 9 | `UpgradeResolver` | | +| 10 | `UpgradeReverseResolver` | | +| 11 | `UpgradeContentResolver` | | +| 12 | `UpgradePopResolver` | | +| 13 | `MigrateStoreFactory` | Deploys the replacement, imports the bindings, rewires the key. | +| 14 | `DeclareRelease` | Declares every codehash, then the release. Last, always. | + +Steps 5 to 12 have no dependency on each other and can go in any order among themselves. + +## Broadcasting through the review PR + +The workflow (`.github/workflows/paseo-upgrade-step.yml`) lives only on this branch and is +triggered by labels on the open review PR into master, because a dispatch button only exists for +workflows on the default branch and nothing that signs with the owner key goes there. One step: + +1. Add the label `run: