diff --git a/product-sdk/packages/sdk/src/identity/dotns-abis.test.ts b/product-sdk/packages/sdk/src/identity/dotns-abis.test.ts new file mode 100644 index 00000000..18c8b227 --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-abis.test.ts @@ -0,0 +1,43 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +// +// Guards on the transcribed deployment table. These check the shape of the +// table, not that it names the live deployment: only a diff against +// `paritytech/dotns` `deployments/paseo-assethub/420420417.json` can do that, +// and it is a network fetch. See the comment on DOTNS_ADDRESSES for the +// commit the values were copied from, and re-check it when bumping. +import { isValidH160 } from "@parity/product-sdk-address"; +import { describe, expect, test } from "vitest"; +import { DOTNS_ADDRESSES } from "./dotns-abis.js"; + +describe("DOTNS_ADDRESSES", () => { + test("every entry is a well-formed H160", () => { + for (const [name, address] of Object.entries(DOTNS_ADDRESSES)) { + expect(isValidH160(address), `${name} = ${address}`).toBe(true); + } + }); + + test("no two entries share an address", () => { + // Six addresses transcribed by hand: a duplicated paste is the + // transcription error most likely to go unnoticed, because the wrong + // contract still answers and only the decode looks odd. + const entries = Object.entries(DOTNS_ADDRESSES); + const seen = new Map(); + for (const [name, address] of entries) { + const key = address.toLowerCase(); + expect(seen.get(key), `${name} duplicates ${seen.get(key)}`).toBeUndefined(); + seen.set(key, name); + } + expect(seen.size).toBe(entries.length); + }); + + test("the resolver and the reverse resolver are different contracts", () => { + // Load-bearing beyond general distinctness: resolveDotNs decides whether + // a node has a forward record by comparing the registry's resolver + // pointer against the reverse resolver, so collapsing these two would + // make every registered name look resolvable. + expect(DOTNS_ADDRESSES.resolver.toLowerCase()).not.toBe( + DOTNS_ADDRESSES.reverseResolver.toLowerCase(), + ); + }); +}); diff --git a/product-sdk/packages/sdk/src/identity/dotns-abis.ts b/product-sdk/packages/sdk/src/identity/dotns-abis.ts new file mode 100644 index 00000000..2e4df4b1 --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-abis.ts @@ -0,0 +1,274 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * Minimal ABIs for the DotNS contracts: the read and write methods this module + * calls, and nothing else. Sourced from `paritytech/dotns` (`contracts/registry`, + * `contracts/resolvers`, `contracts/registrars`, `contracts/pop`). + */ +import type { AbiEntry } from "@parity/product-sdk-contracts"; + +/** + * `DotnsRegistry` — node → resolver / owner, plus the owner-gated pointer write. + * + * `setResolver` is needed because registration parks `records[node].resolver` on + * the reverse resolver, so the pointer must be moved once before any address + * record is readable. + */ +export const DOTNS_REGISTRY_ABI: AbiEntry[] = [ + { + type: "function", + name: "resolver", + inputs: [{ name: "node", type: "bytes32" }], + outputs: [{ name: "", type: "address" }], + stateMutability: "view", + }, + { + type: "function", + name: "owner", + inputs: [{ name: "node", type: "bytes32" }], + outputs: [{ name: "", type: "address" }], + stateMutability: "view", + }, + { + type: "function", + name: "setResolver", + inputs: [ + { name: "node", type: "bytes32" }, + { name: "resolverAddr", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +]; + +/** `DotnsResolver` — node → resolved address. */ +export const DOTNS_RESOLVER_ABI: AbiEntry[] = [ + { + type: "function", + name: "addressOf", + inputs: [{ name: "node", type: "bytes32" }], + outputs: [{ name: "value", type: "address" }], + stateMutability: "view", + }, +]; + +/** `DotnsReverseResolver` — account → primary name. */ +export const DOTNS_REVERSE_RESOLVER_ABI: AbiEntry[] = [ + { + type: "function", + name: "nameOf", + inputs: [{ name: "addr", type: "address" }], + outputs: [{ name: "name", type: "string" }], + stateMutability: "view", + }, +]; + +/** `DotnsResolver` write — set a node's resolved address (owner-gated). */ +export const DOTNS_RESOLVER_WRITE_ABI: AbiEntry[] = [ + { + type: "function", + name: "setAddress", + inputs: [ + { name: "node", type: "bytes32" }, + { name: "value", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +]; + +/** + * `DotnsRegistrarController` — the commit-reveal registration flow. `register` + * is payable; the value is the price from {@link DOTNS_POP_RULES_ABI}. + * `Registration` = (label, owner, secret, reserved). + */ +export const DOTNS_REGISTRAR_CONTROLLER_ABI: AbiEntry[] = [ + { + type: "function", + name: "available", + inputs: [{ name: "label", type: "string" }], + outputs: [{ name: "isAvailable", type: "bool" }], + stateMutability: "view", + }, + { + type: "function", + name: "minCommitmentAge", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "maxCommitmentAge", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "makeCommitment", + inputs: [ + { + name: "registration", + type: "tuple", + components: [ + { name: "label", type: "string" }, + { name: "owner", type: "address" }, + { name: "secret", type: "bytes32" }, + { name: "reserved", type: "bool" }, + ], + }, + ], + outputs: [{ name: "commitment", type: "bytes32" }], + stateMutability: "pure", + }, + { + type: "function", + name: "commit", + inputs: [{ name: "commitment", type: "bytes32" }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "register", + inputs: [ + { + name: "registration", + type: "tuple", + components: [ + { name: "label", type: "string" }, + { name: "owner", type: "address" }, + { name: "secret", type: "bytes32" }, + { name: "reserved", type: "bool" }, + ], + }, + ], + outputs: [], + stateMutability: "payable", + }, +]; + +/** + * `PopRules` — registration pricing. + * + * `price` is the label-only cost and runs no eligibility rules, so it is not + * what `register` charges. `register` uses the owner-aware variants and adds + * `transferFloor` when the payer is not the owner: + * `max(priceWithoutCheck(label, owner).price, transferFloor(label, payer, owner))`. + * + * `PopStatus` encodes as `uint8`: 0 NoStatus, 1 PopLite, 2 PopFull, 3 Reserved. + */ +export const DOTNS_POP_RULES_ABI: AbiEntry[] = [ + { + type: "function", + name: "price", + inputs: [{ name: "name", type: "string" }], + outputs: [{ name: "cost", type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "priceWithoutCheck", + inputs: [ + { name: "name", type: "string" }, + { name: "userAddress", type: "address" }, + ], + outputs: [ + { + name: "metadata", + type: "tuple", + components: [ + { name: "price", type: "uint256" }, + { name: "status", type: "uint8" }, + { name: "userStatus", type: "uint8" }, + { name: "message", type: "string" }, + ], + }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "classifyName", + inputs: [{ name: "name", type: "string" }], + outputs: [ + { name: "requirement", type: "uint8" }, + { name: "message", type: "string" }, + ], + stateMutability: "pure", + }, + { + type: "function", + name: "transferFloor", + inputs: [ + { name: "name", type: "string" }, + { name: "from", type: "address" }, + { name: "to", type: "address" }, + ], + outputs: [{ name: "floor", type: "uint256" }], + stateMutability: "view", + }, +]; + +/** + * `IDotnsProtocolRegistry`, the two TLD getters. + * + * The TLD is per network: `initialize(tldLabel)` fixes it once at deployment + * and there is no setter, so these are immutable for the life of a deployment. + * `tld()` returns the suffix with its leading dot (`".paseo"`); `tldNode()` + * returns `namehash(0, labelhash(label))`, which is derivable from the suffix + * and so is read only as a cross-check. + * + * Deployments predating `dotns` `b4096968` have neither getter — the TLD was a + * compile-time constant there — and revert with an empty payload. + */ +export const DOTNS_PROTOCOL_REGISTRY_ABI: AbiEntry[] = [ + { + type: "function", + name: "tld", + inputs: [], + outputs: [{ name: "suffix", type: "string" }], + stateMutability: "view", + }, + { + type: "function", + name: "tldNode", + inputs: [], + outputs: [{ name: "node", type: "bytes32" }], + stateMutability: "view", + }, +]; + +/** `IPopRules.PopStatus`. Ordering is meaningful: a user meets a tier when `userStatus >= status`. */ +export const POP_STATUS = { NoStatus: 0, PopLite: 1, PopFull: 2, Reserved: 3 } as const; + +/** + * The deployed DotNS addresses. The same on every network. + * + * Not a per-network table, despite what the old name (`PASEO_ASSETHUB_DOTNS`) + * implied: every DotNS network is deployed through the same CREATE3 factory, so + * the addresses are identical everywhere and **only the TLD differs**. Verified + * on both Paseo Asset Hub Next V2 and Previewnet — `protocolRegistry.get(...)` + * returns the same registry on each, and all six addresses hold contracts on + * both. `paritytech/dotns` `DEPLOYMENTS.md` states the mechanism, and + * `preview-net-v1` confirms it from the deploy side: the TLD is passed only as + * registry init calldata, so it moves no address. + * + * So extending this module to another network means resolving its TLD, which + * `resolveTld` already does — not adding a second address table. + * + * Each address answers `DotnsProtocolRegistry.get(bytes32)` under its + * `DotnsConstants` key (the name below, right-padded to 32 bytes) and has a + * `Revive.AccountInfoOf` entry. They are pinned rather than resolved at runtime, + * so re-verify after a redeploy; `protocolRegistry` is the address to walk from + * if we ever resolve the rest dynamically. + */ +export const DOTNS_ADDRESSES = { + registry: "0xf34054fd76BbF85f216cf9908226D5f0A72E50CA", + reverseResolver: "0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035", + resolver: "0xbd1165E549DF96F083c0A16f61590927bC187009", + registrarController: "0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30", + popRules: "0x747B456bE03aec0b42bd85C51513730FBD45DA31", + protocolRegistry: "0xD19e3D0C97CF501125a04A97405e3e6592fa846E", +} as const; diff --git a/product-sdk/packages/sdk/src/identity/dotns-errors.ts b/product-sdk/packages/sdk/src/identity/dotns-errors.ts new file mode 100644 index 00000000..da978d25 --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-errors.ts @@ -0,0 +1,34 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +import type { SdkError } from "@parity/product-sdk-errors"; + +/** + * Error on the `Err` channel of a DotNS registry read/write. Implements the + * cross-package {@link SdkError} marker so `isSdkError(e)` recognizes it. + * + * `reason` distinguishes the failure kind so callers can branch without string + * matching. + */ +export type DotNsErrorReason = + | "InvalidName" // name failed isValidDotNsName + | "InvalidTld" // the deployment reported a TLD we cannot use, or the caller supplied an inconsistent one + | "TldMismatch" // a well-formed name, but rooted at another deployment's TLD + | "MissingOrigin" // a write was requested without the submitting account + | "InvalidOrigin" // the submitting account was supplied, but is not a decodable SS58 address + | "NameUnavailable" // already registered + | "NameReserved" // governance-held, or a base stem held by another user + | "OwnerStatusInsufficient" // the owner lacks the personhood tier the label requires + | "RegistryCall" // the on-chain contract call failed + | "Decode"; // the contract returned something we couldn't decode + +export class DotNsError extends Error implements SdkError { + readonly isSdkError = true as const; + readonly source = "dotns"; + readonly reason: DotNsErrorReason; + + constructor(reason: DotNsErrorReason, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "DotNsError"; + this.reason = reason; + } +} diff --git a/product-sdk/packages/sdk/src/identity/dotns-namehash.test.ts b/product-sdk/packages/sdk/src/identity/dotns-namehash.test.ts new file mode 100644 index 00000000..61c37ed8 --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-namehash.test.ts @@ -0,0 +1,155 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +import { bytesToHex, keccak256 } from "@parity/product-sdk-crypto"; +import { describe, expect, test } from "vitest"; +import { DOT_NODE, DOT_TLD, dotNsTld, isConsistentDotNsTld, namehash } from "./dotns-namehash.js"; + +// Independent reference implementation of the same algorithm, so the test +// pins behaviour rather than restating the code. +function hexBytes(hex: string): Uint8Array { + const pairs = hex.slice(2).match(/.{2}/g) ?? []; + return Uint8Array.from(pairs.map((b) => Number.parseInt(b, 16))); +} + +function refNamehash(name: string, suffix: string, root: string): string { + const trimmed = name.endsWith(suffix) ? name.slice(0, -suffix.length) : name; + const labels = trimmed === "" ? [] : trimmed.split("."); + let node = hexBytes(root); + for (let i = labels.length - 1; i >= 0; i--) { + const lh = keccak256(new TextEncoder().encode(labels[i])); + const combined = new Uint8Array(64); + combined.set(node, 0); + combined.set(lh, 32); + node = keccak256(combined); + } + return `0x${bytesToHex(node)}`; +} + +/** + * The `.paseo` TLD node, as `DotnsProtocolRegistry.tldNode()` reports it on + * Paseo Asset Hub Next V2. + * + * Pinned as an observed literal on purpose: it is the one value that proves our + * derivation matches what the contract computed at initialisation. Deriving it + * here instead would make the test agree with the code by construction. + * + * Needs no re-verification — it is `keccak256(0x00…00 ‖ keccak256("paseo"))`, + * so it is fixed by the label alone, not by any deployment's current state. + */ +const PASEO_NODE_FROM_CHAIN = "0x096b436ee9a398429fe33ad4b359bad4398dd74b412ec1dd043c93dfbf581874"; + +const PASEO_TLD = dotNsTld(".paseo"); + +describe("dotns namehash", () => { + test("DOT_NODE is keccak256(zeros32 || keccak256('dot'))", () => { + // The only hardcoded value in the module: every node hash derives from + // it, and namehash(".dot") returning it is a tautology, since an empty + // label list skips the loop. Derive it instead. + const labelhash = keccak256(new TextEncoder().encode("dot")); + const combined = new Uint8Array(64); // leading 32 bytes are the zero root + combined.set(labelhash, 32); + expect(`0x${bytesToHex(keccak256(combined))}`).toBe(DOT_NODE); + }); + + test("bare .dot hashes to the DOT_NODE constant", () => { + expect(namehash(".dot", DOT_TLD)).toBe(DOT_NODE); + expect(namehash("", DOT_TLD)).toBe(DOT_NODE); + }); + + test("normalizes the same with or without the .dot suffix", () => { + expect(namehash("alice.dot", DOT_TLD)).toBe(namehash("alice", DOT_TLD)); + }); + + test("matches the reference algorithm for single + sub labels", () => { + expect(namehash("alice.dot", DOT_TLD)).toBe(refNamehash("alice.dot", ".dot", DOT_NODE)); + expect(namehash("bob.alice.dot", DOT_TLD)).toBe( + refNamehash("bob.alice.dot", ".dot", DOT_NODE), + ); + }); + + test("distinct names produce distinct nodes", () => { + expect(namehash("alice.dot", DOT_TLD)).not.toBe(namehash("bob.dot", DOT_TLD)); + }); + + test("returns a 32-byte 0x-hex string", () => { + expect(namehash("alice.dot", DOT_TLD)).toMatch(/^0x[0-9a-f]{64}$/); + }); + + test("roots at the TLD it is given, matching the reference algorithm", () => { + expect(namehash("alice.paseo", PASEO_TLD)).toBe( + refNamehash("alice.paseo", ".paseo", PASEO_NODE_FROM_CHAIN), + ); + expect(namehash("bob.alice.paseo", PASEO_TLD)).toBe( + refNamehash("bob.alice.paseo", ".paseo", PASEO_NODE_FROM_CHAIN), + ); + }); + + test("the same label under two TLDs gives two different nodes", () => { + // The bug this whole change exists for, in one assertion: `dim2` is + // owned on paseo-asset-hub-next under `.paseo` and unowned under `.dot`. + expect(namehash("alice.paseo", PASEO_TLD)).not.toBe(namehash("alice.dot", DOT_TLD)); + }); + + test("strips the suffix by length, not by a hardcoded 4", () => { + // `slice(0, -4)` would leave the label `alice.pa`, which hashes as the + // subname `pa` under `alice`. + const wrong = refNamehash("alice.pa", ".paseo", PASEO_NODE_FROM_CHAIN); + expect(namehash("alice.paseo", PASEO_TLD)).not.toBe(wrong); + expect(namehash("alice.paseo", PASEO_TLD)).toBe(namehash("alice", PASEO_TLD)); + }); +}); + +describe("dotNsTld", () => { + test("derives the .paseo node the contract computed at initialisation", () => { + // `DotnsProtocolRegistry.initialize` sets + // `_tldNode = namehashUnder(0, labelhash(tldLabel))`, so the node is a + // pure function of the suffix and we never need to read `tldNode()`. + expect(dotNsTld(".paseo").node).toBe(PASEO_NODE_FROM_CHAIN); + }); + + test("derives DOT_NODE for .dot, tying the helper to the constant", () => { + expect(dotNsTld(".dot").node).toBe(DOT_NODE); + expect(dotNsTld(".dot")).toEqual(DOT_TLD); + }); + + test("keeps the suffix verbatim, including its leading dot", () => { + expect(dotNsTld(".paseo").suffix).toBe(".paseo"); + }); + + test("accepts a bare label as well as a dotted suffix", () => { + // `tld()` returns ".paseo" but the deploy pipeline and `initialize` + // both speak the bare label, so accept either spelling. + expect(dotNsTld("paseo")).toEqual(dotNsTld(".paseo")); + }); +}); + +describe("isConsistentDotNsTld", () => { + test("accepts a pair whose node is the derivation of its suffix", () => { + expect(isConsistentDotNsTld(DOT_TLD)).toBe(true); + expect(isConsistentDotNsTld(PASEO_TLD)).toBe(true); + }); + + test("rejects a suffix paired with another TLD's node", () => { + // The override-path form of the original bug: `.paseo` names rooted at + // the `.dot` node resolve to nodes nobody owns. + expect(isConsistentDotNsTld({ suffix: ".paseo", node: DOT_NODE })).toBe(false); + }); + + test("rejects an empty suffix and a zero node", () => { + // What a pre-b4096968 proxy upgraded without a reinitializer reports: + // `_tld` is "" and `_tldNode` is 0x00…0, and both getters succeed. + expect(isConsistentDotNsTld({ suffix: "", node: DOT_NODE })).toBe(false); + expect(isConsistentDotNsTld({ suffix: ".", node: DOT_NODE })).toBe(false); + expect( + isConsistentDotNsTld({ + suffix: ".paseo", + node: `0x${"00".repeat(32)}`, + }), + ).toBe(false); + }); + + test("rejects a multi-label suffix, which no deployment can have", () => { + // `initialize` requires `tldLabel.isSingleLabel()`. + expect(isConsistentDotNsTld({ suffix: ".a.b", node: dotNsTld(".a.b").node })).toBe(false); + }); +}); diff --git a/product-sdk/packages/sdk/src/identity/dotns-namehash.ts b/product-sdk/packages/sdk/src/identity/dotns-namehash.ts new file mode 100644 index 00000000..e256eb6c --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-namehash.ts @@ -0,0 +1,149 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * DotNS namehash. + * + * ENS-style recursive hashing, but rooted at the network's TLD node rather than + * the empty root — matching `DotnsRegistry` (`contracts/registry/DotnsRegistry.sol` + * `_namehash`) and `LabelUtils.namehashUnder`. + * + * labelhash(l) = keccak256(utf8(l)) + * node(parent, l) = keccak256(parent ‖ labelhash(l)) + * node("alice.paseo") = node(tldNode, "alice") + * node("bob.alice.paseo") = node(node(tldNode, "alice"), "bob") + * + * The root is **per network**, not a constant. `DotnsProtocolRegistry.initialize` + * fixes it once from a deploy-time label — `.dot` on Paseo Asset Hub Previewnet, + * `.paseo` on Next V2 — and publishes it as `tld()` / `tldNode()`. Hashing under + * the wrong root yields a node the chain has never written to, so every lookup + * reports "unregistered" with no error anywhere. That is why {@link namehash} + * takes the root rather than assuming one; `resolveTld` in `./dotns-registry.js` + * is what asks the chain for it. + */ +import { bytesToHex, hexToBytes, keccak256 } from "@parity/product-sdk-crypto"; + +/** + * A network's DotNS top-level domain: the suffix names are spelled with, and + * the node every one of its names hashes onto. + * + * The two must agree — `node` is `namehash(0, labelhash(label))` of `suffix`'s + * label — so build one with {@link dotNsTld} rather than by hand, and check an + * externally supplied pair with {@link isConsistentDotNsTld}. A `.paseo` suffix + * carrying the `.dot` node is exactly the defect this type exists to prevent. + */ +export interface DotNsTld { + /** Suffix including the leading dot, as `protocolRegistry.tld()` returns it. */ + suffix: string; + /** Namehash of the TLD label, as `protocolRegistry.tldNode()` returns it. */ + node: `0x${string}`; +} + +/** The `.dot` TLD node. Mirrors the `DotnsConstants.DOT_NODE` the contracts removed in `b4096968`. */ +export const DOT_NODE = "0x3fce7d1364a893e213bc4212792b517ffc88f5b13b86c8ef9c8d390c3a1370ce"; + +const ZERO_NODE = `0x${"00".repeat(32)}`; + +const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s); + +/** `keccak256(parent ‖ labelhash(label))`, the one hash step every node is built from. */ +function nodeUnder(parent: Uint8Array, label: string): Uint8Array { + const combined = new Uint8Array(64); + combined.set(parent, 0); + combined.set(keccak256(utf8(label)), 32); + return keccak256(combined); +} + +/** + * Build a {@link DotNsTld} from a TLD suffix, deriving the node the same way the + * contract does. + * + * Mirrors `DotnsProtocolRegistry.initialize`, which sets + * `_tldNode = namehashUnder(bytes32(0), labelhash(tldLabel))`. Because the node + * is a pure function of the label, a client only ever needs the suffix — reading + * `tldNode()` is a cross-check, never the source. + * + * Accepts `".paseo"` or `"paseo"`: `tld()` returns the dotted form while + * `initialize` and the deploy pipeline speak the bare label. + */ +export function dotNsTld(suffix: string): DotNsTld { + const label = suffix.startsWith(".") ? suffix.slice(1) : suffix; + return { + suffix: `.${label}`, + node: `0x${bytesToHex(nodeUnder(hexToBytes(ZERO_NODE.slice(2)), label))}`, + }; +} + +/** + * Whether a {@link DotNsTld} could have come from a real deployment. + * + * Three ways a pair fails, all of which produce silently wrong nodes rather + * than errors if they reach {@link namehash}: + * + * - the node is not the derivation of the suffix (a caller mixing `.paseo` + * with `DOT_NODE`); + * - the suffix is empty, or the node is zero — what a pre-`b4096968` registry + * proxy reports after an upgrade with no reinitializer, since `_tld` and + * `_tldNode` are then still at their defaults and *both getters succeed*; + * - the suffix is not a single label, which `initialize` rejects outright. + * + * Returns a boolean rather than a `Result` to keep this module free of the + * error type; callers with an error channel (`resolveTld`) map `false` to an + * `err` carrying the offending value. + */ +export function isConsistentDotNsTld(tld: DotNsTld): boolean { + const label = tld.suffix.startsWith(".") ? tld.suffix.slice(1) : tld.suffix; + if (label.length === 0 || label.includes(".")) return false; + if (tld.node.toLowerCase() === ZERO_NODE) return false; + return dotNsTld(label).node.toLowerCase() === tld.node.toLowerCase(); +} + +/** The `.dot` TLD, for a deployment that uses it. Not a default: pass it explicitly. */ +export const DOT_TLD: DotNsTld = { suffix: ".dot", node: DOT_NODE }; + +/** + * Drop `suffix` from the end of `name`, leaving the labels beneath it. + * + * Strips by the suffix's own length, which is the whole point: the same logic + * written as `slice(0, -4)` turns `alice.paseo` into `alice.pa`, and every + * lookup for that name then asks about a subname nobody registered. A name that + * does not carry the suffix is returned unchanged, so a foreign suffix stays + * visible to whatever validates next. + * + * Lives here rather than in `./dotns.js` so the one copy is reachable from this + * module without pulling its heavier imports in. + */ +export function stripSuffix(name: string, suffix: string): string { + return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name; +} + +/** + * Compute the DotNS node hash for a name like `"alice.paseo"` or + * `"bob.alice.paseo"`, rooted at `tld`. + * + * The trailing suffix is the TLD (folded into `tld.node`); remaining labels are + * hashed most-significant-last, so `bob.alice.paseo` layers `bob` on top of + * `alice` on top of the TLD. Input is used as-is — normalize with + * `normalizeDotNsName` first if needed. + * + * `tld` is required rather than defaulted, because a default that is correct on + * one deployment is the defect this module was fixed for. It is also not + * validated here: a name whose suffix belongs to another deployment hashes to a + * node under `tld` instead of being refused, since this function returns a hash + * and has nowhere to report a complaint. The entry points in + * `./dotns-registry.js` do that check, where there is an error channel. + * + * @returns the 32-byte node as a `0x`-prefixed hex string. + */ +export function namehash(name: string, tld: DotNsTld): `0x${string}` { + const trimmed = stripSuffix(name, tld.suffix); + // "" → the bare TLD node; otherwise split into labels. + const labels = trimmed === "" ? [] : trimmed.split("."); + // Layer from the TLD outward: labels are given left-most-first + // (bob.alice), but must be applied right-to-left onto the parent. + // Annotated: hexToBytes yields ArrayBufferLike, keccak256 yields ArrayBuffer. + let node: Uint8Array = hexToBytes(tld.node.slice(2)); + for (let i = labels.length - 1; i >= 0; i--) { + node = nodeUnder(node, labels[i]); + } + return `0x${bytesToHex(node)}`; +} diff --git a/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts b/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts new file mode 100644 index 00000000..94b51cea --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-registry.test.ts @@ -0,0 +1,1154 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +// +// Contract paths run through `createFakeContractRuntime` from +// `@parity/product-sdk-contracts/testing`: the real codec, no chain. What these +// cannot cover is whether the deployed contracts behave as modelled here. +import { + type AbiEntry, + type ContractRuntime, + QUERY_FALLBACK_ORIGIN, +} from "@parity/product-sdk-contracts"; +import { createFakeContractRuntime, fakeDryRunResult } from "@parity/product-sdk-contracts/testing"; +import { ss58ToH160 } from "@parity/product-sdk-address"; +import type { SS58String } from "polkadot-api"; +import { describe, expect, test } from "vitest"; +import { + DOTNS_POP_RULES_ABI, + DOTNS_PROTOCOL_REGISTRY_ABI, + DOTNS_REGISTRAR_CONTROLLER_ABI, + DOTNS_REGISTRY_ABI, + DOTNS_RESOLVER_ABI, + DOTNS_RESOLVER_WRITE_ABI, + DOTNS_REVERSE_RESOLVER_ABI, + DOTNS_ADDRESSES, + POP_STATUS, +} from "./dotns-abis.js"; +import { DotNsError } from "./dotns-errors.js"; +import { + isDotNsAvailable, + prepareDotNsRegistration, + resolveDotNs, + resolveTld, + reverseDotNs, + setDotNsRecord, +} from "./dotns-registry.js"; +import { DOT_TLD, dotNsTld, namehash } from "./dotns-namehash.js"; + +/** A second protocol-registry address, to prove the TLD cache keys on it. */ +const OTHER_REGISTRY = "0x9999999999999999999999999999999999999999" as const; + +// These suites pass `tld: DOT_TLD` explicitly so they exercise resolution, +// writes and pricing rather than the TLD read — that path has its own suite in +// `describe("resolveTld")`, and the entry-point/`.paseo` wiring has +// `describe("the deployment's TLD reaches every entry point")`. Supplying the +// TLD also keeps validation free of IO, which is what lets the cases below +// assert an `InvalidName` against a runtime that would throw if touched. +// Never reached on the validation path (the calls reject before touching it). +const opts = { runtime: {} as ContractRuntime, tld: DOT_TLD }; + +/** Any SS58 account. The writes only need one to dry-run against. */ +const SIGNER = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" as const; + +const ALL_ABIS: AbiEntry[] = [ + ...DOTNS_REGISTRY_ABI, + ...DOTNS_RESOLVER_ABI, + ...DOTNS_REVERSE_RESOLVER_ABI, + ...DOTNS_RESOLVER_WRITE_ABI, + ...DOTNS_REGISTRAR_CONTROLLER_ABI, + ...DOTNS_POP_RULES_ABI, +]; + +/** Unset pointer / unregistered owner. */ +const ZERO = "0x0000000000000000000000000000000000000000"; +/** Stand-in accounts. Distinct on purpose: a test must not pass by confusing them. */ +const OWNER = "0x1111111111111111111111111111111111111111"; +const TARGET = "0x2222222222222222222222222222222222222222"; + +// The real defaults, because these tests leave the addresses out of `opts` and +// exercise the fallback path. Arbitrary values here would stop the +// reverse-resolver branch firing and the tests would pass for the wrong reason. +// See "honours the address overrides" for the opts path. +const FORWARD_RESOLVER = DOTNS_ADDRESSES.resolver; +const REVERSE_RESOLVER = DOTNS_ADDRESSES.reverseResolver; +/** classifyName returns two values, so the fake encodes it positionally. */ +const OPEN = [POP_STATUS.NoStatus, "Available to all"]; + +/** A runtime whose view calls answer from `answers`, keyed by function name. */ +function runtimeWith(answers: Record) { + return createFakeContractRuntime({ + abi: ALL_ABIS, + onQuery: ({ functionName }) => + functionName && functionName in answers ? answers[functionName] : undefined, + }); +} + +describe("dotns registry surface", () => { + test("resolveDotNs rejects an invalid name before any registry call", async () => { + const r = await resolveDotNs("no", opts); // too short, no .dot + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toBeInstanceOf(DotNsError); + expect(r.error.reason).toBe("InvalidName"); + } + }); + + test("setDotNsRecord rejects an invalid name before any contract call", async () => { + const r = await setDotNsRecord({ name: "no", address: "0x00" }, opts); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidName"); + }); + + test("prepareDotNsRegistration rejects an invalid name before any contract call", async () => { + const r = await prepareDotNsRegistration({ name: "no", owner: "0x00" }, opts); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidName"); + }); + + test("DotNsError implements the SdkError marker", () => { + const e = new DotNsError("RegistryCall", "boom"); + expect(e.isSdkError).toBe(true); + expect(e.source).toBe("dotns"); + expect(e.reason).toBe("RegistryCall"); + }); +}); + +describe("resolveDotNs", () => { + test("resolves a name that has a forward record", async () => { + const runtime = runtimeWith({ + owner: OWNER, + resolver: FORWARD_RESOLVER, + addressOf: TARGET, + }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ + ok: true, + value: { address: TARGET, name: "alice.dot", owner: OWNER }, + }); + }); + + test("a node pointed at the reverse resolver is registered with no forward record", async () => { + // The state of every name straight after registration. + const runtime = runtimeWith({ owner: OWNER, resolver: REVERSE_RESOLVER }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { name: "alice.dot", owner: OWNER } }); + // addressOf must not be attempted against the reverse resolver. + expect(runtime.calls.map((c) => c.functionName)).not.toContain("addressOf"); + }); + + test("the reverse-resolver comparison ignores address casing", async () => { + const runtime = runtimeWith({ + owner: OWNER, + resolver: REVERSE_RESOLVER.toLowerCase(), + }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r.ok && r.value?.address).toBeUndefined(); + }); + + test("an unset resolver pointer is also registered with no forward record", async () => { + const runtime = runtimeWith({ owner: OWNER, resolver: ZERO }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { name: "alice.dot", owner: OWNER } }); + }); + + test("a forward resolver holding an empty record has no address", async () => { + const runtime = runtimeWith({ + owner: OWNER, + resolver: FORWARD_RESOLVER, + addressOf: ZERO, + }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { name: "alice.dot", owner: OWNER } }); + }); + + test("a zero owner means unregistered, reported as null not as a record", async () => { + const runtime = runtimeWith({ owner: ZERO, resolver: ZERO }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: null }); + }); + + test("an unregistered name and a registered one are distinguishable", async () => { + const unregistered = await resolveDotNs("alice.dot", { + runtime: runtimeWith({ owner: ZERO, resolver: ZERO }), + tld: DOT_TLD, + }); + const registered = await resolveDotNs("alice.dot", { + runtime: runtimeWith({ owner: OWNER, resolver: REVERSE_RESOLVER }), + tld: DOT_TLD, + }); + expect(unregistered.ok && unregistered.value).toBeNull(); + expect(registered.ok && registered.value).not.toBeNull(); + }); + + test("honours the address overrides instead of the defaults", async () => { + // Proves the branch above keys off `opts`, not off the hardcoded table. + // Note the reverse-resolver override no longer decides anything here: any + // pointer that is not the forward resolver means "no forward record". The + // case is kept because it is the shape a registered name really has. + const customRegistry = "0x3333333333333333333333333333333333333333"; + const customReverse = "0x4444444444444444444444444444444444444444"; + const runtime = runtimeWith({ owner: OWNER, resolver: customReverse }); + const r = await resolveDotNs("alice.dot", { + runtime, + registryAddress: customRegistry, + reverseResolverAddress: customReverse, + tld: DOT_TLD, + }); + // Treated as "no forward record" because it matches the override. + expect(r).toEqual({ ok: true, value: { name: "alice.dot", owner: OWNER } }); + expect(runtime.calls.every((c) => c.dest === customRegistry)).toBe(true); + }); + + test("a failing owner read is an error, not a guessed owner", async () => { + const runtime = runtimeWith({ + owner: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + resolver: FORWARD_RESOLVER, + addressOf: TARGET, + }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); +}); + +describe("isDotNsAvailable", () => { + test("asks the registrar controller rather than inferring from resolution", async () => { + const runtime = runtimeWith({ available: true, classifyName: OPEN }); + const r = await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: true }); + expect(runtime.calls.map((c) => c.functionName).sort()).toEqual([ + "available", + "classifyName", + ]); + }); + + test("passes the bare label, without the .dot suffix", async () => { + const runtime = runtimeWith({ available: true, classifyName: OPEN }); + await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); + expect(runtime.calls[0]?.args).toEqual(["alice"]); + }); + + test("an owned name with no forward record is not available", async () => { + // This shape used to report `true`, costing the caller a commit fee. + const runtime = runtimeWith({ + available: false, + classifyName: OPEN, + owner: OWNER, + resolver: ZERO, + }); + const r = await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: false }); + }); + + test("an invalid name is rejected before the controller call", async () => { + const runtime = runtimeWith({ available: true, classifyName: OPEN }); + const r = await isDotNsAvailable("no", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidName"); + expect(runtime.calls).toHaveLength(0); + }); +}); + +describe("setDotNsRecord", () => { + test("moves the resolver pointer before writing the record", async () => { + const runtime = runtimeWith({ resolver: REVERSE_RESOLVER }); + const r = await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toHaveLength(2); + expect(runtime.calls.map((c) => c.functionName)).toEqual([ + "resolver", + "setResolver", + "setAddress", + ]); + }); + + test("skips the pointer move when the node already points at the resolver", async () => { + const runtime = runtimeWith({ resolver: FORWARD_RESOLVER }); + const r = await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toHaveLength(1); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("setResolver"); + }); + + test("the pointer move targets the forward resolver for the right node", async () => { + const runtime = runtimeWith({ resolver: ZERO }); + await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + const setResolver = runtime.calls.find((c) => c.functionName === "setResolver"); + expect(setResolver?.args?.[1]).toBe(FORWARD_RESOLVER); + const setAddress = runtime.calls.find((c) => c.functionName === "setAddress"); + expect(setAddress?.args?.[0]).toBe(setResolver?.args?.[0]); + }); + + test("a failing resolver read is an error", async () => { + const runtime = runtimeWith({ + resolver: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); +}); + +describe("origin", () => { + test("reads pass the pallet-revive fallback rather than leaving it unset", async () => { + const runtime = runtimeWith({ owner: OWNER, resolver: ZERO }); + await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + // Explicit, so the contracts layer does not warn once per query. + expect(runtime.calls.every((c) => c.origin === QUERY_FALLBACK_ORIGIN)).toBe(true); + }); + + test("reads use opts.origin when given", async () => { + const runtime = runtimeWith({ owner: OWNER, resolver: ZERO }); + await resolveDotNs("alice.dot", { runtime, origin: SIGNER, tld: DOT_TLD }); + expect(runtime.calls.every((c) => c.origin === SIGNER)).toBe(true); + }); + + test("setDotNsRecord dry-runs as the caller, not the fallback account", async () => { + const runtime = runtimeWith({ resolver: REVERSE_RESOLVER }); + await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + const writes = runtime.calls.filter((c) => + ["setResolver", "setAddress"].includes(c.functionName ?? ""), + ); + expect(writes).toHaveLength(2); + expect(writes.every((c) => c.origin === SIGNER)).toBe(true); + expect(writes.every((c) => c.origin !== QUERY_FALLBACK_ORIGIN)).toBe(true); + }); + + test("setDotNsRecord without an origin fails before any call", async () => { + const runtime = runtimeWith({ resolver: REVERSE_RESOLVER }); + const r = await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("MissingOrigin"); + expect(runtime.calls).toHaveLength(0); + }); + + test("prepareDotNsRegistration without an origin fails before any call", async () => { + const runtime = runtimeWith({}); + const r = await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("MissingOrigin"); + expect(runtime.calls).toHaveLength(0); + }); + + test("prepareDotNsRegistration with a malformed origin fails before any call", async () => { + // ss58ToH160 throws on an undecodable address. Every function here + // promises a Result, so the throw has to be converted, not escape. + const runtime = runtimeWith({}); + const r = await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, origin: "not-an-ss58-address" as SS58String, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidOrigin"); + expect(runtime.calls).toHaveLength(0); + }); + + test("prepareDotNsRegistration dry-runs every call as the caller", async () => { + const runtime = runtimeWith({ + makeCommitment: `0x${"ab".repeat(32)}`, + price: 1000n, + minCommitmentAge: 60n, + maxCommitmentAge: 86400n, + }); + await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(runtime.calls.length).toBeGreaterThan(0); + expect(runtime.calls.every((c) => c.origin === SIGNER)).toBe(true); + }); +}); + +describe("prepareDotNsRegistration", () => { + /** PopRules metadata: eligible NoStatus owner unless overridden. */ + const quote = (over: Partial> = {}) => ({ + price: 1000n, + status: POP_STATUS.NoStatus, + userStatus: POP_STATUS.NoStatus, + message: "Available to all", + ...over, + }); + + const happy = (over: Record = {}) => + runtimeWith({ + makeCommitment: `0x${"ab".repeat(32)}`, + priceWithoutCheck: quote(), + transferFloor: 0n, + minCommitmentAge: 60n, + maxCommitmentAge: 86400n, + available: true, + ...over, + }); + + const REG = { name: "alice.dot", owner: OWNER }; + const withSigner = (runtime: ReturnType) => ({ + runtime, + origin: SIGNER, + tld: DOT_TLD, + }); + + test("does not dry-run register up front", async () => { + // register consumes the commitment, so preparing it before commitCall + // lands reverts with CommitmentNotFound. + const runtime = happy(); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(true); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("register"); + expect(runtime.calls.map((c) => c.functionName)).toContain("commit"); + }); + + test("returns a thunk that builds the register call later", async () => { + const runtime = happy(); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(true); + if (!r.ok) return; + runtime.reset(); + const later = await r.value.prepareRegisterCall(); + expect(later.ok).toBe(true); + expect(runtime.calls.map((c) => c.functionName)).toContain("register"); + }); + + test("prices from priceWithoutCheck, not from price", async () => { + const runtime = happy(); + await prepareDotNsRegistration(REG, withSigner(runtime)); + const names = runtime.calls.map((c) => c.functionName); + expect(names).toContain("priceWithoutCheck"); + expect(names).not.toContain("price"); + }); + + test("a governance-reserved label fails before the commit is prepared", async () => { + const runtime = happy({ + priceWithoutCheck: quote({ + status: POP_STATUS.Reserved, + message: "Reserved for Governance", + }), + }); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("NameReserved"); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("commit"); + }); + + test("an owner below the required tier fails before the commit is prepared", async () => { + const runtime = happy({ + priceWithoutCheck: quote({ + status: POP_STATUS.PopFull, + userStatus: POP_STATUS.NoStatus, + message: "Requires Full personhood verification", + }), + }); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("OwnerStatusInsufficient"); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("commit"); + }); + + test("a verified owner meeting the tier is accepted", async () => { + const runtime = happy({ + priceWithoutCheck: quote({ + status: POP_STATUS.PopFull, + userStatus: POP_STATUS.PopFull, + price: 0n, + }), + }); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.price).toBe(0n); + }); + + test("the direct path skips the friction read", async () => { + // SIGNER derives to a different H160 than OWNER, so force the direct + // case by registering to the payer's own address. + const payer = ss58ToH160(SIGNER); + const runtime = happy(); + await prepareDotNsRegistration({ name: "alice.dot", owner: payer }, withSigner(runtime)); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("transferFloor"); + }); + + test("a cross-payer registration charges the friction floor when it is higher", async () => { + const runtime = happy({ priceWithoutCheck: quote({ price: 10n }), transferFloor: 5000n }); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value.price).toBe(5000n); + }); + + test("the register value is re-quoted, not the stale prepare-time price", async () => { + let current = 1000n; + const runtime = createFakeContractRuntime({ + abi: ALL_ABIS, + onQuery: ({ functionName }) => { + if (functionName === "makeCommitment") return `0x${"ab".repeat(32)}`; + if (functionName === "available") return true; + if (functionName === "priceWithoutCheck") return quote({ price: current }); + if (functionName === "transferFloor") return 0n; + if (functionName === "minCommitmentAge") return 60n; + if (functionName === "maxCommitmentAge") return 86400n; + return undefined; + }, + }); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok && r.value.price).toBe(1000n); + if (!r.ok) return; + + current = 7777n; // price moves during the mandatory wait + runtime.reset(); + await r.value.prepareRegisterCall(); + const register = runtime.calls.find((c) => c.functionName === "register"); + expect(register?.value).toBe(7777n); + }); + + test("a failing commitment-window read is an error, not a zero window", async () => { + const runtime = happy({ + minCommitmentAge: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await prepareDotNsRegistration(REG, withSigner(runtime)); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); +}); + +describe("error causes", () => { + // The contracts package surfaces a dispatch payload on a failed query and a + // typed ContractError from a failed prepare, so callers can tell "not the + // owner" from "node missing" from "RPC down". Collapsing both to a bare + // string loses that, which is what these pin. + const FAILURE = { type: "ContractTrapped" } as const; + + test("a failed read carries the dispatch payload", async () => { + const runtime = runtimeWith({ owner: fakeDryRunResult({ failure: FAILURE }) }); + const r = await resolveDotNs("alice.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.cause).toEqual(FAILURE); + }); + + test("a reverting write carries the ContractError, with its decoded reason", async () => { + const runtime = runtimeWith({ + resolver: FORWARD_RESOLVER, + setAddress: fakeDryRunResult({ revert: "Unauthorised" }), + }); + const r = await setDotNsRecord( + { name: "alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.cause).toBeDefined(); + expect(JSON.stringify(r.error.cause)).toContain("Unauthorised"); + } + }); + + test("a failed availability read carries its payload", async () => { + const runtime = runtimeWith({ + available: fakeDryRunResult({ failure: FAILURE }), + classifyName: OPEN, + }); + const r = await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.cause).toEqual(FAILURE); + }); + + test("a failed pricing read carries its payload", async () => { + const runtime = runtimeWith({ + makeCommitment: `0x${"ab".repeat(32)}`, + priceWithoutCheck: fakeDryRunResult({ failure: FAILURE }), + minCommitmentAge: 60n, + maxCommitmentAge: 86400n, + available: true, + }); + const r = await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.cause).toEqual(FAILURE); + }); +}); + +describe("subnames", () => { + test("a subname resolves", async () => { + const runtime = runtimeWith({ + owner: OWNER, + resolver: FORWARD_RESOLVER, + addressOf: TARGET, + }); + const r = await resolveDotNs("bob.alice.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ + ok: true, + value: { address: TARGET, name: "bob.alice.dot", owner: OWNER }, + }); + }); + + test("a subname hashes to a different node than its parent", async () => { + const sub = runtimeWith({ owner: OWNER, resolver: ZERO }); + await resolveDotNs("bob.alice.dot", { runtime: sub, tld: DOT_TLD }); + const parent = runtimeWith({ owner: OWNER, resolver: ZERO }); + await resolveDotNs("alice.dot", { runtime: parent, tld: DOT_TLD }); + expect(sub.calls[0]?.args?.[0]).not.toBe(parent.calls[0]?.args?.[0]); + }); + + test("a record can be set on a subname", async () => { + const runtime = runtimeWith({ resolver: FORWARD_RESOLVER }); + const r = await setDotNsRecord( + { name: "bob.alice.dot", address: TARGET }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(true); + }); + + test("registration still refuses a subname, since the registrar only mints single labels", async () => { + const runtime = runtimeWith({}); + const r = await prepareDotNsRegistration( + { name: "bob.alice.dot", owner: OWNER }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidName"); + }); + + test("availability still refuses a subname", async () => { + const runtime = runtimeWith({ available: true, classifyName: OPEN }); + const r = await isDotNsAvailable("bob.alice.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidName"); + }); + + test("a malformed label anywhere in the path is rejected as InvalidName", async () => { + // Asserting the reason, not just !ok: an empty fake runtime makes the + // call fail anyway, so `ok === false` alone would pass for any input. + const runtime = runtimeWith({}); + for (const bad of ["a.alice.dot", "bob..alice.dot", "-bob.alice.dot", "bob.al ice.dot"]) { + const r = await resolveDotNs(bad, { runtime, tld: DOT_TLD }); + expect(r.ok, bad).toBe(false); + if (!r.ok) expect(r.error.reason, bad).toBe("InvalidName"); + } + expect(runtime.calls, "rejected before any contract call").toHaveLength(0); + }); + + test("an uppercase subname is normalized rather than rejected", async () => { + const runtime = runtimeWith({ owner: OWNER, resolver: ZERO }); + const r = await resolveDotNs("BOB.Alice.DOT", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { name: "bob.alice.dot", owner: OWNER } }); + }); +}); + +describe("availability and registration agree", () => { + const OPEN_LABEL = [POP_STATUS.NoStatus, "Available to all"]; + const GOV = [POP_STATUS.Reserved, "Reserved for Governance"]; + + test("a governance-reserved label is not available, even though it is unminted", async () => { + // available() only asks the registrar whether the token is minted, and + // says yes for a 3 to 5 character label that can never be claimed. + const runtime = runtimeWith({ available: true, classifyName: GOV }); + const r = await isDotNsAvailable("bob.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: false }); + }); + + test("availability and registration now give the same verdict on a reserved label", async () => { + const avail = await isDotNsAvailable("bob.dot", { + runtime: runtimeWith({ available: true, classifyName: GOV }), + tld: DOT_TLD, + }); + const reg = await prepareDotNsRegistration( + { name: "bob.dot", owner: OWNER }, + { + runtime: runtimeWith({ + available: true, + makeCommitment: `0x${"ab".repeat(32)}`, + priceWithoutCheck: { + price: 0n, + status: POP_STATUS.Reserved, + userStatus: POP_STATUS.NoStatus, + message: "Reserved for Governance", + }, + minCommitmentAge: 60n, + maxCommitmentAge: 86400n, + }), + origin: SIGNER, + tld: DOT_TLD, + }, + ); + expect(avail.ok && avail.value).toBe(false); + expect(reg.ok).toBe(false); + if (!reg.ok) expect(reg.error.reason).toBe("NameReserved"); + }); + + test("a failing classifyName is an error, not a false", async () => { + const runtime = runtimeWith({ + available: true, + classifyName: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await isDotNsAvailable("alice.dot", { runtime, tld: DOT_TLD }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); + + test("an unminted, unrestricted label is available", async () => { + const runtime = runtimeWith({ available: true, classifyName: OPEN_LABEL }); + expect(await isDotNsAvailable("longenough.dot", { runtime, tld: DOT_TLD })).toEqual({ + ok: true, + value: true, + }); + }); +}); + +describe("registration refuses a taken name before the commit", () => { + const base = { + makeCommitment: `0x${"ab".repeat(32)}`, + priceWithoutCheck: { + price: 1000n, + status: POP_STATUS.NoStatus, + userStatus: POP_STATUS.NoStatus, + message: "Available to all", + }, + transferFloor: 0n, + minCommitmentAge: 60n, + maxCommitmentAge: 86400n, + }; + + test("a taken name fails as NameUnavailable with no commit prepared", async () => { + // register runs _requireAvailableLabel first, so without this the caller + // pays for the commit and only then reverts with NameNotAvailable. + const runtime = runtimeWith({ ...base, available: false }); + const r = await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("NameUnavailable"); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("commit"); + }); + + test("availability is actually consulted", async () => { + const runtime = runtimeWith({ ...base, available: true }); + await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(runtime.calls.map((c) => c.functionName)).toContain("available"); + }); + + test("a failing availability read is an error, not an assumed yes", async () => { + const runtime = runtimeWith({ + ...base, + available: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await prepareDotNsRegistration( + { name: "alice.dot", owner: OWNER }, + { runtime, origin: SIGNER, tld: DOT_TLD }, + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); +}); + +describe("resolveTld", () => { + /** A runtime whose protocol registry answers `tld()` / `tldNode()` as told. */ + function registryRuntime(answers: { tld?: unknown; tldNode?: unknown }) { + return createFakeContractRuntime({ + abi: [...ALL_ABIS, ...DOTNS_PROTOCOL_REGISTRY_ABI], + onQuery: ({ functionName }) => { + if (functionName === "tld") return answers.tld; + if (functionName === "tldNode") return answers.tldNode; + return undefined; + }, + }); + } + + const PASEO = dotNsTld(".paseo"); + /** Both getters absent, which is what a pre-b4096968 deployment looks like. */ + const NO_GETTER = fakeDryRunResult({ revert: true }); + + test("reads the suffix from the chain and derives the node", async () => { + const runtime = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + const r = await resolveTld({ runtime }); + expect(r).toEqual({ ok: true, value: PASEO }); + }); + + test("derives the node itself, so tldNode() is not required to answer", async () => { + const runtime = registryRuntime({ tld: ".paseo", tldNode: NO_GETTER }); + const r = await resolveTld({ runtime }); + expect(r).toEqual({ ok: true, value: PASEO }); + }); + + test("caches per runtime: a second call does not re-read", async () => { + const runtime = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + await resolveTld({ runtime }); + const afterFirst = runtime.calls.filter((c) => c.functionName === "tld").length; + await resolveTld({ runtime }); + expect(runtime.calls.filter((c) => c.functionName === "tld").length).toBe(afterFirst); + expect(afterFirst).toBe(1); + }); + + test("concurrent first calls share one read", async () => { + const runtime = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + const [a, b, c] = await Promise.all([ + resolveTld({ runtime }), + resolveTld({ runtime }), + resolveTld({ runtime }), + ]); + expect(runtime.calls.filter((x) => x.functionName === "tld").length).toBe(1); + expect([a, b, c]).toEqual([ + { ok: true, value: PASEO }, + { ok: true, value: PASEO }, + { ok: true, value: PASEO }, + ]); + }); + + test("the cache is per runtime, not global", async () => { + const one = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + const two = registryRuntime({ tld: ".dot", tldNode: DOT_TLD.node }); + expect(await resolveTld({ runtime: one })).toEqual({ ok: true, value: PASEO }); + expect(await resolveTld({ runtime: two })).toEqual({ ok: true, value: DOT_TLD }); + expect(one.calls.filter((c) => c.functionName === "tld").length).toBe(1); + expect(two.calls.filter((c) => c.functionName === "tld").length).toBe(1); + }); + + test("the cache key includes the protocol registry address", async () => { + // One runtime pointed at two registries has two TLDs; keying on the + // runtime alone would serve the first answer for both. + const runtime = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + await resolveTld({ runtime }); + await resolveTld({ runtime, protocolRegistryAddress: OTHER_REGISTRY }); + expect(runtime.calls.filter((c) => c.functionName === "tld").length).toBe(2); + }); + + test("a supplied tld skips the chain entirely", async () => { + const runtime = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + const r = await resolveTld({ runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: DOT_TLD }); + expect(runtime.calls).toHaveLength(0); + }); + + test("a supplied tld whose node does not match its suffix is refused", async () => { + // The override path re-creating the original bug: `.paseo` names rooted + // at the `.dot` node. + const runtime = registryRuntime({ tld: ".paseo", tldNode: PASEO.node }); + const r = await resolveTld({ + runtime, + tld: { suffix: ".paseo", node: DOT_TLD.node }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidTld"); + }); + + test("an absent getter falls back to .dot, because that TLD was compiled in", async () => { + // Pre-b4096968 deployments had no tld() and no way to be anything but + // `.dot`. Verified against Paseo Asset Hub Previewnet. + const runtime = registryRuntime({ tld: NO_GETTER, tldNode: NO_GETTER }); + expect(await resolveTld({ runtime })).toEqual({ ok: true, value: DOT_TLD }); + }); + + test("a dispatch failure is an error, not a fallback", async () => { + const runtime = registryRuntime({ + tld: fakeDryRunResult({ failure: { type: "ContractTrapped" } }), + }); + const r = await resolveTld({ runtime }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); + + test("a revert carrying a reason is an error, not a fallback", async () => { + // Only an *empty* revert means "no such function". A reverting getter + // that has something to say is a real failure. + const runtime = registryRuntime({ tld: fakeDryRunResult({ revert: "nope" }) }); + const r = await resolveTld({ runtime }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("RegistryCall"); + }); + + test("a failed read is not cached, so the next call retries", async () => { + let firstCall = true; + const runtime = createFakeContractRuntime({ + abi: [...ALL_ABIS, ...DOTNS_PROTOCOL_REGISTRY_ABI], + onQuery: ({ functionName }) => { + if (functionName === "tldNode") return PASEO.node; + if (functionName !== "tld") return undefined; + if (firstCall) { + firstCall = false; + return fakeDryRunResult({ failure: { type: "ContractTrapped" } }); + } + return ".paseo"; + }, + }); + expect((await resolveTld({ runtime })).ok).toBe(false); + expect(await resolveTld({ runtime })).toEqual({ ok: true, value: PASEO }); + }); + + test("an empty suffix is refused, not treated as the ENS root", async () => { + // What an upgraded-but-unmigrated proxy reports: the getters succeed and + // return `_tld` = "" with `_tldNode` = 0, which would reroot every name. + const runtime = registryRuntime({ tld: "", tldNode: `0x${"00".repeat(32)}` }); + const r = await resolveTld({ runtime }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidTld"); + }); + + test("a multi-label suffix is refused, since initialize cannot produce one", async () => { + const runtime = registryRuntime({ tld: ".a.b" }); + const r = await resolveTld({ runtime }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidTld"); + }); + + test("a tldNode() that contradicts tld() is refused, trusting neither", async () => { + const runtime = registryRuntime({ tld: ".paseo", tldNode: DOT_TLD.node }); + const r = await resolveTld({ runtime }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.reason).toBe("InvalidTld"); + }); +}); + +describe("the deployment's TLD reaches every entry point", () => { + const PASEO = dotNsTld(".paseo"); + + /** + * `dim2` under the `.paseo` root, as read from `DotnsRegistry` on + * paseo-asset-hub-next — the node the chain actually keys that name by. + * + * Pinned from the chain rather than computed here: it is what makes these + * assertions evidence about the deployment instead of a restatement of + * `namehash`. Under the old hardcoded `.dot` root the same name hashed to + * 0xec7bd203… , which is owned by nobody, which is why every lookup + * reported "unregistered". + */ + const DIM2_UNDER_PASEO = "0x4eeda1749326395729498c3df6e0cf87fe912297bbf35c4f2549c19d77f56dad"; + + /** A deployment whose protocol registry reports `.paseo`. */ + function paseoRuntime(answers: Record = {}) { + return createFakeContractRuntime({ + abi: [...ALL_ABIS, ...DOTNS_PROTOCOL_REGISTRY_ABI], + onQuery: ({ functionName }) => { + if (functionName === "tld") return ".paseo"; + if (functionName === "tldNode") return PASEO.node; + return answers[functionName ?? ""]; + }, + }); + } + + const nodesFor = (runtime: ReturnType, fn: string) => + runtime.calls.filter((c) => c.functionName === fn).map((c) => c.args?.[0]); + + test("resolveDotNs hashes under the chain's TLD, not a compiled-in one", async () => { + const runtime = paseoRuntime({ owner: OWNER, resolver: ZERO }); + const r = await resolveDotNs("dim2.paseo", { runtime }); + expect(r).toEqual({ ok: true, value: { name: "dim2.paseo", owner: OWNER } }); + // The assertion that would have caught the original bug. + expect(nodesFor(runtime, "owner")).toEqual([DIM2_UNDER_PASEO]); + expect(namehash("dim2.paseo", PASEO)).toBe(DIM2_UNDER_PASEO); + }); + + test("a bare label picks up the deployment's suffix", async () => { + const runtime = paseoRuntime({ owner: OWNER, resolver: ZERO }); + const r = await resolveDotNs("dim2", { runtime }); + expect(r).toEqual({ ok: true, value: { name: "dim2.paseo", owner: OWNER } }); + expect(nodesFor(runtime, "owner")).toEqual([DIM2_UNDER_PASEO]); + }); + + test("a name from another deployment is refused, not resolved under ours", async () => { + const runtime = paseoRuntime({ owner: OWNER, resolver: ZERO }); + const r = await resolveDotNs("dim2.dot", { runtime }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.reason).toBe("TldMismatch"); + // The message has to name the deployment's TLD, or the caller cannot + // tell this apart from a typo. + expect(r.error.message).toContain(".paseo"); + } + // Nothing beyond the TLD read: no registry lookup for a foreign name. + expect(runtime.calls.map((c) => c.functionName)).toEqual(["tld", "tldNode"]); + }); + + test("setDotNsRecord targets the same node resolveDotNs reads", async () => { + const runtime = paseoRuntime({ resolver: DOTNS_ADDRESSES.resolver }); + const r = await setDotNsRecord( + { name: "dim2.paseo", address: TARGET }, + { runtime, origin: SIGNER }, + ); + expect(r.ok).toBe(true); + expect(nodesFor(runtime, "resolver")).toEqual([DIM2_UNDER_PASEO]); + expect(nodesFor(runtime, "setAddress")).toEqual([DIM2_UNDER_PASEO]); + }); + + test("isDotNsAvailable sends the whole label, not one truncated by four", async () => { + const runtime = paseoRuntime({ + available: true, + // Multi-output returns are encoded positionally, as the suites above do. + classifyName: [POP_STATUS.PopLite, ""], + }); + const r = await isDotNsAvailable("dim2.paseo", { runtime }); + expect(r).toEqual({ ok: true, value: true }); + // `slice(0, -4)` would send "dim2.pa" here. + expect(nodesFor(runtime, "available")).toEqual(["dim2"]); + expect(nodesFor(runtime, "classifyName")).toEqual(["dim2"]); + }); + + test("prepareDotNsRegistration sends the whole label too", async () => { + const runtime = paseoRuntime({ + available: true, + makeCommitment: `0x${"ab".repeat(32)}`, + priceWithoutCheck: [1n, POP_STATUS.NoStatus, POP_STATUS.PopFull, ""], + transferFloor: 0n, + minCommitmentAge: 60n, + maxCommitmentAge: 86400n, + }); + const r = await prepareDotNsRegistration( + { name: "dim2.paseo", owner: OWNER }, + { runtime, origin: SIGNER }, + ); + expect(r.ok).toBe(true); + expect(nodesFor(runtime, "available")).toEqual(["dim2"]); + }); + + test("reverseDotNs needs no TLD, and reads none", async () => { + const runtime = paseoRuntime({ nameOf: "dim2.paseo" }); + const r = await reverseDotNs(OWNER, { runtime }); + expect(r).toEqual({ ok: true, value: "dim2.paseo" }); + expect(runtime.calls.map((c) => c.functionName)).toEqual(["nameOf"]); + }); + + test("every entry point consults the chain when no tld is supplied", async () => { + // The regression guard for the whole change: if any entry point stops + // asking, it is back to assuming a root. + for (const call of [ + (runtime: ReturnType) => resolveDotNs("dim2.paseo", { runtime }), + (runtime: ReturnType) => + isDotNsAvailable("dim2.paseo", { runtime }), + (runtime: ReturnType) => + setDotNsRecord( + { name: "dim2.paseo", address: TARGET }, + { runtime, origin: SIGNER }, + ), + (runtime: ReturnType) => + prepareDotNsRegistration( + { name: "dim2.paseo", owner: OWNER }, + { runtime, origin: SIGNER }, + ), + ]) { + const runtime = paseoRuntime({ owner: OWNER, resolver: ZERO, available: true }); + await call(runtime); + expect(runtime.calls.map((c) => c.functionName)).toContain("tld"); + } + }); + + test("a legacy deployment with no tld() getter still resolves .dot names", async () => { + // Paseo Asset Hub Previewnet: both getters revert empty, and `dim2` is + // owned there under the `.dot` root. The fix must not break it. + const runtime = createFakeContractRuntime({ + abi: [...ALL_ABIS, ...DOTNS_PROTOCOL_REGISTRY_ABI], + onQuery: ({ functionName }) => { + if (functionName === "tld" || functionName === "tldNode") { + return fakeDryRunResult({ revert: true }); + } + if (functionName === "owner") return OWNER; + if (functionName === "resolver") return ZERO; + return undefined; + }, + }); + const r = await resolveDotNs("dim2.dot", { runtime }); + expect(r).toEqual({ ok: true, value: { name: "dim2.dot", owner: OWNER } }); + expect(nodesFor(runtime, "owner")).toEqual([namehash("dim2.dot", DOT_TLD)]); + }); +}); + +describe("the resolver pointer is an allowlist, not a denylist", () => { + /** + * `DotnsContentResolver` on Paseo Asset Hub Next V2, from walking + * `protocolRegistry.get(bytes32("contentResolver"))`. + * + * The live pointer for `dim2` on both networks. It is a real contract with no + * `addressOf`, so asking it for an address reverts — which is why a + * registered, owned name used to come back as `err RegistryCall`. + */ + const CONTENT_RESOLVER = "0x7F74D7CD50f5a834270E2ad395a01b01891AB37d"; + /** `DotnsPopResolver` from the same walk: a fourth resolver, equally unknown here. */ + const POP_RESOLVER = "0xDaC984884EcA8Fc44011f1D6C49B27828390A72B"; + + /** A runtime where the node is owned and points at `resolverAddr`. */ + const ownedPointingAt = (resolverAddr: string) => + createFakeContractRuntime({ + abi: ALL_ABIS, + onQuery: ({ functionName }) => { + if (functionName === "owner") return OWNER; + if (functionName === "resolver") return resolverAddr; + // Asking any of these for an address reverts, as the real ones do. + if (functionName === "addressOf") return fakeDryRunResult({ revert: true }); + return undefined; + }, + }); + + test("a resolver we do not know means no forward record, not an error", async () => { + // The assertion no earlier test could make: every fake until now returned + // one of the three pointers the code contemplated, so a fourth resolver + // was unreachable by construction. + const runtime = ownedPointingAt(CONTENT_RESOLVER); + const r = await resolveDotNs("dim2.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { name: "dim2.dot", owner: OWNER } }); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("addressOf"); + }); + + test("the same holds for the PoP resolver, and for one nobody has deployed yet", async () => { + for (const pointer of [POP_RESOLVER, "0x1234512345123451234512345123451234512345"]) { + const runtime = ownedPointingAt(pointer); + const r = await resolveDotNs("dim2.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { name: "dim2.dot", owner: OWNER } }); + expect(runtime.calls.map((c) => c.functionName)).not.toContain("addressOf"); + } + }); + + test("the forward resolver is still asked, and still answers", async () => { + const runtime = createFakeContractRuntime({ + abi: ALL_ABIS, + onQuery: ({ functionName }) => { + if (functionName === "owner") return OWNER; + if (functionName === "resolver") return DOTNS_ADDRESSES.resolver; + if (functionName === "addressOf") return TARGET; + return undefined; + }, + }); + const r = await resolveDotNs("dim2.dot", { runtime, tld: DOT_TLD }); + expect(r).toEqual({ ok: true, value: { address: TARGET, name: "dim2.dot", owner: OWNER } }); + expect(runtime.calls.map((c) => c.functionName)).toContain("addressOf"); + }); + + test("an overridden forward resolver is the one that gets asked", async () => { + // The allowlist has to key off `opts`, not the hardcoded table, or an + // override silently turns every name into "no forward record". + const custom = "0x5555555555555555555555555555555555555555"; + const runtime = createFakeContractRuntime({ + abi: ALL_ABIS, + onQuery: ({ functionName }) => { + if (functionName === "owner") return OWNER; + if (functionName === "resolver") return custom; + if (functionName === "addressOf") return TARGET; + return undefined; + }, + }); + const r = await resolveDotNs("dim2.dot", { + runtime, + tld: DOT_TLD, + resolverAddress: custom, + }); + expect(r).toEqual({ ok: true, value: { address: TARGET, name: "dim2.dot", owner: OWNER } }); + }); +}); diff --git a/product-sdk/packages/sdk/src/identity/dotns-registry.ts b/product-sdk/packages/sdk/src/identity/dotns-registry.ts new file mode 100644 index 00000000..5468b922 --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns-registry.ts @@ -0,0 +1,870 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +/** + * DotNS registry reads and writes. + * + * DotNS is an ENS-style system on Asset Hub: a `DotnsRegistry` maps a node + * (namehash) to a resolver + owner, a `DotnsResolver` maps a node to an + * address, and a `DotnsReverseResolver` maps an account back to its name. All + * are Revive contracts, reached via `@parity/product-sdk-contracts`' + * `createContract(...)..query(...)`. Contract source: + * https://github.com/paritytech/dotns + * + * **Names are rooted at the network's TLD, which is per deployment.** It is + * fixed when `DotnsProtocolRegistry.initialize` runs, has no setter, and is + * published as `tld()`. `.paseo` on Paseo Asset Hub Next V2, `.dot` on + * Previewnet, and operators can choose others. Every entry point that hashes a + * name therefore asks the chain first, through {@link resolveTld}, and callers + * can supply `opts.tld` to skip the read. Hashing under the wrong root returns a + * node the chain never wrote to, so the failure looks exactly like an + * unregistered name — which is why nothing here assumes a root. + * + * **A node's resolver pointer is not necessarily a forward resolver.** The + * deployment has several — forward, reverse, content, PoP — and only the forward + * one answers `addressOf`. `resolveDotNs` therefore calls it only when the + * pointer *is* the forward resolver, and reports every other pointer as + * "registered, no forward record". The content resolver is the common case for a + * product name. + * + * A rule this module holds to throughout: **a failed read must never become a + * plausible value.** The one deliberate exception is documented on + * {@link resolveTld}, where a missing getter identifies a deployment whose TLD + * could only have been `.dot`. + * + * Reads (`resolveDotNs` / `reverseDotNs` / `isDotNsAvailable`) query chain. + * Writes return prepared calls the caller submits with their own signer: + * `setDotNsRecord` (the resolver pointer, when it needs moving, plus the + * record) and `prepareDotNsRegistration` (the commit call plus a thunk for the + * register call, which cannot be built until the commitment is on chain). + * + * Note: this deployment has no name-expiry concept (the registrar exposes no + * expiry getter), so `DotNsRecord.expiresAt` is always omitted. + */ +import { err, ok, type Result } from "@parity/result"; +import { + type AbiEntry, + type BatchableCall, + createContract, + QUERY_FALLBACK_ORIGIN, +} from "@parity/product-sdk-contracts"; +import type { ContractRuntime } from "@parity/product-sdk-contracts"; +import { bytesToHex, randomBytes } from "@parity/product-sdk-crypto"; +import { ss58ToH160 } from "@parity/product-sdk-address"; +import { createLogger } from "@parity/product-sdk-logger"; +import type { SS58String } from "polkadot-api"; +import { + DOTNS_POP_RULES_ABI, + DOTNS_PROTOCOL_REGISTRY_ABI, + DOTNS_REGISTRAR_CONTROLLER_ABI, + DOTNS_REGISTRY_ABI, + DOTNS_RESOLVER_ABI, + DOTNS_RESOLVER_WRITE_ABI, + DOTNS_REVERSE_RESOLVER_ABI, + DOTNS_ADDRESSES, + POP_STATUS, +} from "./dotns-abis.js"; +import { DotNsError } from "./dotns-errors.js"; +import { isResolvableDotNsName, isValidDotNsName, normalizeDotNsName } from "./dotns.js"; +import { + DOT_TLD, + dotNsTld, + type DotNsTld, + isConsistentDotNsTld, + namehash, + stripSuffix, +} from "./dotns-namehash.js"; +import type { DotNsRecord } from "./types.js"; + +const log = createLogger("identity:dotns"); + +type HexString = `0x${string}`; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +/** Shared inputs for a DotNS registry call. */ +export interface DotNsClientOptions { + /** A contract runtime for the chain hosting DotNS (Asset Hub). */ + runtime: ContractRuntime; + /** + * SS58 account that will submit the prepared calls. + * + * Required by the write helpers, which dry-run against it: `setAddress` and + * `setResolver` are owner-gated, so a dry-run from anyone else reverts. + * Optional for reads, which fall back to the pallet-revive query account. + * + * Must be SS58, not the account's H160. pallet-revive derives `msg.sender` + * from it; convert with `h160ToSs58` from `@parity/product-sdk-address`. + */ + origin?: SS58String; + /** `DotnsRegistry` address. Defaults to the deployed set, which is the same on every network. */ + registryAddress?: HexString; + /** `DotnsReverseResolver` address. Defaults to the deployed set. */ + reverseResolverAddress?: HexString; + /** `DotnsResolver` address (writes: setDotNsRecord). Defaults to Paseo AH. */ + resolverAddress?: HexString; + /** `DotnsRegistrarController` address (registration). Defaults to Paseo AH. */ + registrarControllerAddress?: HexString; + /** `PopRules` address (registration price). Defaults to Paseo AH. */ + popRulesAddress?: HexString; + /** + * `DotnsProtocolRegistry` address, the contract holding this network's TLD. + * Defaults to Paseo AH — and the address is the same on every network, since + * the whole set is CREATE3-deterministic. + */ + protocolRegistryAddress?: HexString; + /** + * The deployment's TLD, when you already know it. + * + * Omit it and the client reads `protocolRegistry.tld()` once per runtime and + * caches it, which is the correct default: the TLD is per network and only + * the chain knows which one is in play. Supply it to skip the read for + * offline or test use, or to pin a deployment deliberately. Build it with + * `dotNsTld(".paseo")` rather than by hand — a suffix paired with the wrong + * node is rejected, because that pairing is the defect this option could + * otherwise reintroduce. + */ + tld?: DotNsTld; +} + +/** Arguments for {@link prepareDotNsRegistration}. */ +export interface RegisterDotNsArgs { + /** The name to register, e.g. `"alice.paseo"` — or the bare label `"alice"`. */ + name: string; + /** + * The account that will own the registered name: an H160, `0x` and 20 + * bytes. Not SS58 — DotNS is a set of Revive contracts, and the registrar + * takes the owner as an ABI `address`. Convert with `ss58ToH160` from + * `@parity/product-sdk-address` if you hold a Substrate-shaped address. + * + * Typed `string` rather than `` `0x${string}` `` so callers need no cast, + * which means the compiler will not catch an SS58 address here: it reaches + * the contract and reverts on chain. + */ + owner: string; + /** Reserved-name registration (default `false`). */ + reserved?: boolean; +} + +/** Arguments for {@link setDotNsRecord}. */ +export interface SetRecordArgs { + /** The name whose resolver record is being set. */ + name: string; + /** + * The address the name should resolve to: an H160, `0x` and 20 bytes. It is + * written straight into `resolver.setAddress` as an ABI `address`, so SS58 + * is not accepted — convert with `ss58ToH160` from + * `@parity/product-sdk-address` first. Unenforced by the type, as with + * {@link RegisterDotNsArgs.owner}. + */ + address: string; +} + +function contractOf(runtime: ContractRuntime, address: HexString, abi: AbiEntry[]) { + // createContract is generic over a typed ABI def; these minimal literal ABIs + // are called by name via .query(), so the handle is untyped by construction. + return createContract(runtime, address, abi as any) as any; +} + +function isZero(addr: unknown): boolean { + return typeof addr === "string" && addr.toLowerCase() === ZERO_ADDRESS; +} + +/** + * Origin for a read. Passing the fallback explicitly rather than letting the + * contracts layer substitute it keeps each query from logging a warning. + */ +function readOrigin(opts: DotNsClientOptions): SS58String { + return opts.origin ?? QUERY_FALLBACK_ORIGIN; +} + +/** Case-insensitive H160 compare: chain reads are lowercase, our table is EIP-55. */ +function sameAddress(a: unknown, b: unknown): boolean { + return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase(); +} + +// ── The deployment's TLD ───────────────────────────────────────────── +// +// Every node hash is rooted at the network's TLD, which is fixed when +// `DotnsProtocolRegistry.initialize` runs and has no setter. Read it once per +// (runtime, protocol registry) and cache it: it cannot change under us. + +/** + * Cached TLD per runtime, then per protocol-registry address. + * + * Keyed on the address as well as the runtime because the address is + * overridable, and one runtime pointed at two registries has two TLDs. The + * value is the in-flight promise rather than the resolved TLD, so concurrent + * first calls share one read instead of racing. + */ +const tldCache = new WeakMap>>>(); + +/** An empty-payload revert: the contract ran and refused, telling us nothing. */ +function isEmptyRevert(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "ContractRevertedWithPayload" && + (value as { data?: unknown }).data === "0x" + ); +} + +/** + * The TLD every name on this deployment is rooted at. + * + * Asks `protocolRegistry.tld()` and derives the node from the suffix, which is + * exactly what `initialize` did — so `tldNode()` is only ever a cross-check, and + * one read answers the question. + * + * Three outcomes, and the middle one is a deliberate exception to this module's + * rule that a failed read must never become a plausible value: + * + * - the getter answers with a usable suffix → that TLD; + * - the getter **reverts with an empty payload** → `DOT_TLD`, with a warning. + * That signature means the function does not exist, which dates the + * deployment before `dotns` `b4096968` ("make the TLD network-configurable + * via the protocol registry"). Before that commit the TLD was a + * compile-time constant and `.dot` was the only value it could hold, so + * this is the one case where the fallback is provably right rather than + * merely plausible. Verified against Paseo Asset Hub Previewnet, where both + * getters revert empty and `dim2` is owned under the `.dot` root; + * - anything else → `err`. A dispatch failure, a revert carrying a reason, an + * undecodable answer, or a TLD we cannot use. + * + * That last case includes a getter that *succeeds* with an unusable value. The + * registry is `UUPSUpgradeable` with no reinitializer, so a pre-`b4096968` proxy + * upgraded to current contracts without a migration reports `_tld` as `""` and + * `_tldNode` as zero — both getters succeed and hand back the plain ENS root, + * which would silently reroot every name. {@link isConsistentDotNsTld} is what + * catches it. + * + * @internal Exposed for unit testing; consumers reach it through the entry points. + */ +export async function resolveTld(opts: DotNsClientOptions): Promise> { + if (opts.tld) { + // A caller-supplied pair gets the same consistency check as a chain read: + // `.paseo` carrying `DOT_NODE` is the original bug via the override path. + return isConsistentDotNsTld(opts.tld) + ? ok(opts.tld) + : err( + new DotNsError( + "InvalidTld", + `opts.tld is inconsistent: "${opts.tld.suffix}" does not hash to ${opts.tld.node}`, + ), + ); + } + + const address = (opts.protocolRegistryAddress ?? DOTNS_ADDRESSES.protocolRegistry) as HexString; + let perAddress = tldCache.get(opts.runtime); + if (!perAddress) { + perAddress = new Map(); + tldCache.set(opts.runtime, perAddress); + } + const cached = perAddress.get(address); + if (cached) return cached; + + const pending = readTld(opts, address); + perAddress.set(address, pending); + // Only a successful read is worth keeping: a failure is usually transient + // (RPC, not deployment), and caching it would strand the client for the + // life of the runtime. + pending.then((result) => { + if (!result.ok) perAddress?.delete(address); + }); + return pending; +} + +async function readTld( + opts: DotNsClientOptions, + address: HexString, +): Promise> { + try { + const registry = contractOf(opts.runtime, address, DOTNS_PROTOCOL_REGISTRY_ABI); + const origin = readOrigin(opts); + const suffixRes = await registry.tld.query({ origin }); + + if (!suffixRes.success) { + if (isEmptyRevert(suffixRes.value)) { + log.warn( + "DotNS protocol registry has no tld() getter; treating this as a " + + "pre-b4096968 deployment, whose TLD was the compile-time .dot", + { protocolRegistry: address }, + ); + return ok(DOT_TLD); + } + return err( + new DotNsError("RegistryCall", "protocolRegistry.tld call failed", { + cause: suffixRes.value, + }), + ); + } + + const suffix = suffixRes.value as string; + const tld = dotNsTld(suffix); + if (!isConsistentDotNsTld(tld)) { + return err( + new DotNsError( + "InvalidTld", + `protocolRegistry.tld returned an unusable TLD: ${JSON.stringify(suffix)}`, + ), + ); + } + + // Cross-check, not the source. A disagreement means the deployment's + // stored node was not derived from its stored label, which no + // `initialize` can produce — so trust neither value. + // + // Only a well-formed node counts as a disagreement. A revert (the getter + // is absent) or an unreadable answer means the cross-check did not run, + // which is not evidence against a suffix that already derived cleanly. + const nodeRes = await registry.tldNode.query({ origin }); + if (nodeRes.success && isNode(nodeRes.value) && !sameNode(nodeRes.value, tld.node)) { + return err( + new DotNsError( + "InvalidTld", + `protocolRegistry disagrees with itself: tld() ${suffix} derives ` + + `${tld.node} but tldNode() reports ${String(nodeRes.value)}`, + ), + ); + } + + log.debug("resolveTld", { protocolRegistry: address, suffix: tld.suffix, node: tld.node }); + return ok(tld); + } catch (cause) { + return err(new DotNsError("RegistryCall", "DotNS TLD read failed", { cause })); + } +} + +/** + * Why a normalized name was refused. + * + * Two distinct answers, because they call for different things from the caller. + * `TldMismatch` means the name is well formed but belongs to another + * deployment — `alice.dot` handed to a `.paseo` network — which a product may + * want to offer to correct, and which the SDK must never correct silently: + * the two are separate registrations that may have different owners. + * `InvalidName` means the labels themselves are malformed, and no rewrite helps. + */ +function nameError(input: string, normalized: string, tld: DotNsTld): DotNsError { + if (!normalized.endsWith(tld.suffix)) { + return new DotNsError( + "TldMismatch", + `DotNS name "${input}" is not on this deployment, which uses "${tld.suffix}"`, + ); + } + return new DotNsError("InvalidName", `Invalid DotNS name: "${input}"`); +} + +/** Whether a value is a readable 32-byte node, and so usable as a cross-check. */ +function isNode(value: unknown): value is HexString { + return typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value); +} + +/** Case-insensitive 32-byte node compare, for the same reason as {@link sameAddress}. */ +function sameNode(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * Resolve a DotNS name. `expiresAt` is omitted (no on-chain expiry here). + * + * Three outcomes, because the registry's resolver pointer is configuration, not + * proof of existence: + * + * - `ok(null)` — unregistered (`registry.owner` is zero). + * - `ok({ name, owner })` — registered, no forward record yet. + * - `ok({ name, owner, address })` — resolves. + */ +export async function resolveDotNs( + name: string, + opts: DotNsClientOptions, +): Promise> { + // The TLD comes first because normalization and validation both need the + // suffix. One read per runtime, cached; an invalid name therefore costs a + // chain read on the first call and none after. Do not move validation back + // in front of this — it cannot know what a valid name looks like yet. + const tldRes = await resolveTld(opts); + if (!tldRes.ok) return tldRes; + const tld = tldRes.value; + const normalized = normalizeDotNsName(name, tld.suffix); + if (!isResolvableDotNsName(normalized, tld.suffix)) { + return err(nameError(name, normalized, tld)); + } + const node = namehash(normalized, tld); + const registryAddr = opts.registryAddress ?? DOTNS_ADDRESSES.registry; + const origin = readOrigin(opts); + log.debug("resolveDotNs", { name: normalized, node, registry: registryAddr }); + + try { + const registry = contractOf(opts.runtime, registryAddr as HexString, DOTNS_REGISTRY_ABI); + + const [ownerRes, resolverRes] = await Promise.all([ + registry.owner.query(node, { origin }), + registry.resolver.query(node, { origin }), + ]); + if (!ownerRes.success) { + return err( + new DotNsError("RegistryCall", "registry.owner call failed", { + cause: ownerRes.value, + }), + ); + } + if (!resolverRes.success) { + return err( + new DotNsError("RegistryCall", "registry.resolver call failed", { + cause: resolverRes.value, + }), + ); + } + + // Existence check: the getter falls back to the registrar's ERC-721 + // holder, and returns zero for a node with no record. + const owner = ownerRes.value as HexString; + if (isZero(owner)) return ok(null); + + // Only the forward resolver answers `addressOf`, so that is the one + // pointer worth calling — an allowlist, not a denylist of the resolvers + // we happen to have met. A node can legitimately point at the reverse + // resolver (where registration parks it), the content resolver (a + // product name serving content, and the ordinary case in practice), the + // PoP resolver, or one deployed after this was written. None of them has + // an address record to give, and all of them revert if asked — which + // would surface as an infrastructure failure for a name that is simply + // registered without a forward record. + // + // `setDotNsRecord` already compares against the forward resolver this + // way; before this, the read and write paths disagreed about what a + // pointer meant. + const resolverAddr = resolverRes.value as HexString; + const forwardAddr = opts.resolverAddress ?? DOTNS_ADDRESSES.resolver; + if (!sameAddress(resolverAddr, forwardAddr)) { + return ok({ name: normalized, owner }); + } + + const resolver = contractOf(opts.runtime, resolverAddr as HexString, DOTNS_RESOLVER_ABI); + const addrRes = await resolver.addressOf.query(node, { origin }); + if (!addrRes.success) { + return err( + new DotNsError("RegistryCall", "resolver.addressOf call failed", { + cause: addrRes.value, + }), + ); + } + const address = addrRes.value as HexString; + // A forward resolver holding an empty record is still no forward record. + if (isZero(address)) return ok({ name: normalized, owner }); + + return ok({ address, name: normalized, owner }); + } catch (cause) { + return err( + new DotNsError("RegistryCall", `DotNS resolve failed for "${normalized}"`, { cause }), + ); + } +} + +/** + * Reverse-resolve an account to its primary DotNS name. + * + * Single call: `reverseResolver.nameOf(account)`. `ok(null)` when no primary + * name is set (empty string on-chain). + */ +export async function reverseDotNs( + address: string, + opts: DotNsClientOptions, +): Promise> { + const reverseAddr = opts.reverseResolverAddress ?? DOTNS_ADDRESSES.reverseResolver; + log.debug("reverseDotNs", { address, reverseResolver: reverseAddr }); + try { + const reverse = contractOf( + opts.runtime, + reverseAddr as HexString, + DOTNS_REVERSE_RESOLVER_ABI, + ); + const res = await reverse.nameOf.query(address, { origin: readOrigin(opts) }); + if (!res.success) { + return err( + new DotNsError("RegistryCall", "reverseResolver.nameOf call failed", { + cause: res.value, + }), + ); + } + const name = res.value as string; + return ok(name && name.length > 0 ? name : null); + } catch (cause) { + return err( + new DotNsError("RegistryCall", `DotNS reverse failed for "${address}"`, { cause }), + ); + } +} + +/** + * Whether a DotNS name is available to claim. + * + * Two reads, because "not minted" and "claimable" are different questions: + * + * - `registrarController.available(label)`, the predicate `register` enforces + * through `_requireAvailableLabel`. Deliberately not inferred from + * resolution: a registered name has no forward record until its owner sets + * one, so that would report owned names as free. + * - `PopRules.classifyName(label)`, which is pure and owner-independent. + * Labels of 5 base characters or fewer are reserved for governance and can + * never be claimed, though the registrar still reports them as unminted. + * + * `ok(true)` means the name can be registered by someone. It does not mean the + * caller can register it: labels of 6 to 8 characters additionally require a + * personhood tier, which depends on the owner and is checked by + * {@link prepareDotNsRegistration}. + * + * Validated first because both reads revert on labels we already reject. + */ +export async function isDotNsAvailable( + name: string, + opts: DotNsClientOptions, +): Promise> { + const tldRes = await resolveTld(opts); + if (!tldRes.ok) return tldRes; + const tld = tldRes.value; + const normalized = normalizeDotNsName(name, tld.suffix); + if (!isValidDotNsName(normalized, tld.suffix)) { + return err(nameError(name, normalized, tld)); + } + // The registrar takes the bare label, without the TLD suffix. + const label = stripSuffix(normalized, tld.suffix); + const controllerAddr = opts.registrarControllerAddress ?? DOTNS_ADDRESSES.registrarController; + log.debug("isDotNsAvailable", { name: normalized, label, controller: controllerAddr }); + + try { + const controller = contractOf( + opts.runtime, + controllerAddr as HexString, + DOTNS_REGISTRAR_CONTROLLER_ABI, + ); + const popRules = contractOf( + opts.runtime, + (opts.popRulesAddress ?? DOTNS_ADDRESSES.popRules) as HexString, + DOTNS_POP_RULES_ABI, + ); + const origin = readOrigin(opts); + const [res, classified] = await Promise.all([ + controller.available.query(label, { origin }), + popRules.classifyName.query(label, { origin }), + ]); + if (!res.success) { + return err( + new DotNsError("RegistryCall", "registrarController.available call failed", { + cause: res.value, + }), + ); + } + if (res.value !== true) return ok(false); + + // classifyName reverts on a label shape the registrar rejects, so a + // failure here is surfaced rather than folded into `false`. + if (!classified.success) { + return err( + new DotNsError("RegistryCall", "PopRules.classifyName call failed", { + cause: classified.value, + }), + ); + } + const { requirement } = classified.value as { requirement: number; message: string }; + return ok(requirement !== POP_STATUS.Reserved); + } catch (cause) { + return err( + new DotNsError("RegistryCall", `DotNS availability check failed for "${normalized}"`, { + cause, + }), + ); + } +} + +// ── Writes ─────────────────────────────────────────────────────────── +// +// Writes return prepared calls (`BatchableCall`) the caller submits with their +// own signer via `@parity/product-sdk-tx` — the surface stays signer-free, like +// the reads. Registration is a two-transaction commit-reveal, so it can't be a +// single call; `prepareDotNsRegistration` returns both plus the timing window. + +/** + * Prepare the calls binding a name to an address. Submit them in order with the + * owner's signer via `batchSubmitAndWatch`; both are owner-gated. + * + * Returns two calls when the node still points at the reverse resolver (the + * post-registration default): `registry.setResolver` first, or the record + * written by `setAddress` is real but unreadable. One call once it is pointed. + */ +export async function setDotNsRecord( + args: SetRecordArgs, + opts: DotNsClientOptions, +): Promise> { + const tldRes = await resolveTld(opts); + if (!tldRes.ok) return tldRes; + const tld = tldRes.value; + const normalized = normalizeDotNsName(args.name, tld.suffix); + if (!isResolvableDotNsName(normalized, tld.suffix)) { + return err(nameError(args.name, normalized, tld)); + } + // Both calls are owner-gated, so a dry-run from the fallback account would + // revert and the error would look like a contract problem. + const origin = opts.origin; + if (!origin) { + return err(new DotNsError("MissingOrigin", "setDotNsRecord needs opts.origin (SS58)")); + } + const node = namehash(normalized, tld); + const registryAddr = opts.registryAddress ?? DOTNS_ADDRESSES.registry; + const resolverAddr = opts.resolverAddress ?? DOTNS_ADDRESSES.resolver; + try { + const registry = contractOf(opts.runtime, registryAddr as HexString, DOTNS_REGISTRY_ABI); + const currentRes = await registry.resolver.query(node, { origin }); + if (!currentRes.success) { + return err( + new DotNsError("RegistryCall", "registry.resolver call failed", { + cause: currentRes.value, + }), + ); + } + + const calls: BatchableCall[] = []; + if (!sameAddress(currentRes.value, resolverAddr)) { + const pointer = await registry.setResolver.prepare(node, resolverAddr, { origin }); + if (!pointer.ok) { + return err( + new DotNsError("RegistryCall", "registry.setResolver prepare failed", { + cause: pointer.error, + }), + ); + } + calls.push(pointer.value as BatchableCall); + } + + const resolver = contractOf( + opts.runtime, + resolverAddr as HexString, + DOTNS_RESOLVER_WRITE_ABI, + ); + const prepared = await resolver.setAddress.prepare(node, args.address, { origin }); + if (!prepared.ok) { + return err( + new DotNsError("RegistryCall", "resolver.setAddress prepare failed", { + cause: prepared.error, + }), + ); + } + calls.push(prepared.value as BatchableCall); + + return ok(calls); + } catch (cause) { + return err(new DotNsError("RegistryCall", "DotNS setRecord failed", { cause })); + } +} + +/** The prepared pieces of a DotNS registration. */ +export interface DotNsRegistration { + /** The random secret bound into both commit and register. */ + secret: HexString; + /** The commitment hash (also encoded inside `commitCall`). */ + commitment: HexString; + /** Submit first, with the owner's signer. */ + commitCall: BatchableCall; + /** Seconds to wait after `commit` before `register` is accepted. */ + minCommitmentAge: bigint; + /** Seconds after which the commitment expires and `register` is rejected. */ + maxCommitmentAge: bigint; + /** Quote at prepare time, for display. The submitted value is re-read below. */ + price: bigint; + /** + * Build the register call, once `minCommitmentAge` has elapsed. + * + * Deferred rather than returned up front: `register` consumes the + * commitment, so dry-running it before `commitCall` lands reverts with + * `CommitmentNotFound` and no call could be built at all. Deferring also + * prices and sizes it against the state it will actually execute in. + */ + prepareRegisterCall: () => Promise>; +} + +/** What `register` will charge, and whether it will accept the owner at all. */ +async function quoteRegistration( + popRules: ReturnType, + label: string, + owner: string, + payer: string, + origin: SS58String, +): Promise> { + // `price(label)` is not what register charges: it skips every eligibility + // rule and ignores the owner. The owner-aware variant reports the same + // classification register applies, without reverting on it. + const quote = await popRules.priceWithoutCheck.query(label, owner, { origin }); + if (!quote.success) { + return err( + new DotNsError("RegistryCall", "PopRules.priceWithoutCheck failed", { + cause: quote.value, + }), + ); + } + const { price, status, userStatus, message } = quote.value as { + price: bigint; + status: number; + userStatus: number; + message: string; + }; + if (status === POP_STATUS.Reserved) { + return err(new DotNsError("NameReserved", `"${label}" is reserved: ${message}`)); + } + if (userStatus < status) { + return err( + new DotNsError( + "OwnerStatusInsufficient", + `"${label}" requires personhood tier ${status}, owner has ${userStatus}: ${message}`, + ), + ); + } + + // Cross-payer registrations add a friction floor; register charges the + // larger of the two. + if (sameAddress(payer, owner)) return ok(BigInt(price)); + const floor = await popRules.transferFloor.query(label, payer, owner, { origin }); + if (!floor.success) { + return err( + new DotNsError("RegistryCall", "PopRules.transferFloor failed", { + cause: floor.value, + }), + ); + } + const friction = BigInt(floor.value as string | number | bigint); + const base = BigInt(price); + return ok(base > friction ? base : friction); +} + +/** + * Prepare a DotNS registration: the commit call, the shared secret, the timing + * window, and a thunk that builds the register call afterwards. + * + * 1. submit `commitCall` + * 2. wait `minCommitmentAge`, and register before `maxCommitmentAge` + * 3. `await prepareRegisterCall()`, then submit what it returns + * + * Eligibility is checked here, before the caller pays for the commit: a + * governance-reserved label, or an owner below the tier the label requires, + * fails now rather than on the second transaction. + */ +export async function prepareDotNsRegistration( + args: RegisterDotNsArgs, + opts: DotNsClientOptions, +): Promise> { + const tldRes = await resolveTld(opts); + if (!tldRes.ok) return tldRes; + const tld = tldRes.value; + const normalized = normalizeDotNsName(args.name, tld.suffix); + if (!isValidDotNsName(normalized, tld.suffix)) { + return err(nameError(args.name, normalized, tld)); + } + const origin = opts.origin; + if (!origin) { + return err( + new DotNsError("MissingOrigin", "prepareDotNsRegistration needs opts.origin (SS58)"), + ); + } + // The registrar takes the bare label, without the TLD suffix. + const label = stripSuffix(normalized, tld.suffix); + const controllerAddr = opts.registrarControllerAddress ?? DOTNS_ADDRESSES.registrarController; + const popRulesAddr = opts.popRulesAddress ?? DOTNS_ADDRESSES.popRules; + const secret = `0x${bytesToHex(randomBytes(32))}` as HexString; + const registration = { label, owner: args.owner, secret, reserved: args.reserved ?? false }; + // register compares msg.sender against the owner; msg.sender is derived + // from the SS58 origin, so derive it the same way to predict the branch. + // ss58ToH160 throws on an undecodable address, and this sits outside the + // try below, so guard it here rather than letting it escape the Result. + let payer: HexString; + try { + payer = ss58ToH160(origin); + } catch (cause) { + return err( + new DotNsError("InvalidOrigin", `opts.origin is not a valid SS58 address: ${origin}`, { + cause, + }), + ); + } + + try { + const controller = contractOf( + opts.runtime, + controllerAddr as HexString, + DOTNS_REGISTRAR_CONTROLLER_ABI, + ); + const popRules = contractOf(opts.runtime, popRulesAddr as HexString, DOTNS_POP_RULES_ABI); + + const [commitmentRes, availableRes, quote, minRes, maxRes] = await Promise.all([ + controller.makeCommitment.query(registration, { origin }), + controller.available.query(label, { origin }), + quoteRegistration(popRules, label, args.owner, payer, origin), + controller.minCommitmentAge.query({ origin }), + controller.maxCommitmentAge.query({ origin }), + ]); + // `register` runs _requireAvailableLabel before anything else, so a + // taken name costs the caller a commit fee and then reverts. + if (!availableRes.success) { + return err( + new DotNsError("RegistryCall", "registrarController.available call failed", { + cause: availableRes.value, + }), + ); + } + if (availableRes.value !== true) { + return err(new DotNsError("NameUnavailable", `"${label}" is already registered`)); + } + if (!commitmentRes.success) { + return err( + new DotNsError("RegistryCall", "makeCommitment failed", { + cause: commitmentRes.value, + }), + ); + } + if (!quote.ok) return quote; + if (!minRes.success || !maxRes.success) { + return err( + new DotNsError("RegistryCall", "commitment window read failed", { + cause: minRes.success ? maxRes.value : minRes.value, + }), + ); + } + const commitment = commitmentRes.value as HexString; + + const commitPrep = await controller.commit.prepare(commitment, { origin }); + if (!commitPrep.ok) { + return err( + new DotNsError("RegistryCall", "controller.commit prepare failed", { + cause: commitPrep.error, + }), + ); + } + + return ok({ + secret, + commitment, + commitCall: commitPrep.value as BatchableCall, + minCommitmentAge: BigInt(minRes.value as string | number), + maxCommitmentAge: BigInt(maxRes.value as string | number), + price: quote.value, + prepareRegisterCall: async () => { + // Re-quoted, not reused: the price and the owner's tier can both + // move during the mandatory wait. + const current = await quoteRegistration(popRules, label, args.owner, payer, origin); + if (!current.ok) return current; + const prep = await controller.register.prepare(registration, { + value: current.value, + origin, + }); + if (!prep.ok) { + return err( + new DotNsError("RegistryCall", "controller.register prepare failed", { + cause: prep.error, + }), + ); + } + return ok(prep.value as BatchableCall); + }, + }); + } catch (cause) { + return err(new DotNsError("RegistryCall", "DotNS registration prepare failed", { cause })); + } +} diff --git a/product-sdk/packages/sdk/src/identity/dotns.test.ts b/product-sdk/packages/sdk/src/identity/dotns.test.ts new file mode 100644 index 00000000..11c9d24d --- /dev/null +++ b/product-sdk/packages/sdk/src/identity/dotns.test.ts @@ -0,0 +1,128 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, test } from "vitest"; +import { stripSuffix } from "./dotns-namehash.js"; +import { isResolvableDotNsName, isValidDotNsName, normalizeDotNsName } from "./dotns.js"; + +// The two deployments these helpers actually have to serve. Every case below +// runs against `.paseo` as well as `.dot`, because a helper that only behaves +// on a four-character suffix is the defect being fixed. +const DOT = ".dot"; +const PASEO = ".paseo"; + +describe("stripSuffix", () => { + test("removes the suffix by its own length", () => { + expect(stripSuffix("alice.paseo", PASEO)).toBe("alice"); + expect(stripSuffix("alice.dot", DOT)).toBe("alice"); + }); + + test("a six-character suffix does not lose two characters to a hardcoded 4", () => { + // `slice(0, -4)` returns "alice.pa" here, which then hashes and queries + // as the subname `pa` under `alice`. + expect(stripSuffix("alice.paseo", PASEO)).not.toBe("alice.pa"); + }); + + test("leaves a name that does not carry the suffix untouched", () => { + expect(stripSuffix("alice", PASEO)).toBe("alice"); + expect(stripSuffix("alice.dot", PASEO)).toBe("alice.dot"); + }); + + test("keeps subname structure", () => { + expect(stripSuffix("bob.alice.paseo", PASEO)).toBe("bob.alice"); + }); + + test("a bare suffix strips to the empty label", () => { + expect(stripSuffix(".paseo", PASEO)).toBe(""); + }); +}); + +describe("normalizeDotNsName", () => { + test("appends the deployment's suffix to a bare label", () => { + expect(normalizeDotNsName("alice", PASEO)).toBe("alice.paseo"); + expect(normalizeDotNsName("alice", DOT)).toBe("alice.dot"); + }); + + test("is idempotent for a name that already carries the suffix", () => { + expect(normalizeDotNsName("alice.paseo", PASEO)).toBe("alice.paseo"); + }); + + test("leaves a foreign suffix alone instead of burying it", () => { + // The rule this pins: appending only when there is no dot at all means + // `alice.dot` survives to be refused by validation. The `endsWith` + // rule would produce `alice.dot.paseo`, which is a well-formed subname + // and would resolve silently to a node nobody owns. + expect(normalizeDotNsName("alice.dot", PASEO)).toBe("alice.dot"); + expect(normalizeDotNsName("alice.dot", PASEO)).not.toBe("alice.dot.paseo"); + }); + + test("leaves a bare multi-label name alone", () => { + // Recorded behaviour change: this used to become `bob.alice.dot` and + // resolve. It is now left as-is and refused by validation, because + // telling `bob.alice` from `alice.dot` needs a list of known TLDs. + expect(normalizeDotNsName("bob.alice", PASEO)).toBe("bob.alice"); + }); + + test("lowercases, trims, and drops a single trailing root dot", () => { + // Matches `normalize_host` in truapi-server, so a name arriving from a + // URL keys the same way here as it does in the host. + expect(normalizeDotNsName(" Alice.PASEO. ", PASEO)).toBe("alice.paseo"); + expect(normalizeDotNsName("ALICE", PASEO)).toBe("alice.paseo"); + }); + + test("drops only one trailing dot, so a doubled dot stays visible", () => { + // `alice.paseo..` is malformed and must reach validation as such + // rather than being quietly repaired into a valid name. + expect(normalizeDotNsName("alice.paseo..", PASEO)).toBe("alice.paseo."); + }); +}); + +describe("isValidDotNsName", () => { + test("accepts one label under the deployment's suffix", () => { + expect(isValidDotNsName("alice.paseo", PASEO)).toBe(true); + expect(isValidDotNsName("alice.dot", DOT)).toBe(true); + }); + + test("refuses another deployment's suffix", () => { + expect(isValidDotNsName("alice.dot", PASEO)).toBe(false); + expect(isValidDotNsName("alice.paseo", DOT)).toBe(false); + }); + + test("refuses a subname, which the registrar cannot mint", () => { + expect(isValidDotNsName("bob.alice.paseo", PASEO)).toBe(false); + }); + + test("applies the label rule to the label, not to the suffix's length", () => { + // With a hardcoded `slice(0, -4)` the label here would be `dim2.pa`, + // which fails the label regex and would reject a registrable name. + expect(isValidDotNsName("dim2.paseo", PASEO)).toBe(true); + }); + + test("keeps the on-chain label rules", () => { + expect(isValidDotNsName("ab.paseo", PASEO)).toBe(false); // under 3 chars + expect(isValidDotNsName("-alice.paseo", PASEO)).toBe(false); // edge hyphen + expect(isValidDotNsName("al ice.paseo", PASEO)).toBe(false); // space + expect(isValidDotNsName(`${"a".repeat(64)}.paseo`, PASEO)).toBe(false); // over 63 + }); +}); + +describe("isResolvableDotNsName", () => { + test("accepts a subname, which the registry supports even though the registrar does not", () => { + expect(isResolvableDotNsName("bob.alice.paseo", PASEO)).toBe(true); + expect(isResolvableDotNsName("alice.paseo", PASEO)).toBe(true); + }); + + test("refuses another deployment's suffix", () => { + expect(isResolvableDotNsName("bob.alice.dot", PASEO)).toBe(false); + }); + + test("extracts the labels by suffix length, not by a constant 4", () => { + // `slice(0, -4)` would split `dim2.pa` into ["dim2", "pa"] and reject + // it on the 3-character minimum. + expect(isResolvableDotNsName("dim2.paseo", PASEO)).toBe(true); + }); + + test("refuses an empty label anywhere in the chain", () => { + expect(isResolvableDotNsName("bob..alice.paseo", PASEO)).toBe(false); + expect(isResolvableDotNsName(".paseo", PASEO)).toBe(false); + }); +}); diff --git a/product-sdk/packages/sdk/src/identity/dotns.ts b/product-sdk/packages/sdk/src/identity/dotns.ts index 916c6e81..7ac897bb 100644 --- a/product-sdk/packages/sdk/src/identity/dotns.ts +++ b/product-sdk/packages/sdk/src/identity/dotns.ts @@ -3,12 +3,16 @@ /** * DotNS (Polkadot Name Service) utilities * - * Provides name resolution for .dot domains + * Name validation and normalization. Every helper here takes the deployment's + * TLD suffix rather than assuming `.dot`: the TLD is fixed per network when the + * contracts initialise, so the same string is a valid name on one deployment + * and meaningless on another. `resolveTld` in `./dotns-registry.js` is what + * asks the chain which suffix is in play. */ import { accountIdBytes } from "@parity/product-sdk-address"; import { bytesToHex, hexToBytes } from "@parity/product-sdk-crypto"; -import { createLogger } from "@parity/product-sdk-logger"; +import { stripSuffix } from "./dotns-namehash.js"; import type { ChainDefinition, PalletsTypedef, @@ -18,9 +22,6 @@ import type { StorageDescriptor, TxDescriptor, } from "polkadot-api"; -import type { DotNsRecord } from "./types.js"; - -const log = createLogger("identity"); type AnyDescriptorEntry = Record>; @@ -71,94 +72,91 @@ export type PeopleUsernameQueryApi = { }; /** - * Check if a string is a valid DotNS name + * Whether `name` is a single registrable label under `suffix`. + * + * `suffix` is the deployment's own TLD, including its leading dot, as + * `resolveTld` reports it — `".dot"` on Paseo Asset Hub Previewnet, `".paseo"` + * on Next V2. It is required rather than defaulted, because a name is only + * valid *relative to a deployment*: `alice.dot` is registrable on one network + * and meaningless on the other, and a default would silently pick one. * - * @param name - Name to validate - * @returns True if valid DotNS name + * @param name - Name to validate, already normalized by {@link normalizeDotNsName} + * @param suffix - The deployment's TLD suffix, e.g. `".paseo"` */ -export function isValidDotNsName(name: string): boolean { - // Basic validation: alphanumeric, hyphens, ends with .dot - if (!name.endsWith(".dot")) return false; - const label = name.slice(0, -4); - if (label.length < 3 || label.length > 63) return false; - return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label); +export function isValidDotNsName(name: string, suffix: string): boolean { + if (!name.endsWith(suffix)) return false; + return isValidLabel(stripSuffix(name, suffix)); } /** - * Normalize a DotNS name (lowercase, trim whitespace) + * One canonical DNS label, matching the on-chain rule. * - * @param name - Name to normalize - * @returns Normalized name + * `StringUtils.isSingleLabel` allows lowercase ASCII, digits and non-edge + * hyphens up to 63 characters, and the registrar additionally requires 3. */ -export function normalizeDotNsName(name: string): string { - let normalized = name.toLowerCase().trim(); - if (!normalized.endsWith(".dot")) { - normalized += ".dot"; - } - return normalized; +function isValidLabel(label: string): boolean { + if (label.length < 3 || label.length > 63) return false; + return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label); } /** - * Resolve a DotNS name to an address. + * Whether a name can be resolved: one or more valid labels under `suffix`. * - * @deprecated Not implemented — throws at runtime. Use - * `wallet.signMessageWithDotNsIdentity({ peopleChain, username })` (which - * internally calls {@link resolvePeopleUsernameOwner}) for the supported - * People-chain username flow. + * Broader than {@link isValidDotNsName} on purpose. The registrar only mints + * single labels, so registration keeps the stricter rule, but the registry + * supports subnodes and `namehash` hashes them, so `bob.alice.paseo` is + * resolvable even though it is not registrable. * - * @param name - DotNS name (e.g., "alice.dot") - * @returns Resolved record or null if not found + * @param name - Name to check, already normalized by {@link normalizeDotNsName} + * @param suffix - The deployment's TLD suffix, e.g. `".paseo"` */ -export async function resolveDotNs(name: string): Promise { - const normalized = normalizeDotNsName(name); - - if (!isValidDotNsName(normalized)) { - log.warn("Invalid DotNS name", { name }); - return null; - } - - log.debug("Resolving DotNS name", { name: normalized }); - - // TODO: Implement via PAPI query to DotNS pallet - throw new Error( - "resolveDotNs() is not yet implemented. " + - "This is a skeleton for the Product SDK structure.", - ); +export function isResolvableDotNsName(name: string, suffix: string): boolean { + if (!name.endsWith(suffix)) return false; + const labels = stripSuffix(name, suffix).split("."); + return labels.length > 0 && labels.every(isValidLabel); } /** - * Reverse resolve an address to a DotNS name. + * Normalize a DotNS name for the deployment identified by `suffix`. * - * @deprecated Not implemented — throws at runtime. Reverse lookup will land - * alongside future identity work; for now resolve forward via - * `wallet.signMessageWithDotNsIdentity`. + * Case-folds, applies NFC, trims surrounding whitespace, and drops a single + * trailing root dot, so `Alice.PASEO.` keys the same as `alice.paseo`. This + * mirrors `normalize_host` in truapi-server, which is what turns a URL into a + * dotNS identifier — a name arriving from navigation has to key identically + * here or the two layers disagree about the same input. * - * @param address - SS58 address - * @returns Primary name or null if none set - */ -export async function reverseDotNs(address: string): Promise { - log.debug("Reverse resolving address", { address }); - - // TODO: Implement via PAPI query to DotNS pallet - throw new Error( - "reverseDotNs() is not yet implemented. " + - "This is a skeleton for the Product SDK structure.", - ); -} - -/** - * Check if a DotNS name is available for registration. + * The NFC step is defensive only: {@link isValidLabel} and the on-chain + * `StringUtils.isSingleLabel` both allow lowercase ASCII alone, so no name that + * survives validation can differ between its composed and decomposed spellings. + * It costs nothing and it keeps this function honest if the label rule ever + * widens. + * + * **The suffix is appended only when `name` contains no dot at all.** A bare + * label is the friendly path and gets the deployment's suffix; anything already + * carrying a dot is left exactly as written, so a name from another deployment + * survives to be refused by validation rather than being buried under our own + * suffix. Appending on `!endsWith(suffix)` instead would turn `alice.dot` into + * `alice.dot.paseo`, which is a well-formed subname and would resolve silently + * to a node nobody owns. * - * @deprecated Not implemented — depends on {@link resolveDotNs} which throws. + * The cost of that rule: a bare multi-label name such as `bob.alice` is no + * longer completed to `bob.alice.paseo`. Distinguishing it from `alice.dot` + * would require a hardcoded list of every network's TLD, and there are already + * more TLDs deployed than any such list contains. * - * @param name - Name to check - * @returns True if available + * @param name - Name to normalize + * @param suffix - The deployment's TLD suffix, e.g. `".paseo"` */ -export async function isDotNsAvailable(name: string): Promise { - const record = await resolveDotNs(name).catch(() => null); - return record === null; +export function normalizeDotNsName(name: string, suffix: string): string { + const folded = name.trim().normalize("NFC").toLowerCase(); + const normalized = folded.endsWith(".") ? folded.slice(0, -1) : folded; + return normalized.includes(".") ? normalized : normalized + suffix; } +// `resolveDotNs` / `reverseDotNs` / `isDotNsAvailable` now live in +// `./dotns-registry.ts` (real `Result`-returning registry calls), replacing the +// throwing skeletons that used to sit here. See that module + the design doc. + /** * Resolve a People / People Lite username to its owning `AccountId32`. * diff --git a/product-sdk/packages/sdk/src/identity/index.ts b/product-sdk/packages/sdk/src/identity/index.ts index 480855a0..7cc3dc85 100644 --- a/product-sdk/packages/sdk/src/identity/index.ts +++ b/product-sdk/packages/sdk/src/identity/index.ts @@ -11,18 +11,43 @@ * `@parity/product-sdk-signer`. */ -// DotNS utilities +// DotNS utilities (pure helpers + People-chain username path) export { isValidDotNsName, + isResolvableDotNsName, normalizeDotNsName, - resolveDotNs, - reverseDotNs, - isDotNsAvailable, accountIdHexToBytes, resolvePeopleUsernameOwner, } from "./dotns.js"; export type { PeopleUsernameChain, PeopleUsernameQueryApi } from "./dotns.js"; +// DotNS registry reads + writes (Revive contract) +export { + resolveDotNs, + reverseDotNs, + isDotNsAvailable, + setDotNsRecord, + prepareDotNsRegistration, +} from "./dotns-registry.js"; +export type { + DotNsClientOptions, + RegisterDotNsArgs, + SetRecordArgs, + DotNsRegistration, +} from "./dotns-registry.js"; +export { DotNsError } from "./dotns-errors.js"; +export type { DotNsErrorReason } from "./dotns-errors.js"; +export { + namehash, + dotNsTld, + isConsistentDotNsTld, + stripSuffix, + DOT_NODE, + DOT_TLD, +} from "./dotns-namehash.js"; +export type { DotNsTld } from "./dotns-namehash.js"; +export { POP_STATUS, DOTNS_ADDRESSES } from "./dotns-abis.js"; + // Context alias utilities (deprecated) export { deriveContextAlias, verifyContextAlias } from "./product-account.js"; diff --git a/product-sdk/packages/sdk/src/identity/types.ts b/product-sdk/packages/sdk/src/identity/types.ts index 5868426c..e2a9b346 100644 --- a/product-sdk/packages/sdk/src/identity/types.ts +++ b/product-sdk/packages/sdk/src/identity/types.ts @@ -8,13 +8,36 @@ /** DotNS name resolution result */ export interface DotNsRecord { - /** Resolved SS58 address */ - address: string; - /** Name that was resolved */ + /** + * H160 the name resolves to, `0x` and 20 bytes. Not SS58: DotNS is a set of + * Revive contracts and both address fields come back as EVM addresses. + * Convert with `h160ToSs58` from `@parity/product-sdk-address` if a + * Substrate-shaped address is needed. + * + * Absent when the name is registered but has no forward record yet, the + * state of every name just after registration. An unregistered name is + * `null` from `resolveDotNs`, not a record. + */ + address?: `0x${string}`; + /** + * Name that was resolved, normalized: lowercased, and carrying the + * deployment's own TLD suffix — `.paseo` on Paseo Asset Hub Next V2, `.dot` + * on Previewnet. A bare label passed in comes back suffixed. + * + * Note for a **lite-person** registrant: the registry stores their label + * flattened, so `alice.42` is registered as `alice42` and this field reads + * `alice42.paseo`. Pass the flattened spelling when resolving; the dotted + * one derives a different node and finds nothing. + */ name: string; - /** Owner address */ - owner: string; - /** Expiration timestamp (if applicable) */ + /** H160 of the node's owner. Not SS58, same as {@link DotNsRecord.address}. */ + owner: `0x${string}`; + /** + * Expiration timestamp, if the deployment has one. + * + * Always absent today: the Paseo Asset Hub registrar exposes no expiry + * getter, so nothing populates this. + */ expiresAt?: number; } diff --git a/product-sdk/pending-changesets/dotns-registry-surface.md b/product-sdk/pending-changesets/dotns-registry-surface.md new file mode 100644 index 00000000..50058294 --- /dev/null +++ b/product-sdk/pending-changesets/dotns-registry-surface.md @@ -0,0 +1,31 @@ +--- +"@parity/product-sdk": minor +--- + +**Add the DotNS registry surface under `@parity/product-sdk/identity`.** + +Introduces reads (`resolveDotNs` / `reverseDotNs` / `isDotNsAvailable`) and writes (`setDotNsRecord` / `prepareDotNsRegistration`), plus `DotNsClientOptions`, `RegisterDotNsArgs`, `SetRecordArgs`, `DotNsRegistration`, a `DotNsError` (`SdkError` marker, `source: "dotns"`), and the `namehash` / `dotNsTld` / `DOT_NODE` / `DOT_TLD` helpers. Everything returns a `Result`. + +**Reads.** DotNS is an ENS-style set of Revive contracts on Paseo Asset Hub. `resolveDotNs` computes the namehash under the deployment's own TLD, reads `registry.owner(node)` and `registry.resolver(node)`, then `resolver.addressOf(node)`. It reports three states, because the registry's resolver pointer is configuration rather than proof of existence: `ok(null)` for an unregistered name, `ok({ name, owner })` for a name that is registered but has no forward record yet — the pointer is zero, or points at a resolver that is not the forward one (registration parks it on the reverse resolver, and a product name typically points at the content resolver; only the forward resolver answers `addressOf`), and `ok({ name, owner, address })` when it resolves. `isDotNsAvailable` asks `registrarController.available(label)`, the predicate `register` itself enforces. `reverseDotNs` calls `reverseResolver.nameOf(account)`, which the contract already verifies against current ownership. + +**Writes** return prepared calls the caller submits with their own signer. `setDotNsRecord` returns a `BatchableCall[]`: `registry.setResolver` first when the node is not yet pointed at the forward resolver, then `resolver.setAddress`. `prepareDotNsRegistration` returns the commit call, the secret, the timing window, and a `prepareRegisterCall()` thunk to invoke after `minCommitmentAge` has elapsed. Register is deferred because it consumes the commitment: building it up front cannot work. Registration is priced with `PopRules.priceWithoutCheck(label, owner)` plus `transferFloor` on the cross-payer path, matching what `register` charges, and a reserved label or an owner below the label's personhood tier fails before the caller pays for the commit. + +**`DotNsClientOptions.origin`** is the SS58 account that will submit the calls. Required by the write helpers, which dry-run against it since the resolver and registry writes are owner-gated. Optional for reads. Absent where required, it fails with `DotNsErrorReason` `"MissingOrigin"`; present but not decodable as SS58, `"InvalidOrigin"` — never a throw, since deriving the payer's H160 from it is the one step in the module that can raise. + +**Breaking for anyone who imported the old skeletons.** `resolveDotNs` / `reverseDotNs` / `isDotNsAvailable` previously took no options and **threw** `"not yet implemented"`; they now require `DotNsClientOptions` and return a `Result`. `DotNsRecord.address` is optional and both it and `owner` are typed `0x${string}` (H160, not SS58: convert with `h160ToSs58` if needed). `DotNsRecord.expiresAt` is never set, since this deployment has no on-chain expiry. Pre-1.0, so shipped as `minor` per RELEASES.md. + +Contract addresses default to the Paseo Asset Hub deployment and are all overridable. `isResolvableDotNsName` is exported alongside `isValidDotNsName`: the registrar only mints single labels, but the registry supports subnodes, so `bob.alice.paseo` resolves even though it cannot be registered. + +**The TLD is per network, and read from the chain.** DotNS fixes its top-level domain when `DotnsProtocolRegistry.initialize` runs — `.paseo` on Paseo Asset Hub Next V2, `.dot` on Previewnet, operator-chosen elsewhere — and every name is a hash chain rooted at it. The SDK asks `protocolRegistry.tld()` once per runtime and caches it, rather than assuming a root: hashing under the wrong one produces a node the chain never wrote to, so a registered name reads back as unregistered with no error anywhere. The contract addresses, by contrast, are CREATE3-deterministic and identical on every network, which is why `PASEO_ASSETHUB_DOTNS` has been renamed `DOTNS_ADDRESSES` — the old name implied a per-network address table that does not exist. Only the TLD varies. + +A deployment older than `dotns` `b4096968` has no `tld()` getter; there the TLD was a compile-time `.dot`, so an absent getter falls back to `.dot` with a warning. Any other failed read is an error, never a guess. + +**Two behaviours changed for callers.** A name carrying another deployment's suffix is now refused with `DotNsErrorReason` `"TldMismatch"` instead of being hashed under our own root: `alice.dot` and `alice.paseo` are separate registrations that may have different owners, so translating between them silently could hand back a stranger's address. And a bare *multi-label* name is no longer completed with the suffix — `bob.alice` was previously read as `bob.alice.dot`, and is now refused, because telling it apart from `alice.dot` would need a hardcoded list of every network's TLD. A bare single label still works: `alice` resolves as `alice.paseo`. + +**New options.** `DotNsClientOptions.tld` supplies the TLD directly, skipping the chain read for offline or test use — build it with `dotNsTld(".paseo")`, since a suffix paired with the wrong node is rejected. `DotNsClientOptions.protocolRegistryAddress` overrides the contract the TLD is read from, completing the set of address overrides. + +**New exports and signatures.** `DotNsTld`, `dotNsTld(suffix)`, `DOT_TLD`, `stripSuffix`, `isConsistentDotNsTld`, and three `DotNsErrorReason` members: `"TldMismatch"` above, `"InvalidTld"` for a deployment reporting a TLD we cannot use, and `"InvalidOrigin"` above. `namehash(name, tld)` now requires the root, and `normalizeDotNsName` / `isValidDotNsName` / `isResolvableDotNsName` each require the deployment's suffix. Defaulting them was rejected deliberately: a default correct on one deployment is the defect this change fixes. + +**Lite-person names must be passed flattened.** A lite registrant reserves a dotted label (`alice.42`) and the contract strips the dots before hashing, so the registry stores `alice42`. Resolve `alice42.paseo`, not `alice.42.paseo` — the dotted spelling derives a different node and finds nothing. + +**One new failure mode.** `isDotNsAvailable` and `prepareDotNsRegistration` pass the bare label and let the contract derive the node, so they were unaffected by the rooting bug — but they now depend on the TLD read for validation, and a protocol-registry read failure fails a call that previously succeeded.