@parity/product-sdk 0.0.0-dev.312.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 (88) hide show
  1. package/LICENSE +201 -0
  2. package/dist/address/index.d.ts +9 -0
  3. package/dist/address/index.js +3 -0
  4. package/dist/address/index.js.map +1 -0
  5. package/dist/chain/index.d.ts +1 -0
  6. package/dist/chain/index.js +3 -0
  7. package/dist/chain/index.js.map +1 -0
  8. package/dist/chunk-BRZNHTLQ.js +89 -0
  9. package/dist/chunk-BRZNHTLQ.js.map +1 -0
  10. package/dist/chunk-DHRCWMY5.js +240 -0
  11. package/dist/chunk-DHRCWMY5.js.map +1 -0
  12. package/dist/chunk-JVJ552RU.js +3 -0
  13. package/dist/chunk-JVJ552RU.js.map +1 -0
  14. package/dist/cloud-storage/index.d.ts +1 -0
  15. package/dist/cloud-storage/index.js +3 -0
  16. package/dist/cloud-storage/index.js.map +1 -0
  17. package/dist/contracts/index.d.ts +1 -0
  18. package/dist/contracts/index.js +3 -0
  19. package/dist/contracts/index.js.map +1 -0
  20. package/dist/core/index.d.ts +12 -0
  21. package/dist/core/index.js +5 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/crypto/index.d.ts +1 -0
  24. package/dist/crypto/index.js +3 -0
  25. package/dist/crypto/index.js.map +1 -0
  26. package/dist/dotns-bswWAmR2.d.ts +136 -0
  27. package/dist/host/index.d.ts +1 -0
  28. package/dist/host/index.js +3 -0
  29. package/dist/host/index.js.map +1 -0
  30. package/dist/identity/index.d.ts +491 -0
  31. package/dist/identity/index.js +691 -0
  32. package/dist/identity/index.js.map +1 -0
  33. package/dist/index.d.ts +62 -0
  34. package/dist/index.js +12 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/individuality/index.d.ts +1 -0
  37. package/dist/individuality/index.js +3 -0
  38. package/dist/individuality/index.js.map +1 -0
  39. package/dist/local-storage/index.d.ts +1 -0
  40. package/dist/local-storage/index.js +3 -0
  41. package/dist/local-storage/index.js.map +1 -0
  42. package/dist/react/index.d.ts +181 -0
  43. package/dist/react/index.js +198 -0
  44. package/dist/react/index.js.map +1 -0
  45. package/dist/testing/index.d.ts +61 -0
  46. package/dist/testing/index.js +149 -0
  47. package/dist/testing/index.js.map +1 -0
  48. package/dist/types-B5Zx4RNq.d.ts +211 -0
  49. package/dist/wallet/index.d.ts +1 -0
  50. package/dist/wallet/index.js +3 -0
  51. package/dist/wallet/index.js.map +1 -0
  52. package/package.json +118 -0
  53. package/src/address/index.ts +8 -0
  54. package/src/chain/index.ts +8 -0
  55. package/src/cloud-storage/index.ts +8 -0
  56. package/src/contracts/index.ts +8 -0
  57. package/src/core/createApp.ts +408 -0
  58. package/src/core/index.ts +12 -0
  59. package/src/core/logger.ts +15 -0
  60. package/src/core/types.ts +233 -0
  61. package/src/crypto/index.ts +8 -0
  62. package/src/host/index.ts +8 -0
  63. package/src/identity/dotns-abis.test.ts +43 -0
  64. package/src/identity/dotns-abis.ts +274 -0
  65. package/src/identity/dotns-errors.ts +34 -0
  66. package/src/identity/dotns-namehash.test.ts +155 -0
  67. package/src/identity/dotns-namehash.ts +149 -0
  68. package/src/identity/dotns-owner.test.ts +92 -0
  69. package/src/identity/dotns-registry.test.ts +1154 -0
  70. package/src/identity/dotns-registry.ts +870 -0
  71. package/src/identity/dotns.test.ts +128 -0
  72. package/src/identity/dotns.ts +221 -0
  73. package/src/identity/index.ts +61 -0
  74. package/src/identity/product-account.test.ts +130 -0
  75. package/src/identity/product-account.ts +115 -0
  76. package/src/identity/types.ts +87 -0
  77. package/src/index.ts +61 -0
  78. package/src/individuality/contract.test.ts +135 -0
  79. package/src/individuality/index.ts +8 -0
  80. package/src/local-storage/index.ts +8 -0
  81. package/src/react/context.ts +35 -0
  82. package/src/react/index.ts +43 -0
  83. package/src/react/provider.tsx +78 -0
  84. package/src/react/useChain.ts +35 -0
  85. package/src/react/useLocalStorage.ts +127 -0
  86. package/src/react/useWallet.ts +156 -0
  87. package/src/testing.ts +297 -0
  88. package/src/wallet/index.ts +11 -0
@@ -0,0 +1,43 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ //
4
+ // Guards on the transcribed deployment table. These check the shape of the
5
+ // table, not that it names the live deployment: only a diff against
6
+ // `paritytech/dotns` `deployments/paseo-assethub/420420417.json` can do that,
7
+ // and it is a network fetch. See the comment on DOTNS_ADDRESSES for the
8
+ // commit the values were copied from, and re-check it when bumping.
9
+ import { isValidH160 } from "@parity/product-sdk-address";
10
+ import { describe, expect, test } from "vitest";
11
+ import { DOTNS_ADDRESSES } from "./dotns-abis.js";
12
+
13
+ describe("DOTNS_ADDRESSES", () => {
14
+ test("every entry is a well-formed H160", () => {
15
+ for (const [name, address] of Object.entries(DOTNS_ADDRESSES)) {
16
+ expect(isValidH160(address), `${name} = ${address}`).toBe(true);
17
+ }
18
+ });
19
+
20
+ test("no two entries share an address", () => {
21
+ // Six addresses transcribed by hand: a duplicated paste is the
22
+ // transcription error most likely to go unnoticed, because the wrong
23
+ // contract still answers and only the decode looks odd.
24
+ const entries = Object.entries(DOTNS_ADDRESSES);
25
+ const seen = new Map<string, string>();
26
+ for (const [name, address] of entries) {
27
+ const key = address.toLowerCase();
28
+ expect(seen.get(key), `${name} duplicates ${seen.get(key)}`).toBeUndefined();
29
+ seen.set(key, name);
30
+ }
31
+ expect(seen.size).toBe(entries.length);
32
+ });
33
+
34
+ test("the resolver and the reverse resolver are different contracts", () => {
35
+ // Load-bearing beyond general distinctness: resolveDotNs decides whether
36
+ // a node has a forward record by comparing the registry's resolver
37
+ // pointer against the reverse resolver, so collapsing these two would
38
+ // make every registered name look resolvable.
39
+ expect(DOTNS_ADDRESSES.resolver.toLowerCase()).not.toBe(
40
+ DOTNS_ADDRESSES.reverseResolver.toLowerCase(),
41
+ );
42
+ });
43
+ });
@@ -0,0 +1,274 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Minimal ABIs for the DotNS contracts: the read and write methods this module
5
+ * calls, and nothing else. Sourced from `paritytech/dotns` (`contracts/registry`,
6
+ * `contracts/resolvers`, `contracts/registrars`, `contracts/pop`).
7
+ */
8
+ import type { AbiEntry } from "@parity/product-sdk-contracts";
9
+
10
+ /**
11
+ * `DotnsRegistry` — node → resolver / owner, plus the owner-gated pointer write.
12
+ *
13
+ * `setResolver` is needed because registration parks `records[node].resolver` on
14
+ * the reverse resolver, so the pointer must be moved once before any address
15
+ * record is readable.
16
+ */
17
+ export const DOTNS_REGISTRY_ABI: AbiEntry[] = [
18
+ {
19
+ type: "function",
20
+ name: "resolver",
21
+ inputs: [{ name: "node", type: "bytes32" }],
22
+ outputs: [{ name: "", type: "address" }],
23
+ stateMutability: "view",
24
+ },
25
+ {
26
+ type: "function",
27
+ name: "owner",
28
+ inputs: [{ name: "node", type: "bytes32" }],
29
+ outputs: [{ name: "", type: "address" }],
30
+ stateMutability: "view",
31
+ },
32
+ {
33
+ type: "function",
34
+ name: "setResolver",
35
+ inputs: [
36
+ { name: "node", type: "bytes32" },
37
+ { name: "resolverAddr", type: "address" },
38
+ ],
39
+ outputs: [],
40
+ stateMutability: "nonpayable",
41
+ },
42
+ ];
43
+
44
+ /** `DotnsResolver` — node → resolved address. */
45
+ export const DOTNS_RESOLVER_ABI: AbiEntry[] = [
46
+ {
47
+ type: "function",
48
+ name: "addressOf",
49
+ inputs: [{ name: "node", type: "bytes32" }],
50
+ outputs: [{ name: "value", type: "address" }],
51
+ stateMutability: "view",
52
+ },
53
+ ];
54
+
55
+ /** `DotnsReverseResolver` — account → primary name. */
56
+ export const DOTNS_REVERSE_RESOLVER_ABI: AbiEntry[] = [
57
+ {
58
+ type: "function",
59
+ name: "nameOf",
60
+ inputs: [{ name: "addr", type: "address" }],
61
+ outputs: [{ name: "name", type: "string" }],
62
+ stateMutability: "view",
63
+ },
64
+ ];
65
+
66
+ /** `DotnsResolver` write — set a node's resolved address (owner-gated). */
67
+ export const DOTNS_RESOLVER_WRITE_ABI: AbiEntry[] = [
68
+ {
69
+ type: "function",
70
+ name: "setAddress",
71
+ inputs: [
72
+ { name: "node", type: "bytes32" },
73
+ { name: "value", type: "address" },
74
+ ],
75
+ outputs: [],
76
+ stateMutability: "nonpayable",
77
+ },
78
+ ];
79
+
80
+ /**
81
+ * `DotnsRegistrarController` — the commit-reveal registration flow. `register`
82
+ * is payable; the value is the price from {@link DOTNS_POP_RULES_ABI}.
83
+ * `Registration` = (label, owner, secret, reserved).
84
+ */
85
+ export const DOTNS_REGISTRAR_CONTROLLER_ABI: AbiEntry[] = [
86
+ {
87
+ type: "function",
88
+ name: "available",
89
+ inputs: [{ name: "label", type: "string" }],
90
+ outputs: [{ name: "isAvailable", type: "bool" }],
91
+ stateMutability: "view",
92
+ },
93
+ {
94
+ type: "function",
95
+ name: "minCommitmentAge",
96
+ inputs: [],
97
+ outputs: [{ name: "", type: "uint256" }],
98
+ stateMutability: "view",
99
+ },
100
+ {
101
+ type: "function",
102
+ name: "maxCommitmentAge",
103
+ inputs: [],
104
+ outputs: [{ name: "", type: "uint256" }],
105
+ stateMutability: "view",
106
+ },
107
+ {
108
+ type: "function",
109
+ name: "makeCommitment",
110
+ inputs: [
111
+ {
112
+ name: "registration",
113
+ type: "tuple",
114
+ components: [
115
+ { name: "label", type: "string" },
116
+ { name: "owner", type: "address" },
117
+ { name: "secret", type: "bytes32" },
118
+ { name: "reserved", type: "bool" },
119
+ ],
120
+ },
121
+ ],
122
+ outputs: [{ name: "commitment", type: "bytes32" }],
123
+ stateMutability: "pure",
124
+ },
125
+ {
126
+ type: "function",
127
+ name: "commit",
128
+ inputs: [{ name: "commitment", type: "bytes32" }],
129
+ outputs: [],
130
+ stateMutability: "nonpayable",
131
+ },
132
+ {
133
+ type: "function",
134
+ name: "register",
135
+ inputs: [
136
+ {
137
+ name: "registration",
138
+ type: "tuple",
139
+ components: [
140
+ { name: "label", type: "string" },
141
+ { name: "owner", type: "address" },
142
+ { name: "secret", type: "bytes32" },
143
+ { name: "reserved", type: "bool" },
144
+ ],
145
+ },
146
+ ],
147
+ outputs: [],
148
+ stateMutability: "payable",
149
+ },
150
+ ];
151
+
152
+ /**
153
+ * `PopRules` — registration pricing.
154
+ *
155
+ * `price` is the label-only cost and runs no eligibility rules, so it is not
156
+ * what `register` charges. `register` uses the owner-aware variants and adds
157
+ * `transferFloor` when the payer is not the owner:
158
+ * `max(priceWithoutCheck(label, owner).price, transferFloor(label, payer, owner))`.
159
+ *
160
+ * `PopStatus` encodes as `uint8`: 0 NoStatus, 1 PopLite, 2 PopFull, 3 Reserved.
161
+ */
162
+ export const DOTNS_POP_RULES_ABI: AbiEntry[] = [
163
+ {
164
+ type: "function",
165
+ name: "price",
166
+ inputs: [{ name: "name", type: "string" }],
167
+ outputs: [{ name: "cost", type: "uint256" }],
168
+ stateMutability: "view",
169
+ },
170
+ {
171
+ type: "function",
172
+ name: "priceWithoutCheck",
173
+ inputs: [
174
+ { name: "name", type: "string" },
175
+ { name: "userAddress", type: "address" },
176
+ ],
177
+ outputs: [
178
+ {
179
+ name: "metadata",
180
+ type: "tuple",
181
+ components: [
182
+ { name: "price", type: "uint256" },
183
+ { name: "status", type: "uint8" },
184
+ { name: "userStatus", type: "uint8" },
185
+ { name: "message", type: "string" },
186
+ ],
187
+ },
188
+ ],
189
+ stateMutability: "view",
190
+ },
191
+ {
192
+ type: "function",
193
+ name: "classifyName",
194
+ inputs: [{ name: "name", type: "string" }],
195
+ outputs: [
196
+ { name: "requirement", type: "uint8" },
197
+ { name: "message", type: "string" },
198
+ ],
199
+ stateMutability: "pure",
200
+ },
201
+ {
202
+ type: "function",
203
+ name: "transferFloor",
204
+ inputs: [
205
+ { name: "name", type: "string" },
206
+ { name: "from", type: "address" },
207
+ { name: "to", type: "address" },
208
+ ],
209
+ outputs: [{ name: "floor", type: "uint256" }],
210
+ stateMutability: "view",
211
+ },
212
+ ];
213
+
214
+ /**
215
+ * `IDotnsProtocolRegistry`, the two TLD getters.
216
+ *
217
+ * The TLD is per network: `initialize(tldLabel)` fixes it once at deployment
218
+ * and there is no setter, so these are immutable for the life of a deployment.
219
+ * `tld()` returns the suffix with its leading dot (`".paseo"`); `tldNode()`
220
+ * returns `namehash(0, labelhash(label))`, which is derivable from the suffix
221
+ * and so is read only as a cross-check.
222
+ *
223
+ * Deployments predating `dotns` `b4096968` have neither getter — the TLD was a
224
+ * compile-time constant there — and revert with an empty payload.
225
+ */
226
+ export const DOTNS_PROTOCOL_REGISTRY_ABI: AbiEntry[] = [
227
+ {
228
+ type: "function",
229
+ name: "tld",
230
+ inputs: [],
231
+ outputs: [{ name: "suffix", type: "string" }],
232
+ stateMutability: "view",
233
+ },
234
+ {
235
+ type: "function",
236
+ name: "tldNode",
237
+ inputs: [],
238
+ outputs: [{ name: "node", type: "bytes32" }],
239
+ stateMutability: "view",
240
+ },
241
+ ];
242
+
243
+ /** `IPopRules.PopStatus`. Ordering is meaningful: a user meets a tier when `userStatus >= status`. */
244
+ export const POP_STATUS = { NoStatus: 0, PopLite: 1, PopFull: 2, Reserved: 3 } as const;
245
+
246
+ /**
247
+ * The deployed DotNS addresses. The same on every network.
248
+ *
249
+ * Not a per-network table, despite what the old name (`PASEO_ASSETHUB_DOTNS`)
250
+ * implied: every DotNS network is deployed through the same CREATE3 factory, so
251
+ * the addresses are identical everywhere and **only the TLD differs**. Verified
252
+ * on both Paseo Asset Hub Next V2 and Previewnet — `protocolRegistry.get(...)`
253
+ * returns the same registry on each, and all six addresses hold contracts on
254
+ * both. `paritytech/dotns` `DEPLOYMENTS.md` states the mechanism, and
255
+ * `preview-net-v1` confirms it from the deploy side: the TLD is passed only as
256
+ * registry init calldata, so it moves no address.
257
+ *
258
+ * So extending this module to another network means resolving its TLD, which
259
+ * `resolveTld` already does — not adding a second address table.
260
+ *
261
+ * Each address answers `DotnsProtocolRegistry.get(bytes32)` under its
262
+ * `DotnsConstants` key (the name below, right-padded to 32 bytes) and has a
263
+ * `Revive.AccountInfoOf` entry. They are pinned rather than resolved at runtime,
264
+ * so re-verify after a redeploy; `protocolRegistry` is the address to walk from
265
+ * if we ever resolve the rest dynamically.
266
+ */
267
+ export const DOTNS_ADDRESSES = {
268
+ registry: "0xf34054fd76BbF85f216cf9908226D5f0A72E50CA",
269
+ reverseResolver: "0xee3883d7eB60Ee9BCD7F3bcD8f2f05302A9Cc035",
270
+ resolver: "0xbd1165E549DF96F083c0A16f61590927bC187009",
271
+ registrarController: "0xBdaA01bD1bA67d709F2b1fF286Da0d854977EA30",
272
+ popRules: "0x747B456bE03aec0b42bd85C51513730FBD45DA31",
273
+ protocolRegistry: "0xD19e3D0C97CF501125a04A97405e3e6592fa846E",
274
+ } as const;
@@ -0,0 +1,34 @@
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
+ | "RegistryCall" // the on-chain contract call failed
22
+ | "Decode"; // the contract returned something we couldn't decode
23
+
24
+ export class DotNsError extends Error implements SdkError {
25
+ readonly isSdkError = true as const;
26
+ readonly source = "dotns";
27
+ readonly reason: DotNsErrorReason;
28
+
29
+ constructor(reason: DotNsErrorReason, message: string, options?: ErrorOptions) {
30
+ super(message, options);
31
+ this.name = "DotNsError";
32
+ this.reason = reason;
33
+ }
34
+ }
@@ -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
+ }