@x1id/resolve 0.13.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.
Files changed (2) hide show
  1. package/dist/index.js +198 -9
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -100,8 +100,8 @@ const SUBNAME_RESERVED_SUFFIXES = new Set([
100
100
  * name (owner-approved disambiguation order: subname before scoped-TLD). The
101
101
  * shape is a single interior dot with non-empty halves, a `parent` that is not
102
102
  * a reserved suffix, and a `label` with no further dot (a deeper `a.b.c` is
103
- * sub-subname territory the one-level program does not support → stays
104
- * `unrecognized`). Whether `@parent` is actually a registered handle is a chain
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
105
  * fact the resolver establishes separately. Both halves are normalized for real
106
106
  * at resolve; a parent that cannot be a handle (e.g. > 32 bytes) simply fails
107
107
  * the registration check and falls through to the scoped path.
@@ -116,11 +116,36 @@ function subnameCandidate(input) {
116
116
  const label = t.slice(0, dot);
117
117
  const parent = t.slice(dot + 1);
118
118
  if (label.includes("."))
119
- return null; // a.b.c — a true sub-subname, unsupported
119
+ return null; // a.b.c — a sub-subname (see subSubnameCandidate)
120
120
  if (SUBNAME_RESERVED_SUFFIXES.has(parent))
121
121
  return null; // never conflate with X1NS / external
122
122
  return { label, parent };
123
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
+ }
124
149
  // `Primary` pointer account (`["primary", owner]` under the registry):
125
150
  // disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
126
151
  const PRIMARY_LEN = 81;
@@ -460,6 +485,156 @@ export function createResolver(config) {
460
485
  }
461
486
  return value;
462
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
+ }
463
638
  /** See `Resolver.records`. */
464
639
  async function records(input) {
465
640
  const parsed = parseName(input); // throws with a specific code
@@ -477,12 +652,17 @@ export function createResolver(config) {
477
652
  }
478
653
  catch (e) {
479
654
  // A dotted, non-`@`, non-X1NS name is `unrecognized` to the strict native
480
- // parser — but it MAY be a SUBNAME `label.parent` (#7375) or a scoped
481
- // customer-TLD name `name.tld` (#8484/#8485). Owner-approved
482
- // disambiguation order: try the subname interpretation FIRST (when
483
- // `@parent` is a registered handle), then the scoped path. Each re-throws
484
- // / falls through so `.sol`/`.eth`/unknown TLDs behave exactly as before.
485
- // Any other code (ambiguous / invalid-*) is preserved verbatim.
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
664
+ // `.sol`/`.eth`/unknown TLDs behave exactly as before. Any other code
665
+ // (ambiguous / invalid-*) is preserved verbatim.
486
666
  if (e instanceof ResolveError && e.code === "unrecognized") {
487
667
  const sub = subnameCandidate(input);
488
668
  if (sub) {
@@ -493,6 +673,15 @@ export function createResolver(config) {
493
673
  if (resolved !== null)
494
674
  return resolved;
495
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
+ }
496
685
  const cand = scopedTldCandidate(input);
497
686
  if (cand)
498
687
  return resolveScoped(cand.sub, cand.tld, chain, input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.13.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",