@hoodfi/sdk 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * EIP-1577 contenthash — the record that turns a name into a website.
3
+ *
4
+ * Only the IPFS family is handled, which is the whole of what this site can honestly
5
+ * offer: names are minted here, pinned here and served through IPFS gateways here.
6
+ * Swarm and Arweave namespaces exist in the spec and are deliberately not accepted —
7
+ * writing one would produce a record we can't preview, can't link to, and can't tell
8
+ * an owner is working.
9
+ *
10
+ * The stored bytes are <protoCode varint><CID>, so a record is two decisions: which
11
+ * namespace, and a CID the gateways will accept.
12
+ *
13
+ * ## One copy, imported by three packages
14
+ *
15
+ * The site writes records on `/manage` and reads them on `/search`; the MCP worker
16
+ * (`mcp/src/tools.ts`) does both for agents; the gateway's share page is next. Two
17
+ * implementations of a byte format is the thing to avoid above all else here — a
18
+ * contenthash off by one byte is a *well-formed* record that resolves to nothing, on a
19
+ * name whose owner has no way to tell it is broken. So there is exactly one copy, this
20
+ * is it, and the workers import it across the repo by relative path.
21
+ *
22
+ * **Why it sits under `frontend/` rather than a top-level `shared/`:** Turbopack refuses
23
+ * to resolve any import that leaves its project root — a `@shared/*` alias pointing at
24
+ * `../shared` fails the build with "aliased to relative '../shared/…' inside of
25
+ * [project]/" — and Vercel builds this app with `frontend/` as its Root Directory, so a
26
+ * sibling directory is not guaranteed to be in the build at all. The workers have no
27
+ * such restriction: esbuild follows a relative path anywhere. So the file lives where
28
+ * the fussiest consumer can see it, and `shared/` names it for the others.
29
+ *
30
+ * **It has no bare imports on purpose.** Whoever bundles it resolves `node_modules` by
31
+ * walking up from *this* file, which would silently tie the workers to whatever version
32
+ * the site happens to have installed — and break them entirely if the site's install is
33
+ * missing. Everything needed is small, pure and specified: hex, base32, base58 and
34
+ * base36 come to ~60 lines, they are checked vector-by-vector against `multiformats` in
35
+ * `contenthash.test.mjs`, and being dependency-free is what lets that test run under
36
+ * plain `node` with nothing installed.
37
+ *
38
+ * It must also stay clean under `noUncheckedIndexedAccess`, which `mcp/` sets and the
39
+ * site does not. Reasonable for a parser: every index here is into attacker-supplied
40
+ * input.
41
+ */
42
+
43
+ /** uvarint(0xe3) — ipfs-ns. */
44
+ const IPFS_NS = [0xe3, 0x01];
45
+ /** uvarint(0xe5) — ipns-ns. */
46
+ const IPNS_NS = [0xe5, 0x01];
47
+
48
+ /** Multicodecs a CID may carry for each namespace. dag-pb is a directory (a site), raw a single file. */
49
+ const IPFS_CODECS = [0x70, 0x55];
50
+ const IPNS_CODEC = 0x72; // libp2p-key
51
+
52
+ export type ContentProtocol = "ipfs" | "ipns";
53
+
54
+ export type ContentHash = {
55
+ protocol: ContentProtocol;
56
+ /**
57
+ * The canonical multibase id: base32 for an IPFS CID, base36 for an IPNS key. Both
58
+ * are lowercase and DNS-safe, which is what lets them be used as a gateway subdomain
59
+ * — the only gateway form that serves a site's own assets from the site's own origin.
60
+ */
61
+ id: string;
62
+ /** `ipfs://…` — how every other ENS tool shows the same record. */
63
+ uri: string;
64
+ };
65
+
66
+ /*//////////////////////////////////////////////////////////////
67
+ HEX
68
+ //////////////////////////////////////////////////////////////*/
69
+
70
+ function hexToBytes(hex: string): Uint8Array {
71
+ const body = hex.startsWith("0x") ? hex.slice(2) : hex;
72
+ if (body.length % 2 !== 0) throw new Error("Odd-length hex");
73
+ if (body.length > 0 && !/^[0-9a-f]+$/i.test(body)) throw new Error("Not hex");
74
+ const bytes = new Uint8Array(body.length / 2);
75
+ for (let i = 0; i < bytes.length; i += 1) {
76
+ bytes[i] = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16);
77
+ }
78
+ return bytes;
79
+ }
80
+
81
+ function bytesToHex(bytes: Uint8Array): `0x${string}` {
82
+ let out = "";
83
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
84
+ return `0x${out}`;
85
+ }
86
+
87
+ /*//////////////////////////////////////////////////////////////
88
+ MULTIBASE
89
+ //////////////////////////////////////////////////////////////*/
90
+
91
+ const BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz";
92
+ /** RFC 4648 base32, upper case, no padding. */
93
+ const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
94
+ /** Bitcoin base58 — no 0, O, I or l, so a CID can survive being read aloud. */
95
+ const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
96
+
97
+ /**
98
+ * base-N over a big integer, for the two bases whose digits don't align to bit
99
+ * boundaries. Leading zero bytes are carried explicitly in both directions: a
100
+ * big-integer conversion drops them, and for a CID that would silently change which
101
+ * key the record points at.
102
+ */
103
+ function bigBaseDecode(input: string, alphabet: string): Uint8Array {
104
+ const radix = BigInt(alphabet.length);
105
+ let value = 0n;
106
+ for (const char of input) {
107
+ const digit = alphabet.indexOf(char);
108
+ if (digit < 0) throw new Error(`Not base${alphabet.length}`);
109
+ value = value * radix + BigInt(digit);
110
+ }
111
+ const bytes: number[] = [];
112
+ while (value > 0n) {
113
+ bytes.unshift(Number(value & 0xffn));
114
+ value >>= 8n;
115
+ }
116
+ const zero = alphabet[0];
117
+ for (const char of input) {
118
+ if (char !== zero) break;
119
+ bytes.unshift(0);
120
+ }
121
+ return new Uint8Array(bytes);
122
+ }
123
+
124
+ function bigBaseEncode(bytes: Uint8Array, alphabet: string): string {
125
+ const radix = BigInt(alphabet.length);
126
+ let value = 0n;
127
+ for (const byte of bytes) value = (value << 8n) | BigInt(byte);
128
+ let out = "";
129
+ while (value > 0n) {
130
+ out = alphabet[Number(value % radix)] + out;
131
+ value /= radix;
132
+ }
133
+ for (const byte of bytes) {
134
+ if (byte !== 0) break;
135
+ out = `${alphabet[0]}${out}`;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** base36, lowercase — multibase prefix `k`, the form IPNS names are published in. */
141
+ function base36Decode(input: string): Uint8Array {
142
+ return bigBaseDecode(input, BASE36);
143
+ }
144
+
145
+ function base36Encode(bytes: Uint8Array): string {
146
+ return bigBaseEncode(bytes, BASE36);
147
+ }
148
+
149
+ /** base58, no checksum — the encoding inside a `Qm…` CIDv0. */
150
+ function base58Decode(input: string): Uint8Array {
151
+ return bigBaseDecode(input, BASE58);
152
+ }
153
+
154
+ /**
155
+ * base32 with no padding, lowercase — multibase prefix `b`, the canonical CIDv1 form.
156
+ *
157
+ * Bit-accumulator rather than a big integer: base32's 5 bits divide into bytes, so the
158
+ * only thing to get right is the tail. Encoding pads the last group with zero bits;
159
+ * decoding drops a remainder of fewer than 8 bits rather than emitting a byte from it,
160
+ * which is what makes an unpadded string round-trip.
161
+ */
162
+ function base32Decode(input: string): Uint8Array {
163
+ const upper = input.toUpperCase();
164
+ const bytes: number[] = [];
165
+ let bits = 0;
166
+ let value = 0;
167
+ for (const char of upper) {
168
+ const digit = BASE32.indexOf(char);
169
+ if (digit < 0) throw new Error("Not base32");
170
+ value = (value << 5) | digit;
171
+ bits += 5;
172
+ if (bits >= 8) {
173
+ bits -= 8;
174
+ bytes.push((value >> bits) & 0xff);
175
+ }
176
+ }
177
+ return new Uint8Array(bytes);
178
+ }
179
+
180
+ function base32Encode(bytes: Uint8Array): string {
181
+ let out = "";
182
+ let bits = 0;
183
+ let value = 0;
184
+ for (const byte of bytes) {
185
+ value = (value << 8) | byte;
186
+ bits += 8;
187
+ while (bits >= 5) {
188
+ bits -= 5;
189
+ out += BASE32[(value >> bits) & 0x1f];
190
+ }
191
+ }
192
+ if (bits > 0) out += BASE32[(value << (5 - bits)) & 0x1f];
193
+ return out.toLowerCase();
194
+ }
195
+
196
+ /*//////////////////////////////////////////////////////////////
197
+ VARINT
198
+ //////////////////////////////////////////////////////////////*/
199
+
200
+ /** Reads an unsigned varint, returning the value and where it ended. */
201
+ function readVarint(bytes: Uint8Array, offset: number): [number, number] {
202
+ let value = 0;
203
+ let shift = 0;
204
+ let i = offset;
205
+ while (i < bytes.length) {
206
+ const byte = bytes[i];
207
+ if (byte === undefined) break;
208
+ value |= (byte & 0x7f) << shift;
209
+ i += 1;
210
+ if ((byte & 0x80) === 0) return [value >>> 0, i];
211
+ shift += 7;
212
+ if (shift > 28) break;
213
+ }
214
+ throw new Error("Malformed varint");
215
+ }
216
+
217
+ /*//////////////////////////////////////////////////////////////
218
+ CID
219
+ //////////////////////////////////////////////////////////////*/
220
+
221
+ /**
222
+ * Rejects anything that isn't a well-formed CIDv1 for the namespace it claims.
223
+ *
224
+ * The length check is what catches a truncated paste. A CID is base-encoded, so half a
225
+ * CID decodes to bytes perfectly happily — only the multihash's own length prefix says
226
+ * how many bytes should have followed it, and a record written from a half-CID resolves
227
+ * to nothing forever with no error anywhere to explain why.
228
+ */
229
+ function isValidCid(bytes: Uint8Array, codecs: number[]): boolean {
230
+ try {
231
+ if (bytes[0] !== 0x01) return false;
232
+ const [codec, afterCodec] = readVarint(bytes, 1);
233
+ if (!codecs.includes(codec)) return false;
234
+ const [, afterHashCode] = readVarint(bytes, afterCodec);
235
+ const [digestLength, afterLength] = readVarint(bytes, afterHashCode);
236
+ return bytes.length - afterLength === digestLength && digestLength > 0;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ /** Decodes any multibase form a user might paste into raw CID bytes. */
243
+ function decodeMultibase(id: string): Uint8Array | null {
244
+ try {
245
+ // CIDv0 — a bare base58 multihash, always dag-pb. Kept as its own case because it
246
+ // has no multibase prefix at all: the leading `Qm` is part of the hash.
247
+ if (/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(id)) {
248
+ return new Uint8Array([0x01, 0x70, ...base58Decode(id)]);
249
+ }
250
+ if (/^b[a-z2-7]+$/i.test(id)) return base32Decode(id.slice(1));
251
+ if (/^k[0-9a-z]+$/.test(id)) return base36Decode(id.slice(1));
252
+ // A raw libp2p peer id, which is how IPNS names were published before base36.
253
+ if (/^1[1-9A-HJ-NP-Za-km-z]{10,}$/.test(id)) {
254
+ return new Uint8Array([0x01, IPNS_CODEC, ...base58Decode(id)]);
255
+ }
256
+ return null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /*//////////////////////////////////////////////////////////////
263
+ PUBLIC INTERFACE
264
+ //////////////////////////////////////////////////////////////*/
265
+
266
+ /**
267
+ * Reads what the registry holds. Returns null for an unset record and — deliberately —
268
+ * for a record in a namespace this site doesn't handle, so callers can't offer a
269
+ * "Visit" button pointing somewhere they were never able to check.
270
+ */
271
+ export function decodeContenthash(
272
+ stored: string | undefined | null
273
+ ): ContentHash | null {
274
+ if (!stored || stored === "0x") return null;
275
+ try {
276
+ const bytes = hexToBytes(stored);
277
+ const [namespace, offset] = readVarint(bytes, 0);
278
+ const cid = bytes.slice(offset);
279
+
280
+ if (namespace === 0xe3 && isValidCid(cid, IPFS_CODECS)) {
281
+ const id = `b${base32Encode(cid)}`;
282
+ return { protocol: "ipfs", id, uri: `ipfs://${id}` };
283
+ }
284
+ if (namespace === 0xe5 && isValidCid(cid, [IPNS_CODEC])) {
285
+ const id = `k${base36Encode(cid)}`;
286
+ return { protocol: "ipns", id, uri: `ipns://${id}` };
287
+ }
288
+ return null;
289
+ } catch {
290
+ return null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Whatever an owner pasted -> the bytes to store, or null if it can't be one.
296
+ *
297
+ * Generous about the input because everything people copy is a different shape of the
298
+ * same CID: the URI form, a bare CID, a gateway link with the CID in the path, and a
299
+ * subdomain gateway link with it in the host. Every one of those is unambiguous, and
300
+ * refusing them would only teach owners to hand-edit a URL until it looks like ours.
301
+ */
302
+ export function encodeContenthash(input: string): `0x${string}` | null {
303
+ const parsed = parseContentInput(input);
304
+ if (!parsed) return null;
305
+ const namespace = parsed.protocol === "ipfs" ? IPFS_NS : IPNS_NS;
306
+ return bytesToHex(new Uint8Array([...namespace, ...parsed.cid]));
307
+ }
308
+
309
+ /** The same parse, exposed for the editor's "what you're about to publish" preview. */
310
+ export function parseContenthash(input: string): ContentHash | null {
311
+ const parsed = parseContentInput(input);
312
+ if (!parsed) return null;
313
+ const id =
314
+ parsed.protocol === "ipfs"
315
+ ? `b${base32Encode(parsed.cid)}`
316
+ : `k${base36Encode(parsed.cid)}`;
317
+ return { protocol: parsed.protocol, id, uri: `${parsed.protocol}://${id}` };
318
+ }
319
+
320
+ function parseContentInput(
321
+ input: string
322
+ ): { protocol: ContentProtocol; cid: Uint8Array } | null {
323
+ let value = input.trim();
324
+ if (!value) return null;
325
+
326
+ let protocol: ContentProtocol | null = null;
327
+
328
+ // A gateway link: the CID sits either in the path (/ipfs/<cid>) or, on a subdomain
329
+ // gateway, in the host (<cid>.ipfs.dweb.link).
330
+ const httpMatch = value.match(/^https?:\/\/([^/]+)(\/.*)?$/i);
331
+ if (httpMatch) {
332
+ const host = httpMatch[1] ?? "";
333
+ const path = httpMatch[2] ?? "";
334
+ const pathMatch = path.match(/\/(ipfs|ipns)\/([^/?#]+)/i);
335
+ const hostMatch = host.match(/^([^.]+)\.(ipfs|ipns)\./i);
336
+ // Every group read below is inside its own alternative and non-optional in the
337
+ // pattern, so it is present whenever the match is — but the checks are spelled out
338
+ // because a bundler that strips them would leave the string "undefined" being
339
+ // parsed as a CID.
340
+ if (pathMatch?.[1] && pathMatch[2]) {
341
+ protocol = pathMatch[1].toLowerCase() as ContentProtocol;
342
+ value = pathMatch[2];
343
+ } else if (hostMatch?.[1] && hostMatch[2]) {
344
+ protocol = hostMatch[2].toLowerCase() as ContentProtocol;
345
+ value = hostMatch[1];
346
+ } else {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ const uriMatch = value.match(/^(ipfs|ipns):\/\/(?:(?:ipfs|ipns)\/)?(.+)$/i);
352
+ if (uriMatch?.[1] && uriMatch[2]) {
353
+ protocol = uriMatch[1].toLowerCase() as ContentProtocol;
354
+ value = uriMatch[2];
355
+ }
356
+
357
+ // A CID can carry a trailing path — the record can't, and storing the CID alone
358
+ // would quietly point at a different page than the one that was pasted.
359
+ const id = value.replace(/\/+$/, "");
360
+ if (id.includes("/") || id.includes("?") || id.includes("#")) return null;
361
+
362
+ let cid = decodeMultibase(id);
363
+ if (!cid) return null;
364
+
365
+ // A `Qm…` peer id and a `Qm…` file CID are the same 34 bytes; only the prefix that
366
+ // was typed tells them apart, so an explicit ipns:// re-labels it as a key.
367
+ if (protocol === "ipns" && cid[0] === 0x01 && cid[1] === 0x70) {
368
+ cid = new Uint8Array([0x01, IPNS_CODEC, ...cid.slice(2)]);
369
+ }
370
+
371
+ // Otherwise the CID itself says which namespace it belongs to, and it outranks the
372
+ // prefix: `ipfs://k51…` is a key, not a file, however it was pasted.
373
+ if (isValidCid(cid, IPFS_CODECS)) return { protocol: "ipfs", cid };
374
+ if (isValidCid(cid, [IPNS_CODEC])) return { protocol: "ipns", cid };
375
+ // Everything left is a CID in a codec no gateway would serve as a page.
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * A URL that serves the site today, for anyone in an ordinary browser.
381
+ *
382
+ * Deliberately a *subdomain* gateway: a path gateway (`/ipfs/<cid>/`) serves every page
383
+ * from one origin, so a site's own absolute links, its styles and its scripts resolve
384
+ * against the gateway root and the page arrives unstyled. The subdomain form gives the
385
+ * CID its own origin and the site loads as its author built it.
386
+ *
387
+ * Not our own Pinata gateway, which serves only what this account pinned and 403s
388
+ * everything else — owners publish their sites wherever they like.
389
+ */
390
+ export function contentGatewayUrl(content: ContentHash): string {
391
+ return `https://${content.id}.${content.protocol}.dweb.link/`;
392
+ }
393
+
394
+ /**
395
+ * The address the name itself answers on.
396
+ *
397
+ * This is the point of setting the record — the CID link above is the fallback that
398
+ * always works, this is the one worth telling people.
399
+ *
400
+ * `.eth.link` and `.eth.limo` both serve a hoodfi subname (verified on gm.hoodfi.eth
401
+ * once the first record existed) and both are run by eth.limo, so this is a naming
402
+ * call rather than a technical one: `.link` reads as what it is. Kept as one function
403
+ * so every place that shows an owner their address and sends a visitor to it can
404
+ * never drift apart.
405
+ */
406
+ export function nameUrl(label: string): string {
407
+ return `https://${label}.hoodfi.eth.link/`;
408
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export {
2
+ createHoodfiClient,
3
+ HoodfiError,
4
+ type HoodfiClient,
5
+ type HoodfiClientOptions,
6
+ type HoodfiErrorCode,
7
+ type NameCheck,
8
+ type NameProfile,
9
+ type SendAddress,
10
+ type AddressLookup,
11
+ type OwnedName,
12
+ type Registration,
13
+ type TxStep,
14
+ type TextKey,
15
+ } from './client.js'
16
+ export { CHAINS, SEND_CHAINS, paymentCaveat, type SendChain } from './coins.js'
17
+ export {
18
+ checkHoodfiName,
19
+ checkLabel,
20
+ fullName,
21
+ normalizeLabel,
22
+ tierOf,
23
+ MAX_LABEL_LENGTH,
24
+ PUBLIC_MIN_LENGTH,
25
+ STATUS_TEXT,
26
+ type NameStatus,
27
+ } from './labels.js'
28
+ export { decodeContenthash, type ContentHash } from './contenthash.js'
29
+ export { registrarAbi, registryAbi } from './abi.js'
30
+ export * from './constants.js'
package/src/labels.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Label rules, mirroring LabelUtils.sol (and mcp/src/labels.ts). The contract enforces
3
+ * the same rules; this exists so a bad name fails with a sentence before any RPC call.
4
+ */
5
+
6
+ import { PARENT } from './constants.js'
7
+
8
+ export const MAX_LABEL_LENGTH = 32
9
+ /** Shortest label at the $3 floor. 1-3 characters are premium tiers ($15 / $10 / $5). */
10
+ export const PUBLIC_MIN_LENGTH = 4
11
+
12
+ export type LabelCheck = { ok: true; label: string } | { ok: false; reason: string }
13
+
14
+ /**
15
+ * Accepts `foo`, `foo.hoodfi.eth` or `foo.eth` and returns the bare label.
16
+ *
17
+ * Only for minting, where "you typed vitalik.eth, here is vitalik.hoodfi.eth" is a
18
+ * convenience. Never use it to decide who to pay: see {@link checkHoodfiName}.
19
+ */
20
+ export function normalizeLabel(input: string): string {
21
+ return input.trim().toLowerCase().replace(/\.hoodfi\.eth$/, '').replace(/\.eth$/, '')
22
+ }
23
+
24
+ export function checkLabel(input: string): LabelCheck {
25
+ const label = normalizeLabel(input)
26
+ if (label.length === 0) return { ok: false, reason: 'Name is empty' }
27
+ if (label.length > MAX_LABEL_LENGTH)
28
+ return { ok: false, reason: `Names are at most ${MAX_LABEL_LENGTH} characters` }
29
+ if (!/^[a-z0-9-]+$/.test(label))
30
+ return {
31
+ ok: false,
32
+ reason: 'Names may only contain a-z, 0-9 and hyphens. Unicode and uppercase are rejected on-chain.',
33
+ }
34
+ if (label.startsWith('-') || label.endsWith('-'))
35
+ return { ok: false, reason: 'Names cannot start or end with a hyphen' }
36
+ return { ok: true, label }
37
+ }
38
+
39
+ export type HoodfiNameCheck = { ok: true; labels: string[]; name: string } | { ok: false; reason: string }
40
+
41
+ /**
42
+ * For reading or paying a name: its labels under hoodfi.eth, or a reason to refuse.
43
+ *
44
+ * Stricter than {@link normalizeLabel} on purpose. `vitalik.eth` is NOT rewritten to
45
+ * `vitalik.hoodfi.eth`: that is a different name anyone can mint, so "pay vitalik.eth"
46
+ * would pay whoever did. Only a bare label or a name ending in `.hoodfi.eth` passes.
47
+ */
48
+ export function checkHoodfiName(input: string): HoodfiNameCheck {
49
+ const name = input.trim().toLowerCase()
50
+ const suffix = `.${PARENT}`
51
+ let labels: string[]
52
+ if (name.endsWith(suffix)) labels = name.slice(0, -suffix.length).split('.')
53
+ else if (!name.includes('.')) labels = [name]
54
+ else if (name === PARENT) return { ok: false, reason: `${PARENT} is the parent name itself, not a name anyone holds.` }
55
+ else if (name.endsWith('.eth'))
56
+ return {
57
+ ok: false,
58
+ reason: `${name} is not a hoodfi.eth name. ${name.slice(0, -'.eth'.length)}${suffix} would be a different name, possibly held by someone else, so it is not substituted. Resolve ${name} with ENS on Ethereum instead.`,
59
+ }
60
+ else
61
+ return {
62
+ ok: false,
63
+ reason: `"${name}" is not a hoodfi.eth name. Give a bare name ("gm") or the full name ending in ${suffix} ("jack.gm${suffix}").`,
64
+ }
65
+ for (const label of labels) {
66
+ const check = checkLabel(label)
67
+ if (!check.ok) return { ok: false, reason: `"${label}": ${check.reason}` }
68
+ // checkLabel would also strip a trailing ".eth"; a label here never contains a dot.
69
+ }
70
+ return { ok: true, labels, name: `${labels.join('.')}${suffix}` }
71
+ }
72
+
73
+ /** Tier index 0-3 (1 char, 2 chars, 3 chars, 4+). Mirrors LabelUtils.tierOf. */
74
+ export function tierOf(label: string): 0 | 1 | 2 | 3 {
75
+ return (label.length >= 4 ? 3 : label.length - 1) as 0 | 1 | 2 | 3
76
+ }
77
+
78
+ export function fullName(label: string): string {
79
+ return `${label}.${PARENT}`
80
+ }
81
+
82
+ export type NameStatus = 'available' | 'taken' | 'locked' | 'invalid' | 'blocked'
83
+
84
+ /** HoodfiRegistrar.status() codes, in words. */
85
+ export const STATUS_TEXT: Record<number, NameStatus> = {
86
+ 0: 'available',
87
+ 1: 'taken',
88
+ 2: 'locked',
89
+ 3: 'invalid',
90
+ 4: 'blocked',
91
+ }
package/src/xrp.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The two shapes ENSIP-9 stores for the XRP Ledger, and nothing else.
3
+ *
4
+ * The library's coder is base58check over the XRP alphabet and checks the checksum but
5
+ * not the payload, and the XRPL uses that same encoding for its *secrets*: a family
6
+ * seed (`s…`, version 0x21) and an ed25519 seed (`sEd…`) decode just as cleanly as an
7
+ * address. Without this, someone pasting the wrong line from their wallet export would
8
+ * publish their seed in a public record. Testnet X-addresses (`T…`) are refused too —
9
+ * a payment record that resolves to a testnet account loses whatever is sent to it.
10
+ *
11
+ * - r-address: version 0x00 + 20-byte account ID = 21 bytes.
12
+ * - X-address (mainnet): 0x05 0x44 + account ID + tag flag (0/1) + 4-byte tag + 4
13
+ * reserved zero bytes = 31 bytes. It carries the destination tag inside it.
14
+ *
15
+ * Its own module, with no imports of ours, so `xrp.test.mjs` can load it directly.
16
+ * `frontend/lib/ens.ts` applies the same rule — see the header of coins.ts for why the
17
+ * policy is duplicated rather than shared.
18
+ */
19
+
20
+ import {
21
+ decodeXrpAddress,
22
+ encodeXrpAddress,
23
+ } from '@ensdomains/address-encoder/coders'
24
+
25
+ export function isXrpRecord(bytes: Uint8Array): boolean {
26
+ if (bytes.length === 21) return bytes[0] === 0x00
27
+ if (bytes.length !== 31 || bytes[0] !== 0x05 || bytes[1] !== 0x44) return false
28
+ const flag = bytes[22]
29
+ if (flag !== 0 && flag !== 1) return false
30
+ return bytes.slice(27).every((b) => b === 0)
31
+ }
32
+
33
+ /** Address text -> stored bytes. Throws on anything but a mainnet r- or X-address. */
34
+ export function decodeXrpRecord(value: string): Uint8Array {
35
+ const bytes = decodeXrpAddress(value)
36
+ if (!isXrpRecord(bytes)) throw new Error('not an XRP Ledger address')
37
+ return bytes
38
+ }
39
+
40
+ /** Stored bytes -> address text. Throws on bytes that are not one of the two shapes. */
41
+ export function encodeXrpRecord(bytes: Uint8Array): string {
42
+ if (!isXrpRecord(bytes)) throw new Error('not an XRP Ledger address')
43
+ return encodeXrpAddress(bytes)
44
+ }