@parity/product-sdk 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/address/index.d.ts +1 -0
  2. package/dist/chunk-BRZNHTLQ.js +89 -0
  3. package/dist/chunk-BRZNHTLQ.js.map +1 -0
  4. package/dist/{chunk-6BI66XUJ.js → chunk-DHRCWMY5.js} +9 -10
  5. package/dist/chunk-DHRCWMY5.js.map +1 -0
  6. package/dist/core/index.d.ts +2 -2
  7. package/dist/core/index.js +2 -2
  8. package/dist/dotns-bswWAmR2.d.ts +136 -0
  9. package/dist/identity/index.d.ts +495 -49
  10. package/dist/identity/index.js +796 -46
  11. package/dist/identity/index.js.map +1 -1
  12. package/dist/index.d.ts +3 -3
  13. package/dist/index.js +2 -2
  14. package/dist/individuality/index.d.ts +1 -0
  15. package/dist/individuality/index.js +3 -0
  16. package/dist/individuality/index.js.map +1 -0
  17. package/dist/react/index.d.ts +2 -2
  18. package/dist/react/index.js +2 -2
  19. package/dist/testing/index.d.ts +2 -2
  20. package/dist/{types-CztPAgZT.d.ts → types-B5Zx4RNq.d.ts} +1 -1
  21. package/package.json +17 -12
  22. package/src/core/createApp.ts +10 -8
  23. package/src/identity/dotns-abis.test.ts +43 -0
  24. package/src/identity/dotns-abis.ts +309 -0
  25. package/src/identity/dotns-addresses.test.ts +536 -0
  26. package/src/identity/dotns-addresses.ts +320 -0
  27. package/src/identity/dotns-errors.ts +36 -0
  28. package/src/identity/dotns-namehash.test.ts +155 -0
  29. package/src/identity/dotns-namehash.ts +149 -0
  30. package/src/identity/dotns-owner.test.ts +92 -0
  31. package/src/identity/dotns-registry.test.ts +1309 -0
  32. package/src/identity/dotns-registry.ts +868 -0
  33. package/src/identity/dotns.test.ts +128 -0
  34. package/src/identity/dotns.ts +91 -78
  35. package/src/identity/index.test.ts +43 -0
  36. package/src/identity/index.ts +42 -16
  37. package/src/identity/types.ts +29 -38
  38. package/src/individuality/contract.test.ts +326 -0
  39. package/src/individuality/index.ts +8 -0
  40. package/dist/chunk-6BI66XUJ.js.map +0 -1
  41. package/dist/chunk-BIKT6X54.js +0 -70
  42. package/dist/chunk-BIKT6X54.js.map +0 -1
  43. package/dist/dotns-hLSvmo6u.d.ts +0 -178
  44. package/src/identity/product-account.ts +0 -164
@@ -0,0 +1,320 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Where the DotNS contract addresses come from.
5
+ *
6
+ * Every entry point in `./dotns-registry.js` asks {@link resolveDotNsAddresses}
7
+ * rather than reaching for a constant, so "which address do I call?" has one
8
+ * answer and the gateway walk can plug in behind it without touching them.
9
+ */
10
+ import {
11
+ type AbiEntry,
12
+ type ContractRuntime,
13
+ QUERY_FALLBACK_ORIGIN,
14
+ createContract,
15
+ } from "@parity/product-sdk-contracts";
16
+ import { type Result, err, ok } from "@parity/result";
17
+ import {
18
+ DOTNS_ADDRESSES,
19
+ DOTNS_POP_CONTROLLER_ABI,
20
+ DOTNS_PROTOCOL_REGISTRY_ABI,
21
+ DOTNS_ROOT_GATEWAY_DISPATCHER_ABI,
22
+ } from "./dotns-abis.js";
23
+ import { DotNsError } from "./dotns-errors.js";
24
+ import type { SS58String } from "polkadot-api";
25
+ import type { DotNsClientOptions } from "./dotns-registry.js";
26
+
27
+ type HexString = `0x${string}`;
28
+
29
+ export interface DotNsAddresses {
30
+ registry: HexString;
31
+ reverseResolver: HexString;
32
+ resolver: HexString;
33
+ registrarController: HexString;
34
+ popRules: HexString;
35
+ protocolRegistry: HexString;
36
+ }
37
+
38
+ /**
39
+ * The `DotnsProtocolRegistry.get(bytes32)` key each role answers under, from
40
+ * `DotnsConstants`. Solidity's `bytes32("name")` is the UTF-8 bytes
41
+ * right-padded with zeros, which is what these literals are.
42
+ *
43
+ * Two are not the name you would guess, and guessing wrong is silent: `get`
44
+ * answers with a live address for the contract you did not want, and it
45
+ * surfaces much later as a revert. `registrarController` is the commit-reveal
46
+ * controller, key `controller` — `registrar` is the ERC-721 holding name
47
+ * ownership. `resolver` is the forward address resolver — `contentResolver`
48
+ * holds contenthash and text records, which nothing here calls.
49
+ *
50
+ * `protocolRegistry` has no key at all, so it cannot be looked up in the
51
+ * registry it is. The walk reaches it via `popController.protocolRegistry()`.
52
+ */
53
+ export const DOTNS_REGISTRY_KEYS = {
54
+ registry: "0x7265676973747279000000000000000000000000000000000000000000000000",
55
+ registrarController: "0x636f6e74726f6c6c657200000000000000000000000000000000000000000000",
56
+ reverseResolver: "0x726576657273655265736f6c7665720000000000000000000000000000000000",
57
+ resolver: "0x7265736f6c766572000000000000000000000000000000000000000000000000",
58
+ popRules: "0x706f7052756c6573000000000000000000000000000000000000000000000000",
59
+ } as const satisfies Record<Exclude<keyof DotNsAddresses, "protocolRegistry">, HexString>;
60
+
61
+ export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
62
+
63
+ export function isZero(addr: unknown): boolean {
64
+ return typeof addr === "string" && addr.toLowerCase() === ZERO_ADDRESS;
65
+ }
66
+
67
+ /** viem checksums every decoded `address`, so a read is EIP-55 whatever the chain sent. */
68
+ export function sameAddress(a: unknown, b: unknown): boolean {
69
+ return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
70
+ }
71
+
72
+ /** A per-runtime, per-key cache of chain reads that cannot change under us. */
73
+ export type RuntimeCache<T> = WeakMap<ContractRuntime, Map<string, Promise<Result<T, DotNsError>>>>;
74
+
75
+ /**
76
+ * Read `key` once per runtime and hand every later caller the same answer.
77
+ *
78
+ * Stores the in-flight promise rather than the resolved value, so concurrent
79
+ * first calls share one read instead of racing. A failed read is dropped from
80
+ * the cache: those are usually the RPC blinking rather than the deployment
81
+ * moving, and keeping one would strand the client for the life of the runtime.
82
+ */
83
+ export function cachedPerRuntime<T>(
84
+ cache: RuntimeCache<T>,
85
+ runtime: ContractRuntime,
86
+ key: string,
87
+ load: () => Promise<Result<T, DotNsError>>,
88
+ ): Promise<Result<T, DotNsError>> {
89
+ let byKey = cache.get(runtime);
90
+ if (!byKey) {
91
+ byKey = new Map();
92
+ cache.set(runtime, byKey);
93
+ }
94
+ const cached = byKey.get(key);
95
+ if (cached) return cached;
96
+
97
+ const pending = load();
98
+ byKey.set(key, pending);
99
+ pending.then((result) => {
100
+ if (!result.ok) byKey?.delete(key);
101
+ });
102
+ return pending;
103
+ }
104
+
105
+ function withOverrides(base: DotNsAddresses, opts: DotNsClientOptions): DotNsAddresses {
106
+ return {
107
+ registry: opts.registryAddress ?? base.registry,
108
+ reverseResolver: opts.reverseResolverAddress ?? base.reverseResolver,
109
+ resolver: opts.resolverAddress ?? base.resolver,
110
+ registrarController: opts.registrarControllerAddress ?? base.registrarController,
111
+ popRules: opts.popRulesAddress ?? base.popRules,
112
+ protocolRegistry: opts.protocolRegistryAddress ?? base.protocolRegistry,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Async because the discovered source reads the chain. The pinned source does
118
+ * no IO, so a caller that only validates a name still pays nothing for it.
119
+ */
120
+ const walkCache: RuntimeCache<DotNsAddresses> = new WeakMap();
121
+
122
+ /**
123
+ * Constant, unlike the TLD cache which keys on the protocol registry address.
124
+ * Nothing else selects the deployment: `gatewayApi` is required to describe the
125
+ * same chain as `runtime`, and the walk derives every address from there.
126
+ */
127
+ const WALK_KEY = "discovered";
128
+
129
+ export async function resolveDotNsAddresses(
130
+ opts: DotNsClientOptions,
131
+ ): Promise<Result<DotNsAddresses, DotNsError>> {
132
+ if (opts.addressSource !== "discovered") return ok(withOverrides(DOTNS_ADDRESSES, opts));
133
+
134
+ // Cache the walk, not the merged set: two callers differing only in their
135
+ // overrides must share one walk and still each get their own answer.
136
+ const walked = await cachedPerRuntime(walkCache, opts.runtime, WALK_KEY, () =>
137
+ discoverDotNsAddresses(opts),
138
+ );
139
+ return walked.ok ? ok(withOverrides(walked.value, opts)) : walked;
140
+ }
141
+
142
+ /**
143
+ * Reads `DotnsGateway.DispatcherAddress`, the pallet storage the walk starts
144
+ * from. Structural because `ContractRuntime` does not declare it.
145
+ */
146
+ export type DotNsGatewayQueryApi = {
147
+ query: {
148
+ DotnsGateway: {
149
+ DispatcherAddress: {
150
+ getValue: () => Promise<{ asHex(): string } | string | undefined>;
151
+ };
152
+ };
153
+ };
154
+ };
155
+
156
+ const discoveryFailed = (step: string, cause?: unknown) =>
157
+ err(new DotNsError("AddressDiscovery", `DotNS address discovery failed: ${step}`, { cause }));
158
+
159
+ /**
160
+ * `createContract` is generic over a typed ABI def; these minimal literal ABIs
161
+ * are called by name via `.query()`, so the handle is untyped by construction.
162
+ */
163
+ export function contractOf(runtime: ContractRuntime, address: HexString, abi: AbiEntry[]) {
164
+ return createContract(runtime, address, abi as any) as any;
165
+ }
166
+
167
+ /**
168
+ * Origin for a read. Passing the fallback explicitly rather than letting the
169
+ * contracts layer substitute it keeps each query from logging a warning.
170
+ */
171
+ export function readOrigin(opts: DotNsClientOptions): SS58String {
172
+ return opts.origin ?? QUERY_FALLBACK_ORIGIN;
173
+ }
174
+
175
+ async function readAddress(
176
+ contract: {
177
+ [m: string]: { query: (o: object) => Promise<{ success: boolean; value: unknown }> };
178
+ },
179
+ method: string,
180
+ origin: string,
181
+ ): Promise<Result<HexString, DotNsError>> {
182
+ const res = await contract[method].query({ origin });
183
+ if (!res.success) return discoveryFailed(`${method}() failed`, res.value);
184
+ const value = res.value;
185
+ if (typeof value !== "string" || isZero(value)) {
186
+ return discoveryFailed(`${method}() returned no address`);
187
+ }
188
+ return ok(value as HexString);
189
+ }
190
+
191
+ /** PAPI hands back H160 as a wrapper or a hex string depending on the major. */
192
+ function asH160(value: unknown): HexString | null {
193
+ if (typeof value === "string") return value as HexString;
194
+ if (value && typeof (value as { asHex?: unknown }).asHex === "function") {
195
+ return (value as { asHex(): string }).asHex() as HexString;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function gatewayOf(opts: DotNsClientOptions): DotNsGatewayQueryApi | null {
201
+ if (opts.gatewayApi) return opts.gatewayApi;
202
+ const api = opts.runtime?.api as unknown as Partial<DotNsGatewayQueryApi> | undefined;
203
+ const getValue = api?.query?.DotnsGateway?.DispatcherAddress?.getValue;
204
+ return typeof getValue === "function" ? (api as DotNsGatewayQueryApi) : null;
205
+ }
206
+
207
+ /**
208
+ * Locate the live deployment from chain state, trusting nothing pinned here.
209
+ *
210
+ * Pallet -> `dispatcher.TARGET()` -> `popController.protocolRegistry()` ->
211
+ * `get(key)` per role. The first three are sequential because each answer is
212
+ * the next call's address; the role reads are not, so they go together.
213
+ *
214
+ * A failed or zero read is always an error: falling back to the pinned table
215
+ * would recreate the situation this exists to detect, and hide it better.
216
+ */
217
+ export async function discoverDotNsAddresses(
218
+ opts: DotNsClientOptions,
219
+ ): Promise<Result<DotNsAddresses, DotNsError>> {
220
+ try {
221
+ const gateway = gatewayOf(opts);
222
+ if (!gateway) {
223
+ return discoveryFailed(
224
+ "no DotnsGateway.DispatcherAddress storage on this runtime; pass opts.gatewayApi",
225
+ );
226
+ }
227
+ const dispatcher = asH160(await gateway.query.DotnsGateway.DispatcherAddress.getValue());
228
+ if (!dispatcher || isZero(dispatcher)) {
229
+ return discoveryFailed("DotnsGateway.DispatcherAddress is unset");
230
+ }
231
+
232
+ const origin = readOrigin(opts);
233
+ const target = await readAddress(
234
+ contractOf(opts.runtime, dispatcher, DOTNS_ROOT_GATEWAY_DISPATCHER_ABI),
235
+ "TARGET",
236
+ origin,
237
+ );
238
+ if (!target.ok) return target;
239
+
240
+ const protocolRegistry = await readAddress(
241
+ contractOf(opts.runtime, target.value, DOTNS_POP_CONTROLLER_ABI),
242
+ "protocolRegistry",
243
+ origin,
244
+ );
245
+ if (!protocolRegistry.ok) return protocolRegistry;
246
+
247
+ const registry = contractOf(
248
+ opts.runtime,
249
+ protocolRegistry.value,
250
+ DOTNS_PROTOCOL_REGISTRY_ABI,
251
+ );
252
+ const roles = Object.entries(DOTNS_REGISTRY_KEYS) as [
253
+ keyof typeof DOTNS_REGISTRY_KEYS,
254
+ HexString,
255
+ ][];
256
+ // Not readAddress: that calls the method with no arguments, and these
257
+ // need the role name in the error rather than the method name, since
258
+ // every one of them is the same `get`.
259
+ const found = await Promise.all(
260
+ roles.map(async ([role, key]) => {
261
+ const res = await registry.get.query(key, { origin });
262
+ if (!res.success) return discoveryFailed(`get(${role}) failed`, res.value);
263
+ const value = res.value;
264
+ // A wrong key is not an error on chain: it returns address(0),
265
+ // so the role name is the only clue the caller gets.
266
+ if (typeof value !== "string" || isZero(value)) {
267
+ return discoveryFailed(`${role} is unset on the protocol registry`);
268
+ }
269
+ return ok([role, value as HexString] as const);
270
+ }),
271
+ );
272
+ const failed = found.find((r) => !r.ok);
273
+ if (failed && !failed.ok) return failed;
274
+
275
+ const byRole = Object.fromEntries(found.flatMap((r) => (r.ok ? [r.value] : []))) as Omit<
276
+ DotNsAddresses,
277
+ "protocolRegistry"
278
+ >;
279
+ return ok({ ...byRole, protocolRegistry: protocolRegistry.value });
280
+ } catch (cause) {
281
+ return discoveryFailed("unexpected error", cause);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Walk the gateway and report every role whose live address differs from the
287
+ * one this client would call. Meant for startup: it fails loudly once, rather
288
+ * than making every later read pay for the check.
289
+ */
290
+ export async function verifyDotNsAddresses(
291
+ opts: DotNsClientOptions,
292
+ ): Promise<Result<DotNsAddresses, DotNsError>> {
293
+ // Through the cache, so verifying at startup and then using the client is
294
+ // one walk rather than two.
295
+ const discovered = await cachedPerRuntime(walkCache, opts.runtime, WALK_KEY, () =>
296
+ discoverDotNsAddresses(opts),
297
+ );
298
+ if (!discovered.ok) return discovered;
299
+
300
+ // Compare what this client actually calls, which is the pinned table or the
301
+ // walk depending on the source, with the caller's overrides on top either
302
+ // way. A discovered client can still drift, through an override that
303
+ // disagrees with the chain; it is only the pinned base that cannot apply.
304
+ const inUse = withOverrides(
305
+ opts.addressSource === "discovered" ? discovered.value : DOTNS_ADDRESSES,
306
+ opts,
307
+ );
308
+ const drift = (Object.keys(inUse) as (keyof DotNsAddresses)[])
309
+ .filter((role) => !sameAddress(inUse[role], discovered.value[role]))
310
+ .map((role) => `${role}: using ${inUse[role]}, chain says ${discovered.value[role]}`);
311
+
312
+ return drift.length
313
+ ? err(
314
+ new DotNsError(
315
+ "AddressMismatch",
316
+ `DotNS addresses disagree with the deployment — ${drift.join("; ")}`,
317
+ ),
318
+ )
319
+ : ok(inUse);
320
+ }
@@ -0,0 +1,36 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import type { SdkError } from "@parity/product-sdk-errors";
4
+
5
+ /**
6
+ * Error on the `Err` channel of a DotNS registry read/write. Implements the
7
+ * cross-package {@link SdkError} marker so `isSdkError(e)` recognizes it.
8
+ *
9
+ * `reason` distinguishes the failure kind so callers can branch without string
10
+ * matching.
11
+ */
12
+ export type DotNsErrorReason =
13
+ | "InvalidName" // name failed isValidDotNsName
14
+ | "InvalidTld" // the deployment reported a TLD we cannot use, or the caller supplied an inconsistent one
15
+ | "TldMismatch" // a well-formed name, but rooted at another deployment's TLD
16
+ | "MissingOrigin" // a write was requested without the submitting account
17
+ | "InvalidOrigin" // the submitting account was supplied, but is not a decodable SS58 address
18
+ | "NameUnavailable" // already registered
19
+ | "NameReserved" // governance-held, or a base stem held by another user
20
+ | "OwnerStatusInsufficient" // the owner lacks the personhood tier the label requires
21
+ | "AddressDiscovery" // the gateway walk could not locate the deployment
22
+ | "AddressMismatch" // a discovered address disagrees with the one in use
23
+ | "RegistryCall" // the on-chain contract call failed
24
+ | "Decode"; // the contract returned something we couldn't decode
25
+
26
+ export class DotNsError extends Error implements SdkError {
27
+ readonly isSdkError = true as const;
28
+ readonly source = "dotns";
29
+ readonly reason: DotNsErrorReason;
30
+
31
+ constructor(reason: DotNsErrorReason, message: string, options?: ErrorOptions) {
32
+ super(message, options);
33
+ this.name = "DotNsError";
34
+ this.reason = reason;
35
+ }
36
+ }
@@ -0,0 +1,155 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { bytesToHex, keccak256 } from "@parity/product-sdk-crypto";
4
+ import { describe, expect, test } from "vitest";
5
+ import { DOT_NODE, DOT_TLD, dotNsTld, isConsistentDotNsTld, namehash } from "./dotns-namehash.js";
6
+
7
+ // Independent reference implementation of the same algorithm, so the test
8
+ // pins behaviour rather than restating the code.
9
+ function hexBytes(hex: string): Uint8Array {
10
+ const pairs = hex.slice(2).match(/.{2}/g) ?? [];
11
+ return Uint8Array.from(pairs.map((b) => Number.parseInt(b, 16)));
12
+ }
13
+
14
+ function refNamehash(name: string, suffix: string, root: string): string {
15
+ const trimmed = name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
16
+ const labels = trimmed === "" ? [] : trimmed.split(".");
17
+ let node = hexBytes(root);
18
+ for (let i = labels.length - 1; i >= 0; i--) {
19
+ const lh = keccak256(new TextEncoder().encode(labels[i]));
20
+ const combined = new Uint8Array(64);
21
+ combined.set(node, 0);
22
+ combined.set(lh, 32);
23
+ node = keccak256(combined);
24
+ }
25
+ return `0x${bytesToHex(node)}`;
26
+ }
27
+
28
+ /**
29
+ * The `.paseo` TLD node, as `DotnsProtocolRegistry.tldNode()` reports it on
30
+ * Paseo Asset Hub Next V2.
31
+ *
32
+ * Pinned as an observed literal on purpose: it is the one value that proves our
33
+ * derivation matches what the contract computed at initialisation. Deriving it
34
+ * here instead would make the test agree with the code by construction.
35
+ *
36
+ * Needs no re-verification — it is `keccak256(0x00…00 ‖ keccak256("paseo"))`,
37
+ * so it is fixed by the label alone, not by any deployment's current state.
38
+ */
39
+ const PASEO_NODE_FROM_CHAIN = "0x096b436ee9a398429fe33ad4b359bad4398dd74b412ec1dd043c93dfbf581874";
40
+
41
+ const PASEO_TLD = dotNsTld(".paseo");
42
+
43
+ describe("dotns namehash", () => {
44
+ test("DOT_NODE is keccak256(zeros32 || keccak256('dot'))", () => {
45
+ // The only hardcoded value in the module: every node hash derives from
46
+ // it, and namehash(".dot") returning it is a tautology, since an empty
47
+ // label list skips the loop. Derive it instead.
48
+ const labelhash = keccak256(new TextEncoder().encode("dot"));
49
+ const combined = new Uint8Array(64); // leading 32 bytes are the zero root
50
+ combined.set(labelhash, 32);
51
+ expect(`0x${bytesToHex(keccak256(combined))}`).toBe(DOT_NODE);
52
+ });
53
+
54
+ test("bare .dot hashes to the DOT_NODE constant", () => {
55
+ expect(namehash(".dot", DOT_TLD)).toBe(DOT_NODE);
56
+ expect(namehash("", DOT_TLD)).toBe(DOT_NODE);
57
+ });
58
+
59
+ test("normalizes the same with or without the .dot suffix", () => {
60
+ expect(namehash("alice.dot", DOT_TLD)).toBe(namehash("alice", DOT_TLD));
61
+ });
62
+
63
+ test("matches the reference algorithm for single + sub labels", () => {
64
+ expect(namehash("alice.dot", DOT_TLD)).toBe(refNamehash("alice.dot", ".dot", DOT_NODE));
65
+ expect(namehash("bob.alice.dot", DOT_TLD)).toBe(
66
+ refNamehash("bob.alice.dot", ".dot", DOT_NODE),
67
+ );
68
+ });
69
+
70
+ test("distinct names produce distinct nodes", () => {
71
+ expect(namehash("alice.dot", DOT_TLD)).not.toBe(namehash("bob.dot", DOT_TLD));
72
+ });
73
+
74
+ test("returns a 32-byte 0x-hex string", () => {
75
+ expect(namehash("alice.dot", DOT_TLD)).toMatch(/^0x[0-9a-f]{64}$/);
76
+ });
77
+
78
+ test("roots at the TLD it is given, matching the reference algorithm", () => {
79
+ expect(namehash("alice.paseo", PASEO_TLD)).toBe(
80
+ refNamehash("alice.paseo", ".paseo", PASEO_NODE_FROM_CHAIN),
81
+ );
82
+ expect(namehash("bob.alice.paseo", PASEO_TLD)).toBe(
83
+ refNamehash("bob.alice.paseo", ".paseo", PASEO_NODE_FROM_CHAIN),
84
+ );
85
+ });
86
+
87
+ test("the same label under two TLDs gives two different nodes", () => {
88
+ // The bug this whole change exists for, in one assertion: `dim2` is
89
+ // owned on paseo-asset-hub-next under `.paseo` and unowned under `.dot`.
90
+ expect(namehash("alice.paseo", PASEO_TLD)).not.toBe(namehash("alice.dot", DOT_TLD));
91
+ });
92
+
93
+ test("strips the suffix by length, not by a hardcoded 4", () => {
94
+ // `slice(0, -4)` would leave the label `alice.pa`, which hashes as the
95
+ // subname `pa` under `alice`.
96
+ const wrong = refNamehash("alice.pa", ".paseo", PASEO_NODE_FROM_CHAIN);
97
+ expect(namehash("alice.paseo", PASEO_TLD)).not.toBe(wrong);
98
+ expect(namehash("alice.paseo", PASEO_TLD)).toBe(namehash("alice", PASEO_TLD));
99
+ });
100
+ });
101
+
102
+ describe("dotNsTld", () => {
103
+ test("derives the .paseo node the contract computed at initialisation", () => {
104
+ // `DotnsProtocolRegistry.initialize` sets
105
+ // `_tldNode = namehashUnder(0, labelhash(tldLabel))`, so the node is a
106
+ // pure function of the suffix and we never need to read `tldNode()`.
107
+ expect(dotNsTld(".paseo").node).toBe(PASEO_NODE_FROM_CHAIN);
108
+ });
109
+
110
+ test("derives DOT_NODE for .dot, tying the helper to the constant", () => {
111
+ expect(dotNsTld(".dot").node).toBe(DOT_NODE);
112
+ expect(dotNsTld(".dot")).toEqual(DOT_TLD);
113
+ });
114
+
115
+ test("keeps the suffix verbatim, including its leading dot", () => {
116
+ expect(dotNsTld(".paseo").suffix).toBe(".paseo");
117
+ });
118
+
119
+ test("accepts a bare label as well as a dotted suffix", () => {
120
+ // `tld()` returns ".paseo" but the deploy pipeline and `initialize`
121
+ // both speak the bare label, so accept either spelling.
122
+ expect(dotNsTld("paseo")).toEqual(dotNsTld(".paseo"));
123
+ });
124
+ });
125
+
126
+ describe("isConsistentDotNsTld", () => {
127
+ test("accepts a pair whose node is the derivation of its suffix", () => {
128
+ expect(isConsistentDotNsTld(DOT_TLD)).toBe(true);
129
+ expect(isConsistentDotNsTld(PASEO_TLD)).toBe(true);
130
+ });
131
+
132
+ test("rejects a suffix paired with another TLD's node", () => {
133
+ // The override-path form of the original bug: `.paseo` names rooted at
134
+ // the `.dot` node resolve to nodes nobody owns.
135
+ expect(isConsistentDotNsTld({ suffix: ".paseo", node: DOT_NODE })).toBe(false);
136
+ });
137
+
138
+ test("rejects an empty suffix and a zero node", () => {
139
+ // What a pre-b4096968 proxy upgraded without a reinitializer reports:
140
+ // `_tld` is "" and `_tldNode` is 0x00…0, and both getters succeed.
141
+ expect(isConsistentDotNsTld({ suffix: "", node: DOT_NODE })).toBe(false);
142
+ expect(isConsistentDotNsTld({ suffix: ".", node: DOT_NODE })).toBe(false);
143
+ expect(
144
+ isConsistentDotNsTld({
145
+ suffix: ".paseo",
146
+ node: `0x${"00".repeat(32)}`,
147
+ }),
148
+ ).toBe(false);
149
+ });
150
+
151
+ test("rejects a multi-label suffix, which no deployment can have", () => {
152
+ // `initialize` requires `tldLabel.isSingleLabel()`.
153
+ expect(isConsistentDotNsTld({ suffix: ".a.b", node: dotNsTld(".a.b").node })).toBe(false);
154
+ });
155
+ });
@@ -0,0 +1,149 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * DotNS namehash.
5
+ *
6
+ * ENS-style recursive hashing, but rooted at the network's TLD node rather than
7
+ * the empty root — matching `DotnsRegistry` (`contracts/registry/DotnsRegistry.sol`
8
+ * `_namehash`) and `LabelUtils.namehashUnder`.
9
+ *
10
+ * labelhash(l) = keccak256(utf8(l))
11
+ * node(parent, l) = keccak256(parent ‖ labelhash(l))
12
+ * node("alice.paseo") = node(tldNode, "alice")
13
+ * node("bob.alice.paseo") = node(node(tldNode, "alice"), "bob")
14
+ *
15
+ * The root is **per network**, not a constant. `DotnsProtocolRegistry.initialize`
16
+ * fixes it once from a deploy-time label — `.dot` on Paseo Asset Hub Previewnet,
17
+ * `.paseo` on Next V2 — and publishes it as `tld()` / `tldNode()`. Hashing under
18
+ * the wrong root yields a node the chain has never written to, so every lookup
19
+ * reports "unregistered" with no error anywhere. That is why {@link namehash}
20
+ * takes the root rather than assuming one; `resolveTld` in `./dotns-registry.js`
21
+ * is what asks the chain for it.
22
+ */
23
+ import { bytesToHex, hexToBytes, keccak256 } from "@parity/product-sdk-crypto";
24
+
25
+ /**
26
+ * A network's DotNS top-level domain: the suffix names are spelled with, and
27
+ * the node every one of its names hashes onto.
28
+ *
29
+ * The two must agree — `node` is `namehash(0, labelhash(label))` of `suffix`'s
30
+ * label — so build one with {@link dotNsTld} rather than by hand, and check an
31
+ * externally supplied pair with {@link isConsistentDotNsTld}. A `.paseo` suffix
32
+ * carrying the `.dot` node is exactly the defect this type exists to prevent.
33
+ */
34
+ export interface DotNsTld {
35
+ /** Suffix including the leading dot, as `protocolRegistry.tld()` returns it. */
36
+ suffix: string;
37
+ /** Namehash of the TLD label, as `protocolRegistry.tldNode()` returns it. */
38
+ node: `0x${string}`;
39
+ }
40
+
41
+ /** The `.dot` TLD node. Mirrors the `DotnsConstants.DOT_NODE` the contracts removed in `b4096968`. */
42
+ export const DOT_NODE = "0x3fce7d1364a893e213bc4212792b517ffc88f5b13b86c8ef9c8d390c3a1370ce";
43
+
44
+ const ZERO_NODE = `0x${"00".repeat(32)}`;
45
+
46
+ const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
47
+
48
+ /** `keccak256(parent ‖ labelhash(label))`, the one hash step every node is built from. */
49
+ function nodeUnder(parent: Uint8Array, label: string): Uint8Array {
50
+ const combined = new Uint8Array(64);
51
+ combined.set(parent, 0);
52
+ combined.set(keccak256(utf8(label)), 32);
53
+ return keccak256(combined);
54
+ }
55
+
56
+ /**
57
+ * Build a {@link DotNsTld} from a TLD suffix, deriving the node the same way the
58
+ * contract does.
59
+ *
60
+ * Mirrors `DotnsProtocolRegistry.initialize`, which sets
61
+ * `_tldNode = namehashUnder(bytes32(0), labelhash(tldLabel))`. Because the node
62
+ * is a pure function of the label, a client only ever needs the suffix — reading
63
+ * `tldNode()` is a cross-check, never the source.
64
+ *
65
+ * Accepts `".paseo"` or `"paseo"`: `tld()` returns the dotted form while
66
+ * `initialize` and the deploy pipeline speak the bare label.
67
+ */
68
+ export function dotNsTld(suffix: string): DotNsTld {
69
+ const label = suffix.startsWith(".") ? suffix.slice(1) : suffix;
70
+ return {
71
+ suffix: `.${label}`,
72
+ node: `0x${bytesToHex(nodeUnder(hexToBytes(ZERO_NODE.slice(2)), label))}`,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Whether a {@link DotNsTld} could have come from a real deployment.
78
+ *
79
+ * Three ways a pair fails, all of which produce silently wrong nodes rather
80
+ * than errors if they reach {@link namehash}:
81
+ *
82
+ * - the node is not the derivation of the suffix (a caller mixing `.paseo`
83
+ * with `DOT_NODE`);
84
+ * - the suffix is empty, or the node is zero — what a pre-`b4096968` registry
85
+ * proxy reports after an upgrade with no reinitializer, since `_tld` and
86
+ * `_tldNode` are then still at their defaults and *both getters succeed*;
87
+ * - the suffix is not a single label, which `initialize` rejects outright.
88
+ *
89
+ * Returns a boolean rather than a `Result` to keep this module free of the
90
+ * error type; callers with an error channel (`resolveTld`) map `false` to an
91
+ * `err` carrying the offending value.
92
+ */
93
+ export function isConsistentDotNsTld(tld: DotNsTld): boolean {
94
+ const label = tld.suffix.startsWith(".") ? tld.suffix.slice(1) : tld.suffix;
95
+ if (label.length === 0 || label.includes(".")) return false;
96
+ if (tld.node.toLowerCase() === ZERO_NODE) return false;
97
+ return dotNsTld(label).node.toLowerCase() === tld.node.toLowerCase();
98
+ }
99
+
100
+ /** The `.dot` TLD, for a deployment that uses it. Not a default: pass it explicitly. */
101
+ export const DOT_TLD: DotNsTld = { suffix: ".dot", node: DOT_NODE };
102
+
103
+ /**
104
+ * Drop `suffix` from the end of `name`, leaving the labels beneath it.
105
+ *
106
+ * Strips by the suffix's own length, which is the whole point: the same logic
107
+ * written as `slice(0, -4)` turns `alice.paseo` into `alice.pa`, and every
108
+ * lookup for that name then asks about a subname nobody registered. A name that
109
+ * does not carry the suffix is returned unchanged, so a foreign suffix stays
110
+ * visible to whatever validates next.
111
+ *
112
+ * Lives here rather than in `./dotns.js` so the one copy is reachable from this
113
+ * module without pulling its heavier imports in.
114
+ */
115
+ export function stripSuffix(name: string, suffix: string): string {
116
+ return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
117
+ }
118
+
119
+ /**
120
+ * Compute the DotNS node hash for a name like `"alice.paseo"` or
121
+ * `"bob.alice.paseo"`, rooted at `tld`.
122
+ *
123
+ * The trailing suffix is the TLD (folded into `tld.node`); remaining labels are
124
+ * hashed most-significant-last, so `bob.alice.paseo` layers `bob` on top of
125
+ * `alice` on top of the TLD. Input is used as-is — normalize with
126
+ * `normalizeDotNsName` first if needed.
127
+ *
128
+ * `tld` is required rather than defaulted, because a default that is correct on
129
+ * one deployment is the defect this module was fixed for. It is also not
130
+ * validated here: a name whose suffix belongs to another deployment hashes to a
131
+ * node under `tld` instead of being refused, since this function returns a hash
132
+ * and has nowhere to report a complaint. The entry points in
133
+ * `./dotns-registry.js` do that check, where there is an error channel.
134
+ *
135
+ * @returns the 32-byte node as a `0x`-prefixed hex string.
136
+ */
137
+ export function namehash(name: string, tld: DotNsTld): `0x${string}` {
138
+ const trimmed = stripSuffix(name, tld.suffix);
139
+ // "" → the bare TLD node; otherwise split into labels.
140
+ const labels = trimmed === "" ? [] : trimmed.split(".");
141
+ // Layer from the TLD outward: labels are given left-most-first
142
+ // (bob.alice), but must be applied right-to-left onto the parent.
143
+ // Annotated: hexToBytes yields ArrayBufferLike, keccak256 yields ArrayBuffer.
144
+ let node: Uint8Array = hexToBytes(tld.node.slice(2));
145
+ for (let i = labels.length - 1; i >= 0; i--) {
146
+ node = nodeUnder(node, labels[i]);
147
+ }
148
+ return `0x${bytesToHex(node)}`;
149
+ }