diff --git a/.cursor/skills/basenames-architecture/SKILL.md b/.cursor/skills/basenames-architecture/SKILL.md new file mode 100644 index 0000000..8855873 --- /dev/null +++ b/.cursor/skills/basenames-architecture/SKILL.md @@ -0,0 +1,110 @@ +--- +name: basenames-architecture +description: >- + Architecture reference for the Basenames ENS-on-Base smart contract system. + Use when working with Basenames contracts, understanding the registration flow, + resolver records, pricing, discounts, transfer mechanics, or name lifecycle. +--- + +# Basenames Architecture + +Basenames is a Foundry-based Solidity project that implements ENS-style naming on Base L2. It manages `*.base.eth` subdomains via an ERC721 registrar, pluggable controllers, and ENS-compatible resolvers. + +## Repository Layout + +| Path | Role | +|------|------| +| `src/L2/` | Core L2 contracts (registrar, controllers, resolvers, oracles, discounts) | +| `src/L1/` | L1 CCIP-read resolver for `base.eth` on Ethereum mainnet | +| `src/lib/` | Shared libraries (EDA pricing math, hashing, signature verification) | +| `src/util/Constants.sol` | Shared constants (`BASE_ETH_NODE`, `GRACE_PERIOD = 90 days`, etc.) | +| `test/` | Forge tests (~193 files), `test/mocks/` for test doubles | +| `script/` | Deployment and configuration scripts | +| `lib/` | Vendored deps (ens-contracts, forge-std, OpenZeppelin, Solady, EAS) | + +## Core Contract Map + +For detailed contract analysis, see [contracts.md](contracts.md). + +### Registration Stack + +``` +User + | + v +RegistrarController / UpgradeableRegistrarController (URC) + | - validates name, duration, payment + | - applies discounts via IDiscountValidator + | - calls BaseRegistrar.registerWithRecord() + | - optionally sets resolver records via multicall + | - optionally sets reverse record + v +BaseRegistrar (ERC721 + ENS subnode owner) + | - mints NFT token (id = uint256(keccak256(label))) + | - sets nameExpires[id] = block.timestamp + duration + | - writes to ENS Registry + v +Registry (ENS-compatible) + | - stores node -> { owner, resolver, ttl } +``` + +### Key Relationships + +- **BaseRegistrar** inherits Solady `ERC721` + `Ownable`. It owns the `baseNode` in the Registry. +- **Controllers** are whitelisted via `BaseRegistrar.addController(address)` (onlyOwner). Only controllers can call `register`, `registerWithRecord`, `renew`. +- **Resolvers** (`L2Resolver` or `UpgradeableL2Resolver`) store all profile records (addr, text, contenthash, etc.). Authorization: registry owner, approved operators, trusted controllers, or reverse registrar. +- **Reverse Registrar** manages `addr -> name` mappings under a Base-specific reverse node. + +## Name Lifecycle + +1. **Registration**: Controller calls `BaseRegistrar.registerWithRecord()` -> mints ERC721, sets `nameExpires[id]`, writes Registry subnode. +2. **Active period**: `ownerOf()` works, transfers allowed, records can be set. +3. **Expiry**: `nameExpires[id] <= block.timestamp` -> `ownerOf()` reverts, transfers blocked (via `onlyNonExpired` on `_isApprovedOrOwner`). +4. **Grace period**: 90 days post-expiry. Name not available for new registration. Existing owner can still `renew`. +5. **Available**: After grace period. Anyone can register. + +## Transfer Mechanics + +- Standard ERC721 transfers (Solady). No `_beforeTokenTransfer` hook or soul-bound lock exists. +- `_isApprovedOrOwner` and `ownerOf` are gated by `onlyNonExpired` -- expired names cannot be transferred. +- After transfer, new holder must call `reclaim(id, owner)` to sync Registry ownership with the NFT. + +## Expiry Storage + +- `mapping(uint256 id => uint256 expiry) public nameExpires` on BaseRegistrar. +- `renew(id, duration)` adds duration to existing expiry (controller-only). +- A sentinel value of `type(uint256).max` would effectively mean "never expires" since no `block.timestamp` will exceed it. + +## Resolver & Text Records + +- `TextResolver.setText(node, key, value)` / `text(node, key)` -- ENSIP-5 compliant, any UTF-8 key allowed. +- Records are versioned per node; `clearRecords(node)` increments version to invalidate old data. +- Common convention keys: `avatar`, `url`, `description`, `com.twitter`, `com.github`. No protocol-enforced key registry. +- Registration controllers can batch-set records via `multicallWithNodeCheck` during registration. + +## Pricing + +- `IPriceOracle.price(name, expires, duration) -> Price { base, premium }`. +- `StablePriceOracle`: per-second rent by name length tier. +- `LaunchAuctionPriceOracle`: Dutch auction premium for initial launch. +- `ExponentialPremiumPriceOracle`: premium decay for post-expiry re-registration. +- Renewals charge **base only**, no premium. + +## Discount System + +- `DiscountDetails { active, discountValidator, key, discount }` -- flat wei subtraction. +- Pluggable via `IDiscountValidator.isValidDiscountRegistration(claimer, data)`. +- One discounted registration per address (tracked across legacy + current controller). +- Existing validators: Attestation, CBId (Merkle), Coupon, Signature, ERC721, ERC1155, TalentProtocol. + +## Controller Variants + +| Controller | Upgradeable | Key Differences | +|------------|-------------|-----------------| +| `RegistrarController` | No | `launchTime` for auction premium anchor, legacy reverse | +| `UpgradeableRegistrarController` | Yes (UUPS) | ENSIP-19 `RegisterRequest`, legacy controller integration, L2ReverseRegistrar | +| `EARegistrarController` | No | Early access only, 28-day min duration, discount-only | + +## Adding New Controllers + +A new controller can be granted registration powers via `BaseRegistrar.addController(newController)`. This is the primary extension point -- new business logic (soul-bound, free, promotional) can be added without modifying existing contracts. diff --git a/.cursor/skills/basenames-architecture/contracts.md b/.cursor/skills/basenames-architecture/contracts.md new file mode 100644 index 0000000..8cc06d4 --- /dev/null +++ b/.cursor/skills/basenames-architecture/contracts.md @@ -0,0 +1,173 @@ +# Basenames Contract Reference + +Detailed reference for all contracts under `src/`. + +## src/L2/BaseRegistrar.sol + +The core tokenization layer. Inherits Solady `ERC721` + `Ownable`. + +**Key state:** +- `mapping(uint256 id => uint256 expiry) public nameExpires` +- `mapping(address => bool) public controllers` +- `ENS public immutable registry` +- `bytes32 public immutable baseNode` + +**Controller-only methods:** +- `register(id, owner, duration)` -- mint + registry subnode owner +- `registerOnly(id, owner, duration)` -- mint only, no registry update +- `registerWithRecord(id, owner, duration, resolver, ttl)` -- mint + full registry subnode record +- `renew(id, duration)` -- extend expiry (must be active or in grace) + +**Owner-only methods:** +- `addController(address)` / `removeController(address)` +- `setResolver(address)` -- set resolver for the base node itself +- `setBaseTokenURI(string)` / `setContractURI(string)` + +**Public methods:** +- `reclaim(id, owner)` -- sync registry ownership with NFT (caller must be approved/owner) +- `ownerOf(tokenId)` -- reverts if expired +- `isAvailable(id)` -- true if `nameExpires[id] + GRACE_PERIOD < block.timestamp` +- `nameExpires(id)` -- raw expiry timestamp + +**Token ID:** `uint256(keccak256(bytes(label)))` where label is the subdomain string (e.g., "vitalik" for vitalik.base.eth). + +**Transfer behavior:** Standard ERC721. `_isApprovedOrOwner` gated by `onlyNonExpired`. No soul-bound restrictions. + +--- + +## src/L2/UpgradeableRegistrarController.sol + +UUPS-upgradeable controller. Primary active controller for registrations. + +**Storage (EIP-7201):** +- `IBaseRegistrar base` +- `IPriceOracle prices` +- `IReverseRegistrar reverseRegistrar` +- `address l2ReverseRegistrar` +- `bytes32 rootNode`, `string rootName` +- `address paymentReceiver` +- `address legacyRegistrarController`, `address legacyL2Resolver` +- `mapping(bytes32 => DiscountDetails) discounts` +- `mapping(address => bool) discountedRegistrants` +- `EnumerableSetLib.Bytes32Set activeDiscounts` + +**Registration flow:** +1. `register(RegisterRequest)` -- validate name/duration, charge `registerPrice`, call `_register` +2. `discountedRegister(request, discountKey, validationData)` -- same but with discount applied +3. `_register` -> `base.registerWithRecord(...)` -> optionally `_setRecords` -> optionally `_setReverseRecord` +4. `renew(name, duration)` -- charges `price.base` only (no premium) + +**RegisterRequest struct:** `{ name, owner, duration, resolver, data[], reverseRecord, coinTypes[], signatureExpiry, signature }` + +**Constants:** `MIN_REGISTRATION_DURATION = 365 days`, `MIN_NAME_LENGTH = 3` + +--- + +## src/L2/RegistrarController.sol + +Legacy (non-upgradeable) controller. Similar to URC but with `launchTime` for auction premium and simpler reverse record flow. + +--- + +## src/L2/EARegistrarController.sol + +Early access controller. Only `discountedRegister` (no public `register`/`renew`). `MIN_REGISTRATION_DURATION = 28 days`. + +--- + +## src/L2/Registry.sol + +ENS-compatible registry. Stores `node -> Record { owner, resolver, ttl }`. Supports operators. + +--- + +## src/L2/L2Resolver.sol + +Public resolver for Base L2. Composes ENS profile resolvers (Addr, Text, ContentHash, ABI, etc.) + Multicallable + ExtendedResolver. + +**Authorization (`isAuthorised`):** +- `msg.sender == registrarController` or `msg.sender == reverseRegistrar` -> always authorized +- Otherwise: must be registry owner of the node, or approved operator/delegate + +--- + +## src/L2/UpgradeableL2Resolver.sol + +Upgradeable variant of L2Resolver. Uses local `src/L2/resolver/` profile modules with EIP-7201 storage. + +**Authorization (`isAuthorized`):** +- `approvedControllers[msg.sender]` or `msg.sender == reverseRegistrar` -> always authorized +- Otherwise: registry owner, operator, or delegate +- Supports multiple approved controllers (vs single in L2Resolver) + +--- + +## src/L2/resolver/ (Profile Modules) + +All extend `ResolverBase` (ERC165 + IVersionableResolver): +- `TextResolver` -- `setText(node, key, value)` / `text(node, key)` (ENSIP-5) +- `AddrResolver` -- ETH and multi-coin addresses (ENSIP-9/11) +- `NameResolver` -- reverse name string +- `ContentHashResolver`, `ABIResolver`, `DNSResolver`, `InterfaceResolver`, `PubkeyResolver` + +--- + +## src/L2/ReverseRegistrar.sol + +Manages `address -> name` reverse records under `BASE_REVERSE_NODE`. Controllers can `setNameForAddr`. Node for address: `keccak256(abi.encodePacked(reverseNode, hexAddress(addr)))`. + +## src/L2/ReverseRegistrarV2.sol + +Adds ENSIP-19 compliance via `IL2ReverseRegistrar.setNameForAddrWithSignature`. + +--- + +## src/L2/StablePriceOracle.sol + +Per-second rent by name length. 6 configurable tiers. Returns `Price { base, premium: 0 }`. + +## src/L2/LaunchAuctionPriceOracle.sol + +Extends StablePriceOracle. Adds Dutch auction premium with 1.5h half-life. + +## src/L2/ExponentialPremiumPriceOracle.sol + +Extends StablePriceOracle. Adds post-expiry premium decay (ENS-style). + +--- + +## src/L2/discounts/ + +All implement `IDiscountValidator`: +- `AttestationValidator` -- EAS attestation + sybil resistance +- `CBIdDiscountValidator` -- Merkle proof for cb.id allowlist +- `CouponDiscountValidator` -- UUID coupon codes +- `SignatureDiscountValidator` -- Backend signer authorization +- `ERC721DiscountValidator` / `ERC1155DiscountValidator` / `ERC1155DiscountValidatorV2` -- NFT holdings +- `TalentProtocolDiscountValidator` -- Talent Protocol score + +--- + +## src/L1/L1Resolver.sol + +Ethereum mainnet resolver for `base.eth`. Implements CCIP-read (ERC-3668 / ENSIP-10) for wildcard resolution. Delegates root queries to `rootResolver`, subname queries to gateway + signers. + +--- + +## src/lib/ + +- `EDAPrice.sol` -- Exponential decay pricing math +- `Sha3.sol` -- Keccak/name hashing helpers +- `SignatureVerifier.sol` -- ECDSA verification +- `SybilResistanceVerifier.sol` -- Discount attestation signatures + +--- + +## src/util/Constants.sol + +```solidity +bytes32 constant BASE_ETH_NODE = 0xff1e3c0eb00ec714e34b6114125fbde1dea2f24a72fbf672e7b7fd5690328e10; +bytes32 constant BASE_REVERSE_NODE = 0x08d9b0993eb8c4da57c37a4b84a6e384c2623114ff4e9370ed51c9b8935109ba; +uint256 constant GRACE_PERIOD = 90 days; +bytes constant BASE_ETH_NAME = hex"04626173650365746800"; // DNS-encoded "base.eth" +``` diff --git a/.cursor/skills/bdocs/SKILL.md b/.cursor/skills/bdocs/SKILL.md new file mode 100644 index 0000000..b58434f --- /dev/null +++ b/.cursor/skills/bdocs/SKILL.md @@ -0,0 +1,202 @@ +--- +name: bdocs-publishing +description: Publish and manage HTML documents on Base Docs (bdocs.cbhq.net). Use when the user asks to publish a report, upload an HTML document, push a notebook to bdocs, list their documents, check review comments, or update an existing document. +--- + +# Base Docs Publishing + +## When to Use + +Apply this skill when the user wants to: +- Publish an HTML report or notebook export to Base Docs +- Update an existing document with a new version +- List their published documents +- Read reviewer comments on a document + +## Base URL + +`https://bdocs.cbhq.net` + +## Authentication + +Base Docs uses an agentic auth flow. If no API key is stored locally, run this flow: + +1. `POST /api/auth/agent/start` → returns `{ "agent_session_id": "...", "auth_url": "..." }` +2. Ask the user to open `auth_url` in their browser to sign in. +3. Poll `GET /api/auth/agent/poll?session={agent_session_id}` until `{ "status": "completed", "api_key": "bd_..." }`. +4. Store the API key locally for subsequent requests. + +All authenticated endpoints require `Authorization: Bearer `. + +## API Reference + +### Publish a new document + +``` +POST /api/docs +Content-Type: application/json + +{ + "title": "My Document", + "html_content": "...", + "slug": "optional-custom-slug" +} +``` + +Response: `{ "id", "slug", "title", "version": 1, "version_id" }` + +Viewable at `https://bdocs.cbhq.net/d/{slug || id}`. + +### Update an existing document + +``` +PUT /api/docs/{id} +Content-Type: application/json + +{ + "title": "Updated Title (optional)", + "html_content": "..." +} +``` + +Response: `{ "id", "version": N, "version_id", "title" }` + +### List documents + +``` +GET /api/docs +``` + +Add `?owner=me` with a Bearer token to list only your own documents. + +### Read comments + +``` +GET /api/docs/{id}/versions/{version}/comments +``` + +- No authentication required. +- Use `latest` as `{version}` to get comments on the most recent version. +- Response: `{ "comments": [...] }` + +Each comment includes: `id`, `parent_id` (non-null for replies), `body`, `resolved`, `author_name`, `author_address`, `anchor_value.selected_text`, `created_at`. + +## Published Document Registry + +The **"Published Documents" table in `README.md`** is the source of truth for +which local notebooks map to which bdocs URLs and slugs. Always read it before +publishing or updating. + +## Dollar Signs in Notebook Markdown + +The nbconvert HTML template loads MathJax with `inlineMath: [['$','$']]` and +`processEscapes: true`. A single-escaped `\$` in notebook markdown is consumed +by the markdown-to-HTML renderer and produces a bare `$` in the HTML, which +MathJax then interprets as an inline-math delimiter — eating the dollar sign. + +**Fix:** double-escape dollar amounts in notebook markdown cells: + +- Write `\\$100` in the markdown source (stored as `\\\\$100` in the `.ipynb` JSON). +- The markdown renderer produces `\$100` in HTML. +- MathJax sees the `\$` escape and renders a literal `$`. + +This does **not** apply to code cells — only markdown cells. Code output is +wrapped in `
` tags which MathJax skips.
+
+## Converting Markdown to HTML
+
+bdocs requires HTML content. When the source is a `.md` file, convert it using
+Python's `markdown` library with the dark-themed stylesheet below.
+
+**Dependencies:** `pip3 install markdown` (extensions: `tables`, `fenced_code`, `codehilite`).
+
+**Conversion snippet:**
+
+```python
+import markdown
+
+with open("source.md", "r") as f:
+    md_content = f.read()
+
+html_body = markdown.markdown(md_content, extensions=["tables", "fenced_code", "codehilite"])
+```
+
+**Wrap in a styled HTML shell** using the bdocs dark theme:
+
+```html
+
+
+
+
+TITLE
+
+
+
+{html_body}
+
+
+```
+
+**Key style notes:**
+- bdocs has a dark background — text must be white (`#ffffff`), not default dark grey.
+- Headings use `#5b9aff` (blue) for `h2`, `#e0e0e0` (light grey) for `h3`.
+- Tables, code blocks, and `hr` use dark borders (`#444`) and dark backgrounds (`#2a2a2a`).
+- Ordered list items get extra `margin-bottom: 1rem` for readability.
+
+When using `%` in the style block inside a Python f-string or `%`-formatted string,
+escape as `%%`. Use `curl` for the HTTP call (Python's `urllib` may hit SSL issues).
+
+## Typical Workflows
+
+### Publish a markdown file
+
+1. Convert the `.md` to HTML using the snippet and stylesheet above
+2. Authenticate if needed (see Authentication above)
+3. Write the JSON payload to a temp file (`/tmp/payload.json`)
+4. `curl -X POST` with the payload to `POST /api/docs`
+5. Add a row to the Published Documents table in `README.md` if one exists
+6. Return the published URL to the user
+
+### Publish a new document (from pre-built HTML)
+
+1. Generate the HTML (e.g. `make report-parking`)
+2. Authenticate if needed (see Authentication above)
+3. Read the HTML file contents
+4. `POST /api/docs` with the HTML content and a descriptive title
+5. Add a row to the Published Documents table in `README.md`
+6. Return the published URL to the user
+
+### Update an existing document after notebook changes
+
+This is the most common flow — the user modifies a notebook and says
+"generate the report" or "update the bdoc."
+
+1. Read the Published Documents table in `README.md` to find the slug and make target
+2. Run the corresponding `make` target to generate the HTML
+3. Authenticate if needed
+4. Read the generated HTML file
+5. `PUT /api/docs/{slug}` with the updated HTML content
+6. Return the new version URL to the user
+
+Always check the README first. If the user references a bdocs URL or says
+"update the bdoc," match it to a row in the table to find the local source,
+make target, and slug.
diff --git a/SOULBOUND_PROMOTION_PLAN.md b/SOULBOUND_PROMOTION_PLAN.md
new file mode 100644
index 0000000..5092d39
--- /dev/null
+++ b/SOULBOUND_PROMOTION_PLAN.md
@@ -0,0 +1,384 @@
+# Soul-Bound Promotion Plan for Basenames
+
+**Steve Katzman** | March 23, 2026
+
+## Problem Statement
+
+Existing Basenames have four properties that are undesirable for a long-lived permanent identity:
+
+1. **They expire** -- `nameExpires[id]` is a finite timestamp; after expiry + grace, the name is released. Users must continually renew or risk losing their identity.
+
+2. **They are transferable** -- standard ERC721 transfer mechanics allow anyone to `transferFrom`. An identity primitive should be bound to its holder.
+
+3. **A name cannot be changed after the fact** -- the label is baked into the token ID (`keccak256(label)`). There's no way for a user to evolve their display identity without registering an entirely new name.
+
+4. **No protection against impersonation** -- anyone can register a name resembling a real person or brand (e.g., `elonmusk.base.eth`). Once registered, there is no admin mechanism to reclaim it on behalf of the legitimate claimant. If names become permanent and non-transferable without such a mechanism, impersonators gain irrevocable ownership.
+
+We need a solution that:
+
+- Works for **existing** names (opt-in by token holders) as well as fresh registrations.
+
+- Provides **protocol-level** guarantees (not just application-layer conventions).
+
+- Includes **admin safeguards** against impersonation and abuse.
+
+- Requires **no changes** to `BaseRegistrar`, `Registry`, or other immutable contracts.
+
+## Design Principles
+
+- **Minimal tear-up**: No modifications to immutable contracts avoiding complex migrations.
+- **Opt-in**: Existing name holders choose to promote their name. Unpromoted names behave exactly as they do today.
+- **Protocol-level enforcement**: Non-transferability and non-expiry are enforced on-chain, not by front-end convention.
+- **Admin reclamation**: Protocol operators retain the ability to reclaim names from bad actors or impersonators, mirroring the moderation model of web2 identity platforms (Twitter, Facebook, etc.). Permanence applies to good-faith holders; it does not grant squatters or impersonators irrevocable ownership.
+- **Leverage existing extension points**: `BaseRegistrar.addController(address)` grants the wrapper `renew` access. `UpgradeableL2Resolver.setControllerApproval` grants resolver record access. No new permissions models are needed.
+- **ENS specification compliance**: Promoted names must remain resolvable via standard ENS interfaces (ENSIP-1 through ENSIP-19), CCIP-read (ERC-3668/ENSIP-10) must continue to work for L1 resolution, and reverse resolution must behave identically to unpromoted names. Any ENS-speaking client should resolve a promoted name without awareness of the wrapper.
+
+---
+
+## Architecture: `SoulboundNameWrapper`
+
+A single new contract that acts as both a **controller** on `BaseRegistrar` and an **ERC721** (soul-bound) token issuer. It custodies the underlying `BaseRegistrar` NFTs, issues non-transferable wrapper tokens to users, and provides admin hooks for moderation.
+
+### How It Works
+
+```
+                           ┌───────────────────────────────┐
+                           │    SoulboundNameWrapper       │
+                           │                               │
+   User calls              │  - ERC721 (soul-bound)        │
+   wrap(id) ──────────────>│  - IERC721Receiver            │
+                           │  - Controller on BaseRegistrar│
+                           │  - AccessControl (roles)      │
+                           │                               │
+                           │  Holds underlying NFTs        │
+                           │  Issues non-transferable      │
+                           │  wrapper tokens               │
+                           └────────┬──────────────────────┘
+                                    │
+              ┌─────────────────────┼─────────────────────┐
+              │                     │                     │
+              ▼                     ▼                     ▼
+     BaseRegistrar            ENS Registry          L2 Resolver
+     (NFT custodied           (subnode owner        (records set
+      by wrapper)              set to user)          by user or
+                                                     wrapper)
+```
+
+### Wrap Flow (Existing Names)
+
+An existing name holder opts in by approving and wrapping:
+
+1. **User approves** the wrapper to transfer their Basename NFT.
+2. **User calls `wrap(id)`** on the `SoulboundNameWrapper`.
+3. The wrapper atomically:
+   - **Takes custody of the underlying Basename** via `safeTransferFrom`. The wrapper now holds the ERC721 token.
+   - **Extends expiry to permanent** by calling `BaseRegistrar.renew(id, ...)` to push `nameExpires[id]` to effectively infinity.
+   - **Restores ENS Registry ownership to the user** via `BaseRegistrar.reclaim(id, user)`, ensuring the user retains full control over their resolver records.
+   - **Mints a soul-bound wrapper token** (same `id`) to the user. This token is non-transferable (possible EIP-5192 compliance).
+   - **Sets promotion metadata** on the resolver via multicall (e.g., `basenames.promoted` text record and the ENSIP-18 `alias` text record).
+
+### Fresh Registration Flow
+
+For names that don't exist yet, the wrapper can also handle first-time registration:
+
+1. **User calls `registerAndWrap(name, resolver, data)`**.
+2. The wrapper registers the name with itself as the `BaseRegistrar` NFT owner, permanent duration, then follows the same `reclaim` + mint + metadata steps as the wrap flow.
+
+Business logic around pricing/discounting can be determined in a separate exploration.
+
+---
+
+## Objective 1: Non-Expiring Names
+
+### Mechanism
+
+The wrapper, as an approved controller, calls `BaseRegistrar.renew(id, duration)` where `duration` is computed to push `nameExpires[id]` to a value no `block.timestamp` will ever reach (effectively `type(uint256).max`).
+
+The key interactions with `BaseRegistrar`:
+
+- **`isAvailable(id)`** evaluates `nameExpires[id] + GRACE_PERIOD < block.timestamp` -- with a near-max expiry, this is never true. The name can never appear available for re-registration.
+- **`ownerOf(id)`** checks `nameExpires[id] <= block.timestamp` -- never true. The token never appears expired.
+- **`renew` preconditions**: The name must still be active or within its grace period at wrap time. Users must wrap **before** their grace period ends; lapsed names would need to be re-registered fresh via `registerAndWrap`.
+
+**Edge case note**: Setting expiry to exactly `type(uint256).max` causes `isAvailable` to overflow in Solidity 0.8+ (revert, not wrap-around), which is safe but inelegant. Using `type(uint256).max - GRACE_PERIOD - 1` avoids this cleanly. The exact value is an implementation detail to validate in testing.
+
+### Trade-offs
+
+| Consideration | Impact |
+|---|---|
+| **Permanent occupation** | The name is taken for the lifetime of the protocol. If the holder loses their keys, admin reclamation (see below) provides a recovery path. Without admin action, the name sits occupied by an inaccessible address. |
+| **No renewal revenue** | Promoted names generate zero ongoing fees. |
+| **Grace period deadline** | Existing holders must wrap before their name lapses past grace. Communication and UX need to make this clear. |
+
+---
+
+## Objective 2: Non-Transferable (Soul-Bound)
+
+### Mechanism
+
+The `SoulboundNameWrapper` is itself an ERC721 contract whose tokens **cannot be transferred by the holder**:
+
+1. **Transfer functions revert** -- `transferFrom` and `safeTransferFrom` revert unconditionally for all wrapper tokens.
+2. **Underlying NFT is custodied** -- The `BaseRegistrar` token sits in the wrapper contract. The user has no access to transfer it.
+
+The **sole exception** to non-transferability is **admin reclamation**: protocol operators with the `RECLAIMER_ROLE` can reassign a wrapped name to a different address (see Admin Reclamation section). This is an intentional carve-out for moderation and recovery, not a general transfer mechanism.
+
+### Why This Is Protocol-Level
+
+- The wrapper **custodies** the underlying NFT. The user cannot call `transferFrom` on `BaseRegistrar` because they are not the owner or approved.
+- The wrapper **never exposes** a code path that moves the underlying NFT to another address.
+- The wrapper's own ERC721 tokens revert on transfer.
+- No application-layer trust is required for the non-transferability guarantee -- it is enforced entirely in smart contract logic. The admin reclamation path is the only override, and it is role-gated on-chain.
+
+### Registry Ownership
+
+After wrapping, the ENS Registry subnode owner is the **user** (via `reclaim`). This means:
+
+- The user retains full control over resolver records (`setText`, `setAddr`, etc.).
+- The user can change their resolver via `registry.setResolver(node, newResolver)`.
+- The user **cannot** re-register or transfer the underlying name -- the `BaseRegistrar` NFT is custodied by the wrapper.
+- Admin reclamation can reassign Registry subnode ownership to a new address if needed.
+
+### Trade-offs
+
+| Consideration | Impact |
+|---|---|
+| **Key loss** | If a user loses access to their address, the wrapper token is inaccessible. Admin reclamation provides a recovery path: the user proves identity off-chain, the admin reassigns to a new address. Without admin action, the name remains occupied. |
+| **Marketplace compatibility** | EIP-5192 is increasingly supported. Non-compliant marketplaces may still display the token but transfer attempts will revert on-chain. |
+| **Admin trust** | The soul-bound guarantee has an admin exception. Users must trust the operator's moderation policy. This is mitigated by role-based access, multi-sig governance, and on-chain audit trails (see Admin Reclamation). |
+
+---
+
+## Objective 3: Changeable Display Name
+
+### Mechanism
+
+The label (e.g., "alice" in `alice.base.eth`) is immutable -- it's `keccak256(label)`, baked into the token ID. We layer a **mutable display name** on top using the **`alias` text record** defined in [ENSIP-18: Profile Text Records](https://docs.ens.domains/ensip/18).
+
+ENSIP-18 standardizes `alias` as a display alias for ENS names. The spec states it should be displayed near the ENS name but not as a replacement, and should sit below the name in visual hierarchy. This is exactly the semantics we want -- no need for a custom `basenames.display` key.
+
+1. **Text record key**: `"alias"` (ENSIP-18 standard).
+2. During wrap, the wrapper sets `setText(node, "alias", label)` as a sensible default.
+3. The **user can update it at any time** by calling `setText(node, "alias", "New Name")` on the resolver. This works today -- the user is the Registry owner of the node and passes the resolver's authorization check.
+4. The **front-end** resolves display names by checking `text(node, "alias")` first, falling back to the on-chain label if unset. Per ENSIP-18, apps should also check the legacy `name` text key as a fallback if `alias` is not set.
+
+Using the ENSIP-18 standard means any ENS-aware app that supports profile text records will display the alias automatically, not just the Basenames front-end.
+
+### Reverse Resolution
+
+Reverse resolution (address -> name) is a separate mechanism via `ReverseRegistrar.setNameForAddr()`. A user's primary name and their alias text record are independent -- the primary name controls ENS reverse resolution, while the alias is a cosmetic overlay for UIs that support ENSIP-18 profiles.
+
+### Trade-offs
+
+| Consideration | Impact |
+|---|---|
+| **No uniqueness** | Aliases are cosmetic. Two users can set the same alias. The `.base.eth` name remains the unique identifier. |
+| **No content validation** | Aliases are arbitrary strings (per ENSIP-18: "any text"). Content moderation would happen at the front-end or API layer. |
+| **Cheap to update** | `setText` on Base L2 costs fractions of a cent. Low barrier to experimentation. |
+| **Ecosystem compatibility** | Any ENS app supporting ENSIP-18 will display the alias without Basenames-specific integration. |
+
+---
+
+## Admin Reclamation
+
+### Motivation
+
+Permanence and non-transferability are the core value proposition, but they also raise the stakes on impersonation and abuse. If a bad actor wraps `brianarmstrong.base.eth`, that name is locked forever with no recourse -- unless the protocol has a moderation mechanism.
+
+Web2 identity platforms solve this with admin moderation: Twitter reclaims `@elonmusk`, Facebook reclaims `/zuck`. The wrapper provides an on-chain equivalent, allowing protocol operators to act on behalf of legitimate claimants while maintaining a transparent audit trail.
+
+Admin reclamation also serves as a **key loss recovery mechanism**: a user who loses access to their wallet can prove their identity off-chain, and the admin can reassign the name to a new address.
+
+### Mechanism
+
+Two admin functions, both restricted to a `RECLAIMER_ROLE`:
+
+**`adminReclaim(id, newOwner, reason)`** -- Reassign a wrapped name:
+
+1. Burns the current soul-bound wrapper token from the current holder.
+2. Mints a new soul-bound wrapper token to `newOwner`.
+3. Reassigns ENS Registry subnode ownership to `newOwner` via `BaseRegistrar.reclaim(id, newOwner)`.
+4. Optionally clears or resets resolver records.
+5. Emits `NameReclaimed(id, previousOwner, newOwner, reason)`.
+
+**`adminRevoke(id, reason)`** -- Suspend a name without reassigning:
+
+1. Burns the current wrapper token.
+2. Sets the Registry subnode owner to the wrapper itself (suspended state).
+3. The name can later be reassigned via `adminReclaim` or left in limbo.
+4. Emits `NameRevoked(id, previousOwner, reason)`.
+
+In both cases, the underlying `BaseRegistrar` NFT never moves -- it stays custodied by the wrapper. Only the wrapper-level ownership and Registry subnode ownership change.
+
+### Access Control
+
+The reclamation power should be narrowly scoped and governed:
+
+- **Role-based access** via OZ `AccessControl`: a `RECLAIMER_ROLE` separate from `DEFAULT_ADMIN_ROLE`. Moderators can reclaim names without having full admin access to the contract.
+- **Multi-sig governance**: The `RECLAIMER_ROLE` should be held by a multi-sig (e.g., Gnosis Safe) operated by the moderation team, not a single EOA.
+- **On-chain audit trail**: Every reclamation emits an event with the previous owner, new owner, and a reason string. These events are permanent, indexable, and publicly auditable.
+- **Optional timelock**: Reclamation could be subject to an on-chain delay (e.g., 48-72 hours) before taking effect, giving the current holder visibility and a window to dispute. This adds trust at the cost of slower response to clear-cut abuse. Worth exploring whether urgent cases (e.g., active impersonation scams) need a fast-path.
+
+### What Reclamation Does NOT Do
+
+- It does **not** move the underlying `BaseRegistrar` NFT -- that stays in the wrapper permanently.
+- It does **not** change the name's permanent expiry -- the name remains non-expiring.
+- It does **not** affect other wrapped names -- reclamation is strictly per-name.
+
+### Trade-offs
+
+| Consideration | Impact |
+|---|---|
+| **Trust in admin** | Users accept that the soul-bound guarantee has an admin exception. This is identical to how web2 platforms operate. Multi-sig + audit trail + optional timelock mitigate abuse risk. |
+| **Policy dependency** | The smart contract provides the *capability*; a separate policy document must define *when* reclamation is appropriate (impersonation, trademark, court order, key recovery, etc.). The contract is intentionally policy-agnostic. |
+| **Reclamation as recovery** | Using the same mechanism for moderation and key-loss recovery is pragmatic but blurs two distinct use cases. Consider whether recovery should have a separate flow with different approval requirements (e.g., higher multi-sig threshold or identity verification). |
+
+---
+
+## Contract Design: `SoulboundNameWrapper`
+
+This section outlines the likely shape of the contract. It is intentionally high-level -- exact interfaces, storage layouts, and implementation choices will be refined during development.
+
+### Inheritance & Interfaces
+
+- **ERC721** (Solady or OZ) -- for issuing wrapper tokens
+- **IERC721Receiver** -- to accept `safeTransferFrom` of underlying tokens
+- **EIP-5192** -- `Locked` event + `locked(id)` view for soul-bound signaling
+- **AccessControl** (OZ) -- role-based admin: `DEFAULT_ADMIN_ROLE` for configuration, `RECLAIMER_ROLE` for name reclamation and recovery
+
+### Key State
+
+| Field | Purpose |
+|---|---|
+| `baseRegistrar` | Reference to the `BaseRegistrar` contract |
+| `registry` | Reference to the ENS Registry |
+| `rootNode` | `BASE_ETH_NODE` for computing subnodes |
+| `defaultResolver` | Default resolver for fresh registrations |
+
+The wrapper token `ownerOf(id)` is the canonical owner of the promoted identity. No separate ownership mapping is needed.
+
+### Key Functions
+
+| Function | Role | Access |
+|---|---|---|
+| `wrap(id)` | Opt-in promotion for existing names. Takes custody, extends expiry, mints soul-bound token. | Any name holder |
+| `registerAndWrap(request)` | Register + promote a new name in one step. | Public (with payment if applicable) |
+| `adminReclaim(id, newOwner, reason)` | Reassign a name to a new owner (moderation or recovery). | `RECLAIMER_ROLE` |
+| `adminRevoke(id, reason)` | Suspend a name without reassigning. | `RECLAIMER_ROLE` |
+| `transferFrom` / `safeTransferFrom` | Revert unconditionally. Soul-bound. | N/A (always reverts) |
+| `locked(id)` | Returns `true` for all wrapper tokens (EIP-5192). | Public view |
+| `onERC721Received` | Accept NFTs from `BaseRegistrar` during wrap. | Callback |
+
+### Permissions Required from Existing Contracts
+
+| Contract | Permission | How to Grant |
+|---|---|---|
+| `BaseRegistrar` | Controller (for `renew` and `registerWithRecord`) | `BaseRegistrar.addController(wrapper)` (owner-only) |
+| `UpgradeableL2Resolver` | Approved controller (for `setText` during wrap and reclamation) | `resolver.setControllerApproval(wrapper, true)` (owner-only) |
+| `ReverseRegistrar` | Controller (for setting reverse records) | `ReverseRegistrar.setControllerApproval(wrapper, true)` (owner-only) |
+
+### What Doesn't Change
+
+- `BaseRegistrar` -- unchanged, deployed as-is
+- `Registry` -- unchanged
+- `UpgradeableL2Resolver` -- unchanged (just approve the new controller)
+- `ReverseRegistrar` -- unchanged (just approve the new controller)
+- All existing names -- completely unaffected unless the owner opts in
+- All existing controllers -- continue to function normally
+
+---
+
+## Migration Path for Existing Names
+
+### Standard Wrap (Active or Grace Period)
+
+For names that are currently registered or within their 90-day grace period:
+
+1. User approves the wrapper on `BaseRegistrar`.
+2. User calls `wrap(id)`.
+3. Done. Name is now permanent, soul-bound, with a mutable alias and admin recovery as a safety net.
+
+### Lapsed Names (Past Grace Period)
+
+For names that have fully lapsed:
+
+- `renew` will revert -- the name is no longer in grace.
+- The name is available for re-registration by anyone.
+- `registerAndWrap` can register it fresh with permanent expiry.
+- The original holder has no priority (the name is fully released). If priority is desired, an admin-gated `registerAndWrapFor(name, beneficiary)` could reserve it during a rollout window.
+
+### Batch Operations
+
+For promotional rollouts:
+
+- `batchWrap(ids, owners)` -- batch opt-in, requires prior approval of each token.
+- `batchRegisterAndWrap` -- admin-gated batch registration for new names (e.g., airdropping promoted names to notable community members).
+
+---
+
+## End-to-End Ownership Model After Wrapping
+
+| Layer | Owner | Implication |
+|---|---|---|
+| `BaseRegistrar` ERC721 | `SoulboundNameWrapper` contract | Custodied permanently. Cannot be transferred out. Non-expiring. |
+| ENS `Registry` subnode | User's address (via `reclaim`) | User controls resolver and all records. Admin can reassign via reclamation. |
+| `SoulboundNameWrapper` ERC721 | User's address | Soul-bound to user. Cannot be transferred. Admin can reassign via reclamation. |
+| Resolver records | User (authorized as Registry owner) | User can `setText`, `setAddr`, etc. freely. Admin can clear during reclamation. |
+| Reverse record | User (via `ReverseRegistrar`) | User controls their primary name. |
+
+---
+
+## Summary of Guarantees
+
+| Property | Mechanism | Guarantee Level |
+|---|---|---|
+| **Non-expiring** | `renew` to near-max expiry via controller access | Protocol-level. No `block.timestamp` will ever exceed the expiry. |
+| **Non-transferable** | Wrapper custodies underlying NFT; wrapper tokens revert on transfer; EIP-5192 | Protocol-level. No user-initiated transfer path exists. Admin reclamation is the sole exception. |
+| **Admin reclamation** | `RECLAIMER_ROLE` can reassign or revoke names | Protocol-level. Role-gated, auditable, optionally timelocked. |
+| **Changeable display** | ENSIP-18 `alias` text record set by user on resolver | ENS standard (ENSIP-18). User has full on-chain control. Compatible with any ENSIP-18-aware app. |
+
+---
+
+## Recommended Implementation Order
+
+1. **Phase 1: Contract Development**
+   - Implement `SoulboundNameWrapper` with `wrap`, `registerAndWrap`, soul-bound ERC721, EIP-5192, `AccessControl`, `adminReclaim`, `adminRevoke`.
+   - Comprehensive test coverage: wrap flow, permanent expiry edge cases, transfer reverts, resolver record access post-wrap, `reclaim` behavior, admin reclamation/revocation, role-based access control.
+
+2. **Phase 2: Deployment & Configuration**
+   - Deploy `SoulboundNameWrapper`.
+   - Grant controller permissions on `BaseRegistrar`, `UpgradeableL2Resolver`, `ReverseRegistrar`.
+   - Assign `RECLAIMER_ROLE` to a multi-sig operated by the moderation team.
+   - Assign `DEFAULT_ADMIN_ROLE` to a multi-sig + timelock for protocol governance.
+
+3. **Phase 3: Front-End & Operations**
+   - Wrap UI: approve + wrap flow for existing holders.
+   - Display name resolution: read ENSIP-18 `alias` text record with label fallback.
+   - Display name editing UI.
+   - Visual distinction for promoted vs. standard names.
+   - Admin dashboard: reclamation event log, dispute/request intake for impersonation and recovery claims.
+   - Reclamation policy documentation (public-facing).
+
+---
+
+## Areas to Explore Further
+
+1. **Unwrap path**: Should users be able to unwrap (revert to a normal transferable name)? If so, the permanent expiry cannot be undone (`renew` only extends), so the name stays non-expiring. An unwrap path weakens the identity guarantee but adds user flexibility. Is there a middle ground -- e.g., unwrap with a cooldown or admin approval?
+
+2. **Pricing model**: Is wrapping free? One-time fee? Should `registerAndWrap` mirror normal registration pricing, or is it a separate promotional price tier? How does this interact with the existing discount system?
+
+3. **Eligibility gating**: Should anyone be able to wrap, or only addresses that meet criteria (allowlist, attestation, on-chain activity threshold)? The existing `IDiscountValidator` pattern could be reused. For early rollout, admin-only or allowlisted wrapping may be preferable.
+
+4. **Reclamation policy framework**: The contract is intentionally policy-agnostic. A separate governance document should define when reclamation is appropriate: impersonation, trademark disputes, court orders, key-loss recovery. Different categories may warrant different approval thresholds (e.g., impersonation = standard multi-sig, key recovery = higher threshold + identity proof).
+
+5. **Timelock vs. fast-path reclamation**: A timelock adds transparency but slows response to active scams. Explore a two-tier model: standard reclamation with a 48-72h delay, and an emergency fast-path for urgent cases (e.g., active phishing) with a higher multi-sig quorum.
+
+6. **Alias uniqueness**: Should aliases be unique? On-chain enforcement adds complexity and gas cost. Off-chain enforcement via an indexer is simpler but weaker. No enforcement is simplest and aligns with how ENSIP-18 defines the field ("any text") -- the `.base.eth` label remains the canonical unique identifier.
+
+7. **Token metadata and visual identity**: Should promoted names have distinct metadata, artwork, or badge indicators in the wrapper token's `tokenURI`? This could help wallets and UIs visually distinguish promoted identities from standard names.
+
+8. **Subgraph and indexer impact**: Existing subgraphs index `BaseRegistrar` Transfer/NameRegistered events. Wrapped names will appear as owned by the wrapper contract at the `BaseRegistrar` level. Indexers and front-ends that rely on `BaseRegistrar.ownerOf` will need to understand the wrapper layer. Explore whether the wrapper should emit compatible events or if a dedicated subgraph is more appropriate.
+
+9. **L1 resolution path**: Promoted names resolve on L1 via `L1Resolver` + CCIP-read, which ultimately queries L2 state. Wrapping should not affect this path since the resolver and Registry records are unchanged. Worth validating end-to-end in a fork test.
+
+10. **Progressive decentralization of admin**: Over time, the `RECLAIMER_ROLE` could transition from a team-operated multi-sig to a community governance process (e.g., on-chain dispute resolution DAO). The `AccessControl` pattern supports this by allowing role reassignment without contract changes.