@x1id/resolve 0.12.0 → 0.14.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.
package/README.md CHANGED
@@ -116,6 +116,44 @@ to the other". `@jack.x1` throws `ambiguous` rather than guessing. Use
116
116
  `looksLikeName(input)` to decide whether to attempt resolution at all, so
117
117
  pasting base58 does not surface a validation error.
118
118
 
119
+ ## Scoped names under customer TLDs
120
+
121
+ Anyone can launch their own X1ID TLD (e.g. `.e2eshib`), and names registered
122
+ under it — `hello.e2eshib` — are first-class X1ID names. These are **not** X1NS
123
+ or SNS: `.x1` / `.xnt` / `.xen` are the separate **X1NS** domain system and
124
+ `.sol` is **SNS** (Solana Name Service), each resolved on its own path. A
125
+ customer TLD is resolved natively by this SDK against the `@handle` registry.
126
+
127
+ Scoped resolution derives a per-TLD PDA, which needs `@solana/web3.js`. It is
128
+ **opt-in and explicit** — you wire a deriver in; the SDK never auto-imports
129
+ web3 for you:
130
+
131
+ ```ts
132
+ import { createResolver, WasmResolver, makeScopedHandleDeriver } from "@x1id/resolve";
133
+ import * as web3 from "@solana/web3.js";
134
+
135
+ const wasm = await WasmResolver.fromBytes(/* module bytes */);
136
+
137
+ // `makeScopedHandleDeriver` is ASYNC — await it. Pass the RESULT (not the
138
+ // Promise) as `scopedHandleDeriver`.
139
+ const scopedHandleDeriver = await makeScopedHandleDeriver(web3);
140
+
141
+ const x1id = createResolver({
142
+ rpcUrl: "https://rpc.testnet.x1.xyz",
143
+ wasm,
144
+ scopedHandleDeriver,
145
+ });
146
+
147
+ await x1id.resolve("hello.e2eshib"); // resolves the scoped name under .e2eshib
148
+ ```
149
+
150
+ Without `scopedHandleDeriver`, resolving a launched customer TLD throws
151
+ `ResolveError { code: "not-configured" }` even with `@solana/web3.js` installed —
152
+ the deriver is what the SDK actually calls. And because `makeScopedHandleDeriver`
153
+ returns a Promise, passing it **un-awaited** is a common footgun; the SDK
154
+ detects that and throws a clear `not-configured` error telling you to `await` it,
155
+ rather than a cryptic "scopedHandleKey is not a function".
156
+
119
157
  ## Reads chain state, not an API
120
158
 
121
159
  X1NS names resolve by deriving accounts and reading them over RPC. This library
@@ -77,7 +77,17 @@ export interface GuardResult {
77
77
  *
78
78
  * Fail-closed by default: if the decision cannot be obtained (network/5xx), the
79
79
  * guard RE-THROWS {@link GateError} so your route returns an error rather than
80
- * silently admitting an unverified caller. Pass `onError: "allow"` to fail-open.
80
+ * silently admitting an unverified caller. `onError: "deny"` instead resolves to
81
+ * a clean `allowed: false` (fail-closed without a throw).
82
+ *
83
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
84
+ * timeout, 5xx, auth failure, a malformed response) it fabricates a
85
+ * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
86
+ * real access gate — anyone who can make your gate call fail (trivial: induce a
87
+ * timeout) then bypasses it completely. It exists ONLY for soft, non-security
88
+ * personalization where a missing verification must not degrade UX (e.g.
89
+ * optionally showing a "verified" flourish). If the gate protects anything —
90
+ * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
81
91
  *
82
92
  * @example
83
93
  * ```ts
@@ -78,7 +78,17 @@ export async function checkGate(cfg, subject, policy) {
78
78
  *
79
79
  * Fail-closed by default: if the decision cannot be obtained (network/5xx), the
80
80
  * guard RE-THROWS {@link GateError} so your route returns an error rather than
81
- * silently admitting an unverified caller. Pass `onError: "allow"` to fail-open.
81
+ * silently admitting an unverified caller. `onError: "deny"` instead resolves to
82
+ * a clean `allowed: false` (fail-closed without a throw).
83
+ *
84
+ * ⚠️ SECURITY — `onError: "allow"` FAILS OPEN. On ANY error (network blip,
85
+ * timeout, 5xx, auth failure, a malformed response) it fabricates a
86
+ * `passed: true` decision and admits the caller UNVERIFIED. NEVER use it for a
87
+ * real access gate — anyone who can make your gate call fail (trivial: induce a
88
+ * timeout) then bypasses it completely. It exists ONLY for soft, non-security
89
+ * personalization where a missing verification must not degrade UX (e.g.
90
+ * optionally showing a "verified" flourish). If the gate protects anything —
91
+ * a route, a mint, an airdrop, a write — use the default `"throw"` or `"deny"`.
82
92
  *
83
93
  * @example
84
94
  * ```ts
package/dist/index.js CHANGED
@@ -70,6 +70,7 @@ import { scopedTldCandidate, namespaceIsActive, NAMESPACE_MAX_LEN, } from "./sco
70
70
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
71
71
  import { DEFAULT_HANDLE_PROGRAM, TOKEN_ACCOUNT_MIN_LEN, bytesEqual, makeAccountReader, nftHolder, parseHandleAccount, readI64, readU64, } from "./accounts.js";
72
72
  import { fetchRecords, liveRecords } from "./records.js";
73
+ import { fetchSubnames, resolveSubnameRecords } from "./subname.js";
73
74
  /** Root authority for each X1NS TLD — used to verify a fetched account really
74
75
  * belongs to the TLD it claims. Without this check a caller handed an
75
76
  * arbitrary account would read an owner straight out of it. */
@@ -79,6 +80,72 @@ const TLD_ROOT = Object.freeze({
79
80
  xen: "3SUwpSz33AsyJwf6B48cKZuDTswuUEdUhcXszZrFWPqo",
80
81
  });
81
82
  const SPL_NAME_HEADER_LEN = 96;
83
+ /**
84
+ * Suffixes that are NEVER a subname parent: the native X1NS namespaces (handled
85
+ * by `parseName` before we ever reach the subname path) and the external TLDs
86
+ * the universal resolver owns. `foo.sol`/`foo.eth` must stay `unrecognized` to
87
+ * the native resolver — never resolved as a subname under `@sol`/`@eth` — so
88
+ * the whole never-conflate-a-namespace rule holds on the money path too.
89
+ */
90
+ const SUBNAME_RESERVED_SUFFIXES = new Set([
91
+ "x1",
92
+ "xnt",
93
+ "xen",
94
+ "sol",
95
+ "eth",
96
+ ]);
97
+ /**
98
+ * Classify `input` as a `label.parent` SUBNAME candidate by SHAPE ONLY (no
99
+ * chain read) — the FIRST interpretation tried for a dotted, non-`@`, non-X1NS
100
+ * name (owner-approved disambiguation order: subname before scoped-TLD). The
101
+ * shape is a single interior dot with non-empty halves, a `parent` that is not
102
+ * a reserved suffix, and a `label` with no further dot (a deeper `a.b.c` is
103
+ * sub-subname territory handled by {@link subSubnameCandidate}, NOT a one-level
104
+ * subname). Whether `@parent` is actually a registered handle is a chain
105
+ * fact the resolver establishes separately. Both halves are normalized for real
106
+ * at resolve; a parent that cannot be a handle (e.g. > 32 bytes) simply fails
107
+ * the registration check and falls through to the scoped path.
108
+ */
109
+ function subnameCandidate(input) {
110
+ const t = input.trim().toLowerCase();
111
+ if (!t || t.startsWith("@"))
112
+ return null;
113
+ const dot = t.lastIndexOf(".");
114
+ if (dot <= 0 || dot === t.length - 1)
115
+ return null;
116
+ const label = t.slice(0, dot);
117
+ const parent = t.slice(dot + 1);
118
+ if (label.includes("."))
119
+ return null; // a.b.c — a sub-subname (see subSubnameCandidate)
120
+ if (SUBNAME_RESERVED_SUFFIXES.has(parent))
121
+ return null; // never conflate with X1NS / external
122
+ return { label, parent };
123
+ }
124
+ /**
125
+ * Classify `input` as a `leaf.mid.root` SUB-SUBNAME candidate by SHAPE ONLY (no
126
+ * chain read) — a THIRD naming level (#7375 two-level), `deep.pay.jack`: a
127
+ * sub-subname `leaf` under a subname `mid.root` under the handle `@root`. Shape:
128
+ * EXACTLY three dot-separated non-empty segments, no leading `@`, and a `root`
129
+ * (last) segment that is not a reserved suffix (so `a.b.sol`/`a.b.x1` stay with
130
+ * the X1NS / universal path, never a native sub-subname). FOUR or more segments
131
+ * → `null` (the program is two levels deep only). Whether `@root` is a
132
+ * registered handle and `mid.root` a live subname are chain facts the resolver
133
+ * establishes separately ({@link Resolver.resolve}'s `resolveSubSubname`).
134
+ */
135
+ function subSubnameCandidate(input) {
136
+ const t = input.trim().toLowerCase();
137
+ if (!t || t.startsWith("@"))
138
+ return null;
139
+ const parts = t.split(".");
140
+ if (parts.length !== 3)
141
+ return null; // exactly three segments; 4+ → not a sub-subname
142
+ const [leaf, mid, root] = parts;
143
+ if (!leaf || !mid || !root)
144
+ return null; // no empty segment (e.g. `a..c`, `.a.b`)
145
+ if (SUBNAME_RESERVED_SUFFIXES.has(root))
146
+ return null; // never conflate with X1NS / external
147
+ return { leaf, mid, root };
148
+ }
82
149
  // `Primary` pointer account (`["primary", owner]` under the registry):
83
150
  // disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
84
151
  const PRIMARY_LEN = 81;
@@ -240,6 +307,15 @@ export function createResolver(config) {
240
307
  if (!scopedDeriver) {
241
308
  throw new ResolveError("not-configured", `.${label} is a launched X1ID TLD, but scoped resolution needs a PDA deriver — install @solana/web3.js and pass scopedHandleDeriver (see makeScopedHandleDeriver)`, input);
242
309
  }
310
+ // `makeScopedHandleDeriver(web3)` is async; a common footgun is passing the
311
+ // un-awaited Promise as `scopedHandleDeriver`, which would otherwise surface
312
+ // a baffling `scopedDeriver.scopedHandleKey is not a function` wrapped as an
313
+ // rpc-error. Detect the Promise/thenable (or any object missing the method)
314
+ // and fail with an actionable message instead.
315
+ if (typeof scopedDeriver.then === "function" ||
316
+ typeof scopedDeriver.scopedHandleKey !== "function") {
317
+ throw new ResolveError("not-configured", "scopedHandleDeriver must be the awaited result of makeScopedHandleDeriver(web3), not the Promise it returns — did you forget `await`?", input);
318
+ }
243
319
  let pda;
244
320
  try {
245
321
  pda = await scopedDeriver.scopedHandleKey(label, name, programBase58);
@@ -290,6 +366,275 @@ export function createResolver(config) {
290
366
  cache.set(`scoped:${label}:${name}:${chain}`, { value, expires: Date.now() + ttl });
291
367
  return value;
292
368
  }
369
+ /**
370
+ * Resolve a `label.parent` SUBNAME (#7375) — a label under a parent
371
+ * `@handle`, e.g. `pay.alice`. Called ONLY for a dotted, non-`@`, non-X1NS
372
+ * input (the native parser's `unrecognized` fall-through), and BEFORE the
373
+ * scoped customer-TLD path, per the owner-approved disambiguation order.
374
+ *
375
+ * Returns `null` — the signal to try the scoped path next — when `@parent`
376
+ * is NOT a registered handle (so `x.y` was never a subname). Once `@parent`
377
+ * IS a registered handle we are COMMITTED to the subname interpretation:
378
+ * every outcome is a `Resolved` or a throw (`not-found` / `no-record-for-chain`),
379
+ * never a fall-through to scoped.
380
+ *
381
+ * A subname has NO owner; it resolves PURELY through its per-chain `Record`s
382
+ * (like an ETH/BTC record on a handle), so EVERY chain — X1/SOL included —
383
+ * comes from a record, never an owner key. Both staleness layers of
384
+ * docs/record-trust.md are enforced via `subname.ts`:
385
+ * 1. `subname.createdAt >= parent.registeredAt` — else the subname was left
386
+ * behind by a PREVIOUS owner of the parent (any transfer/sale/recovery/
387
+ * re-registration bumps `registered_at`): `not-found`, never resolved.
388
+ * 2. `record.updatedAt >= subname.createdAt` — a record from a previous
389
+ * incarnation of the same `["subname", parent, label]` PDA is dropped.
390
+ * The subname account itself is located by the program-scoped
391
+ * `getProgramAccounts` scan (`fetchSubnames`) — scoping the scan to the
392
+ * registry program id IS the ownership check — then matched by canonical
393
+ * label; nothing is hand-derived here (the WASM module has no subname PDA
394
+ * derivation and this package never hand-rolls the on-curve check).
395
+ */
396
+ async function resolveSubname(labelRaw, parentRaw, chain, input) {
397
+ // The parent half must canonicalize as a handle; if it cannot, `@parent`
398
+ // can never be a registered handle → not a subname, try scoped instead.
399
+ let parentCanonical;
400
+ try {
401
+ parentCanonical = normalizeHandle(parentRaw);
402
+ }
403
+ catch {
404
+ return null;
405
+ }
406
+ // The subname label must also canonicalize as a handle label (same rules).
407
+ // If it cannot we cannot build a cache key yet — defer the verdict until
408
+ // after the parent-registration check decides the branch.
409
+ let subLabel = null;
410
+ try {
411
+ subLabel = normalizeHandle(labelRaw);
412
+ }
413
+ catch {
414
+ subLabel = null;
415
+ }
416
+ if (subLabel !== null && ttl > 0) {
417
+ const hit = cache.get(`subname:${parentCanonical}:${subLabel}:${chain}`);
418
+ if (hit && hit.expires > Date.now())
419
+ return hit.value;
420
+ }
421
+ // Is `@parent` a REGISTERED handle? That — and only that — decides whether
422
+ // `x.y` is a subname. `not-found`/`invalid-handle` → not a subname, fall
423
+ // through to scoped; a real rpc-error/malformed account propagates.
424
+ let parent;
425
+ try {
426
+ parent = await fetchHandle(parentCanonical, input);
427
+ }
428
+ catch (e) {
429
+ if (e instanceof ResolveError && (e.code === "not-found" || e.code === "invalid-handle")) {
430
+ return null;
431
+ }
432
+ throw e;
433
+ }
434
+ // COMMITTED to the subname interpretation: `@parent` is a live handle.
435
+ if (subLabel === null) {
436
+ throw new ResolveError("not-found", `"${input.trim()}" is not a subname of @${parentCanonical}`, input);
437
+ }
438
+ const display = `${subLabel}.${parentCanonical}`;
439
+ // Locate the subname account via the program scan (ownership check), then
440
+ // match the canonical label. `fetchSubnames` already flags each with
441
+ // `live` (staleness layer 1: createdAt >= parent.registeredAt).
442
+ const subs = await fetchSubnames(rpc, programBase58, parent.pda, parent.handle.registeredAt);
443
+ const match = subs.find((s) => s.label === subLabel);
444
+ if (!match) {
445
+ throw new ResolveError("not-found", `${display} is not a subname of @${parentCanonical}`, input);
446
+ }
447
+ // Staleness layer 1 — a subname stranded by a parent transfer/re-registration
448
+ // belongs to a PREVIOUS owner and must NEVER resolve to a payment address.
449
+ if (!match.live) {
450
+ throw new ResolveError("not-found", `${display} was created before the current @${parentCanonical} registration — it belongs to a previous owner`, input);
451
+ }
452
+ // Read the subname's LIVE records. `resolveSubnameRecords` re-applies layer
453
+ // 1 (returns [] when stale) AND layer 2 (record.updatedAt >= createdAt),
454
+ // dropping any stale record — the only safe entry point.
455
+ const subname = {
456
+ parent: match.parent,
457
+ label: match.label,
458
+ createdAt: match.createdAt,
459
+ bump: match.bump,
460
+ };
461
+ const records = await resolveSubnameRecords(rpc, programBase58, match.account, subname, parent.handle.registeredAt);
462
+ const record = records.find((r) => !r.stale && r.coinType === CHAIN_COIN_TYPE[chain]);
463
+ if (!record) {
464
+ throw new ResolveError("no-record-for-chain", `${display} has no ${chain} record`, input);
465
+ }
466
+ const value = {
467
+ input,
468
+ name: display,
469
+ // A subname is its own namespace — never "handle" (so a consumer that
470
+ // branches on `=== "handle"`, e.g. dev-api's attestation enrichment, does
471
+ // not treat it as a top-level handle) and never the parent's.
472
+ namespace: "subname",
473
+ address: record.address,
474
+ chain,
475
+ // Like a handle's ETH/BTC record: `unverified` unless the record itself
476
+ // is co-signed/verified (and `verified` is already forced false for any
477
+ // stale record by records.ts).
478
+ verification: record.verified ? "verified" : "unverified",
479
+ };
480
+ if (ttl > 0) {
481
+ cache.set(`subname:${parentCanonical}:${subLabel}:${chain}`, {
482
+ value,
483
+ expires: Date.now() + ttl,
484
+ });
485
+ }
486
+ return value;
487
+ }
488
+ /**
489
+ * Resolve a `leaf.mid.root` SUB-SUBNAME (#7375 two-level, owner decision
490
+ * 2026-09-24) — a sub-subname `leaf` under a subname `mid.root` under the
491
+ * handle `@root`, e.g. `deep.pay.jack`. Called ONLY for an exactly-3-segment,
492
+ * non-`@`, non-X1NS input (the native parser's `unrecognized` fall-through).
493
+ *
494
+ * Returns `null` — the signal to fall through to `unrecognized` — when the
495
+ * input was never a sub-subname at all: `@root` is NOT a registered handle,
496
+ * or there is no `mid` subname account under it. Those cases are "this 3-dot
497
+ * name is not one of ours", not an error. Once the `mid` subname ACCOUNT
498
+ * exists we are COMMITTED: every outcome is a `Resolved` or a throw
499
+ * (`not-found` / `no-record-for-chain`), never a fall-through and never a
500
+ * wrong address.
501
+ *
502
+ * A sub-subname has NO owner (like every subname); it resolves PURELY through
503
+ * its per-chain `Record`s, EVERY chain included. ALL THREE staleness layers of
504
+ * docs/record-trust.md (generalized to three levels — see app's subSubname.ts)
505
+ * are enforced:
506
+ * 1. `mid.createdAt >= root.registeredAt` — the level-2 subname belongs to
507
+ * the handle's CURRENT owner (the subname scan's `.live` flag). A `mid`
508
+ * stranded by a parent transfer/re-registration → `not-found`.
509
+ * 2. `leaf.createdAt >= mid.createdAt` — the sub-subname belongs to the
510
+ * CURRENT incarnation of its immediate parent subname (that PDA is reused
511
+ * across revoke + re-create). The second scan's `.live` flag, re-checked
512
+ * inside `resolveSubnameRecords`.
513
+ * 3. `record.updatedAt >= leaf.createdAt` — the record belongs to the
514
+ * CURRENT incarnation of the sub-subname itself (ordinary record staleness
515
+ * with the leaf's `createdAt` as the epoch).
516
+ * Nothing is hand-derived: both levels are located by the program-scoped
517
+ * `getProgramAccounts` scan (`fetchSubnames`, which IS the ownership check),
518
+ * the mid subname's pubkey standing in for the parent at the lower level — the
519
+ * same account type and formula, one level deeper.
520
+ */
521
+ async function resolveSubSubname(leafRaw, midRaw, rootRaw, chain, input) {
522
+ // The root half must canonicalize as a handle; if it cannot, `@root` can
523
+ // never be a registered handle → not a sub-subname, fall through.
524
+ let rootCanonical;
525
+ try {
526
+ rootCanonical = normalizeHandle(rootRaw);
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ // The mid + leaf halves must also canonicalize as handle labels. Defer a
532
+ // non-canonical verdict until the branch (mid-account existence) is decided,
533
+ // exactly as `resolveSubname` defers its `subLabel`.
534
+ let midLabel = null;
535
+ let leafLabel = null;
536
+ try {
537
+ midLabel = normalizeHandle(midRaw);
538
+ }
539
+ catch {
540
+ midLabel = null;
541
+ }
542
+ try {
543
+ leafLabel = normalizeHandle(leafRaw);
544
+ }
545
+ catch {
546
+ leafLabel = null;
547
+ }
548
+ if (midLabel !== null && leafLabel !== null && ttl > 0) {
549
+ const hit = cache.get(`subsubname:${rootCanonical}:${midLabel}:${leafLabel}:${chain}`);
550
+ if (hit && hit.expires > Date.now())
551
+ return hit.value;
552
+ }
553
+ // Is `@root` a REGISTERED handle? If not, `a.b.c` was never a sub-subname.
554
+ // `not-found`/`invalid-handle` → fall through; a real error propagates.
555
+ let root;
556
+ try {
557
+ root = await fetchHandle(rootCanonical, input);
558
+ }
559
+ catch (e) {
560
+ if (e instanceof ResolveError && (e.code === "not-found" || e.code === "invalid-handle")) {
561
+ return null;
562
+ }
563
+ throw e;
564
+ }
565
+ // A non-canonical mid can never match a stored subname label → not a
566
+ // sub-subname (fall through), same as a missing mid account below.
567
+ if (midLabel === null)
568
+ return null;
569
+ // Locate the level-2 (mid) subname under the handle. Its EXISTENCE (a label
570
+ // match) is the COMMIT POINT: a missing mid subname means `a.b.c` names
571
+ // nothing of ours → fall through to `unrecognized`; a mid subname that
572
+ // EXISTS is the point of no return. `fetchSubnames` flags each `.live`
573
+ // against `root.registeredAt` — staleness LAYER 1.
574
+ const subs = await fetchSubnames(rpc, programBase58, root.pda, root.handle.registeredAt);
575
+ const mid = subs.find((s) => s.label === midLabel);
576
+ if (!mid)
577
+ return null; // no such mid subname → unrecognized
578
+ // COMMITTED to the sub-subname interpretation from here.
579
+ const leafDisplay = leafLabel ?? leafRaw.trim().toLowerCase();
580
+ const display = `${leafDisplay}.${midLabel}.${rootCanonical}`;
581
+ const midDisplay = `${midLabel}.${rootCanonical}`;
582
+ // Staleness LAYER 1 — a mid subname stranded by a parent transfer/
583
+ // re-registration belongs to a PREVIOUS owner; nothing under it may resolve.
584
+ if (!mid.live) {
585
+ throw new ResolveError("not-found", `${midDisplay} was created before the current @${rootCanonical} registration — ${display} belongs to a previous owner`, input);
586
+ }
587
+ if (leafLabel === null) {
588
+ throw new ResolveError("not-found", `"${input.trim()}" is not a sub-subname of ${midDisplay}`, input);
589
+ }
590
+ // Locate the level-3 (leaf) sub-subname under the mid subname — the SAME
591
+ // program scan + `Subname` account type, the mid subname's pubkey standing
592
+ // in for the parent and its `createdAt` for `parentRegisteredAt`. The
593
+ // resulting `.live` flag is staleness LAYER 2 (leaf.createdAt >= mid.createdAt).
594
+ const subsubs = await fetchSubnames(rpc, programBase58, mid.account, mid.createdAt);
595
+ const leaf = subsubs.find((s) => s.label === leafLabel);
596
+ if (!leaf) {
597
+ throw new ResolveError("not-found", `${display} is not a sub-subname of ${midDisplay}`, input);
598
+ }
599
+ // Staleness LAYER 2 — a leaf stranded by its parent subname being revoked +
600
+ // re-created belongs to a previous incarnation and must never resolve.
601
+ if (!leaf.live) {
602
+ throw new ResolveError("not-found", `${display} was created before the current ${midDisplay} subname — it belongs to a previous incarnation`, input);
603
+ }
604
+ // Read the leaf's LIVE records. `resolveSubnameRecords` re-applies LAYER 2
605
+ // (leaf.createdAt >= mid.createdAt) AND LAYER 3 (record.updatedAt >=
606
+ // leaf.createdAt), dropping any stale record — the only safe entry point.
607
+ const leafSubname = {
608
+ parent: leaf.parent,
609
+ label: leaf.label,
610
+ createdAt: leaf.createdAt,
611
+ bump: leaf.bump,
612
+ };
613
+ const records = await resolveSubnameRecords(rpc, programBase58, leaf.account, leafSubname, mid.createdAt);
614
+ const record = records.find((r) => !r.stale && r.coinType === CHAIN_COIN_TYPE[chain]);
615
+ if (!record) {
616
+ throw new ResolveError("no-record-for-chain", `${display} has no ${chain} record`, input);
617
+ }
618
+ const value = {
619
+ input,
620
+ name: display,
621
+ // A sub-subname is its own namespace — never "handle", "subname", or the
622
+ // root's — so a consumer branching on the namespace treats it distinctly.
623
+ namespace: "sub-subname",
624
+ address: record.address,
625
+ chain,
626
+ // Like a subname record: `unverified` unless the record itself is
627
+ // co-signed (sub-subname records are always unverified on chain in v1).
628
+ verification: record.verified ? "verified" : "unverified",
629
+ };
630
+ if (ttl > 0) {
631
+ cache.set(`subsubname:${rootCanonical}:${midLabel}:${leafLabel}:${chain}`, {
632
+ value,
633
+ expires: Date.now() + ttl,
634
+ });
635
+ }
636
+ return value;
637
+ }
293
638
  /** See `Resolver.records`. */
294
639
  async function records(input) {
295
640
  const parsed = parseName(input); // throws with a specific code
@@ -307,12 +652,36 @@ export function createResolver(config) {
307
652
  }
308
653
  catch (e) {
309
654
  // A dotted, non-`@`, non-X1NS name is `unrecognized` to the strict native
310
- // parser — but it MAY be a scoped customer-TLD name `name.tld` (#8484/
311
- // #8485). Try that before giving up; `resolveScoped` itself re-throws the
312
- // same `unrecognized` when `.tld` is not a launched namespace, so
655
+ // parser — but it MAY be a one-level SUBNAME `label.parent` (#7375), a
656
+ // two-level SUB-SUBNAME `leaf.mid.root` (#7375 two-level, 3 segments), or
657
+ // a scoped customer-TLD name `name.tld` (#8484/#8485). Owner-approved
658
+ // disambiguation order: the subname interpretation FIRST (when `@parent`
659
+ // is a registered handle), then the sub-subname interpretation (when
660
+ // `@root` is a registered handle AND `mid.root` an existing subname), then
661
+ // the scoped path. `subnameCandidate`/`scopedTldCandidate` are both
662
+ // 2-segment-only and `subSubnameCandidate` is 3-segment-only, so each
663
+ // input matches at most one; each re-throws / falls through so
313
664
  // `.sol`/`.eth`/unknown TLDs behave exactly as before. Any other code
314
665
  // (ambiguous / invalid-*) is preserved verbatim.
315
666
  if (e instanceof ResolveError && e.code === "unrecognized") {
667
+ const sub = subnameCandidate(input);
668
+ if (sub) {
669
+ // `null` ⇒ `@parent` is not a registered handle ⇒ not a subname;
670
+ // fall through to the scoped path. A committed subname path either
671
+ // returns a `Resolved` or throws (never reaches scoped).
672
+ const resolved = await resolveSubname(sub.label, sub.parent, chain, input);
673
+ if (resolved !== null)
674
+ return resolved;
675
+ }
676
+ const subsub = subSubnameCandidate(input);
677
+ if (subsub) {
678
+ // `null` ⇒ `@root` is not a handle, or `mid.root` is not a subname ⇒
679
+ // not a sub-subname; fall through to `unrecognized`. A committed
680
+ // sub-subname path either returns a `Resolved` or throws.
681
+ const resolved = await resolveSubSubname(subsub.leaf, subsub.mid, subsub.root, chain, input);
682
+ if (resolved !== null)
683
+ return resolved;
684
+ }
316
685
  const cand = scopedTldCandidate(input);
317
686
  if (cand)
318
687
  return resolveScoped(cand.sub, cand.tld, chain, input);
@@ -402,8 +771,15 @@ export function createResolver(config) {
402
771
  */
403
772
  async function reverse(address) {
404
773
  const owner = decodeBase58_32(address);
405
- if (!owner)
406
- throw new ResolveError("unrecognized", `"${address}" is not an address`, address);
774
+ // `decodeBase58_32` left-pads a short/tiny base58 input into 32 bytes, so a
775
+ // malformed address like "abc" would otherwise slip through as a valid tiny
776
+ // key. Require the canonical round-trip: a malformed address must THROW
777
+ // (matching the dev-api's 400 INVALID_ADDRESS and the docs/drill checklist),
778
+ // reserving the `null` return exclusively for a WELL-FORMED address that
779
+ // genuinely has no primary.
780
+ if (!owner || encodeBase58(owner) !== address) {
781
+ throw new ResolveError("unrecognized", `"${address}" is not a valid X1 address`, address);
782
+ }
407
783
  const handleName = await reverseHandle(owner);
408
784
  if (handleName !== null)
409
785
  return handleName;
package/dist/signin.d.ts CHANGED
@@ -211,9 +211,21 @@ export type SignInVerification = {
211
211
  * wallet signed. Whether that wallet currently owns a handle is a separate,
212
212
  * on-chain question answered by {@link resolveIdentity}.
213
213
  *
214
- * Replay protection is nonce + expiry, and both need the relying party: pass
215
- * `expectedNonce` (and never accept the same nonce twice), and keep the expiry
216
- * window short. The domain binding needs `expectedDomain` to become a check.
214
+ * ⚠️ `expectedDomain` and `expectedNonce` are the anti-phishing and anti-replay
215
+ * checks, and they are OPT-IN. OMITTING THEM DISABLES THAT PROTECTION:
216
+ *
217
+ * - no `expectedDomain` → the domain binding is NOT enforced. A signature a
218
+ * user produced for a phishing site's challenge will verify here. Always pass
219
+ * your own origin's domain in a real login.
220
+ * - no `expectedNonce` → the nonce binding is NOT enforced, so this call cannot
221
+ * tell a fresh proof from a replayed one. Pass the nonce the RP issued, and
222
+ * never accept the same nonce twice (burn it server-side). Keep the expiry
223
+ * window short regardless — expiry alone only bounds the replay window, it
224
+ * does not close it.
225
+ *
226
+ * {@link signInWithX1ID} passes both for you. A bare `verifySignIn` without
227
+ * them emits a one-time dev-mode `console.warn` so the gap is noticed in
228
+ * development; it is silent in production (and whenever `process` is absent).
217
229
  */
218
230
  export declare function verifySignIn(params: VerifySignInParams): Promise<SignInVerification>;
219
231
  /** One live "verified by [platform]" signal on the signed-in handle. */
package/dist/signin.js CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Resolve @handles and X1NS names on X1. Never silently picks between namespaces.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",