@x1id/resolve 0.13.0 → 0.15.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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * X1ID Connect — the TRANSACTION-signature sign-in client (#8452, verify-tx).
3
+ *
4
+ * # Why a transaction, not a message
5
+ *
6
+ * The message path ({@link import("./signin.js").signInWithX1ID}) asks the
7
+ * wallet to `signMessage` the challenge bytes. Two real wallets break that on
8
+ * X1:
9
+ * - a Ledger (via Phantom/Backpack) REFUSES off-chain message signing; and
10
+ * - Backpack on X1 returns `signMessage` signatures that DO NOT verify under
11
+ * the wallet's own pubkey, even though its TRANSACTION signatures are valid
12
+ * on-chain.
13
+ *
14
+ * So this flow proves wallet control a different, robust way: the wallet signs a
15
+ * throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
16
+ * `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
17
+ * `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
18
+ * it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
19
+ * is the wallet, that the memo binds our single-use nonce, and verifies the
20
+ * ed25519 transaction signature. A valid proof mints the same Bearer session the
21
+ * message path does.
22
+ *
23
+ * This is the WALLET-control (no primary `@handle`) path — the app.x1id.io model
24
+ * where the connected wallet IS the identity, with the `@handle` as optional
25
+ * metadata. A handle-gated sign-in keeps the full on-chain control proof.
26
+ *
27
+ * # web3.js is injected, not depended on
28
+ *
29
+ * `@solana/web3.js` is an OPTIONAL peer dependency of this package (see
30
+ * scoped.ts / universalSns.ts). {@link buildVerifyTxChallenge} therefore takes
31
+ * the `{ Transaction, TransactionInstruction, PublicKey }` constructors as an
32
+ * injected {@link VerifyTxWeb3} rather than importing them — the SDK core stays
33
+ * free of a hard web3 dependency and tests can supply a fake.
34
+ */
35
+ /** Memo prefix the verify-tx proof binds to. The signed memo instruction's
36
+ * UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server rejects any
37
+ * other data, which is what kills replay of an unrelated signed transaction. */
38
+ export declare const VERIFY_TX_MEMO_PREFIX: "x1id-verify:";
39
+ /** Canonical SPL Memo program id — the program the challenge memo rides on. */
40
+ export declare const MEMO_PROGRAM_ID: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
41
+ /** The exact memo string for a given challenge nonce. */
42
+ export declare function verifyTxMemo(nonce: string): string;
43
+ /** The slice of a web3.js `PublicKey` used here. */
44
+ export interface PublicKeyInstance {
45
+ toBytes(): Uint8Array;
46
+ toBase58(): string;
47
+ }
48
+ export interface PublicKeyCtor {
49
+ new (value: string | Uint8Array): PublicKeyInstance;
50
+ }
51
+ /** The slice of a web3.js `TransactionInstruction` constructor used here. */
52
+ export interface TransactionInstructionCtor {
53
+ new (args: {
54
+ keys: ReadonlyArray<{
55
+ pubkey: PublicKeyInstance;
56
+ isSigner: boolean;
57
+ isWritable: boolean;
58
+ }>;
59
+ programId: PublicKeyInstance;
60
+ data: Uint8Array;
61
+ }): unknown;
62
+ }
63
+ /** The slice of a web3.js legacy `Transaction` instance used here. */
64
+ export interface TransactionInstance {
65
+ add(instruction: unknown): TransactionInstance;
66
+ feePayer: PublicKeyInstance | null | undefined;
67
+ recentBlockhash: string | undefined;
68
+ /** Serialize the fully-signed transaction to raw bytes. */
69
+ serialize(options?: unknown): Uint8Array;
70
+ }
71
+ export interface TransactionCtor {
72
+ new (): TransactionInstance;
73
+ }
74
+ /** The three web3.js constructors {@link buildVerifyTxChallenge} needs. Pass the
75
+ * module (`import * as web3 from "@solana/web3.js"`) or a `{ Transaction,
76
+ * TransactionInstruction, PublicKey }` subset — exactly as the scoped deriver
77
+ * takes `web3`. */
78
+ export interface VerifyTxWeb3 {
79
+ readonly Transaction: TransactionCtor;
80
+ readonly TransactionInstruction: TransactionInstructionCtor;
81
+ readonly PublicKey: PublicKeyCtor;
82
+ }
83
+ /** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
84
+ * NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
85
+ * a fresh one can pass its own in {@link BuildVerifyTxChallengeParams.blockhash}. */
86
+ export declare const PLACEHOLDER_BLOCKHASH: "11111111111111111111111111111111";
87
+ export interface BuildVerifyTxChallengeParams {
88
+ /** The single-use nonce from the tx-mode challenge. */
89
+ readonly nonce: string;
90
+ /** The memo the server returned (`x1id-verify:<nonce>`). Optional — derived
91
+ * from `nonce` when omitted; when supplied it MUST equal that, or the build
92
+ * throws (catches a client that paired the wrong nonce/memo). */
93
+ readonly memo?: string;
94
+ /** The signing wallet, base58 — becomes the fee payer AND the memo signer. */
95
+ readonly feePayer: string;
96
+ /** A recent blockhash. Defaults to {@link PLACEHOLDER_BLOCKHASH} (the tx is
97
+ * never submitted). */
98
+ readonly blockhash?: string;
99
+ }
100
+ /**
101
+ * Build the memo-only challenge transaction the wallet signs for verify-tx.
102
+ *
103
+ * The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
104
+ * instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
105
+ * fee payer listed as a required signer on that instruction. Nothing else — no
106
+ * transfer, no compute-budget, no durable nonce — so the signed artifact can
107
+ * only ever be a sign-in proof, never a disguised value-moving transaction. The
108
+ * server enforces the same shape on receipt.
109
+ */
110
+ export declare function buildVerifyTxChallenge(web3: VerifyTxWeb3, params: BuildVerifyTxChallengeParams): TransactionInstance;
111
+ /** Default hosted X1ID /v1 base URL. */
112
+ export declare const DEFAULT_CONNECT_BASE_URL = "https://api.x1id.io";
113
+ export interface ConnectClientConfig {
114
+ /** Your X1ID API key (`x1id_…` / `x1idlive_…`) — identifies the dapp. */
115
+ readonly apiKey: string;
116
+ /** Base URL of the /v1 API. Defaults to {@link DEFAULT_CONNECT_BASE_URL}. */
117
+ readonly baseUrl?: string;
118
+ /** Inject a `fetch` (tests, non-global-fetch runtimes). */
119
+ readonly fetchImpl?: typeof fetch;
120
+ /** Per-request timeout in ms (default 15_000). */
121
+ readonly timeoutMs?: number;
122
+ }
123
+ /** The Bearer session verify-tx mints — identical shape to the message path. */
124
+ export interface ConnectSession {
125
+ readonly token: string;
126
+ readonly token_type: string;
127
+ readonly expires_at: number;
128
+ readonly handle: string | null;
129
+ readonly address: string;
130
+ readonly verified: readonly string[];
131
+ readonly source: string;
132
+ readonly proves: string;
133
+ readonly disclaimer: string;
134
+ }
135
+ /** The tx-mode challenge response (the fields this flow uses). */
136
+ export interface TxChallenge {
137
+ readonly nonce: string;
138
+ readonly memo: string;
139
+ readonly handle: string | null;
140
+ readonly expires_at: number;
141
+ }
142
+ /** Thrown when sign-in could not complete (network, auth, a typed service
143
+ * error, a wallet failure, or tx-mode being unavailable for this wallet). */
144
+ export declare class ConnectError extends Error {
145
+ readonly code: string;
146
+ readonly status?: number | undefined;
147
+ constructor(code: string, message: string, status?: number | undefined);
148
+ }
149
+ export interface SigninWithTxParams {
150
+ /** Injected web3.js constructors (see {@link VerifyTxWeb3}). */
151
+ readonly web3: VerifyTxWeb3;
152
+ /** The connected wallet's address, base58 — the fee payer / identity. */
153
+ readonly wallet: string;
154
+ /**
155
+ * The wallet's transaction signer. Takes the memo transaction built here and
156
+ * returns it fully signed (the wallet's `signTransaction`). The returned
157
+ * object must `serialize()` to the signed transaction bytes.
158
+ */
159
+ readonly signTransaction: (tx: TransactionInstance) => Promise<TransactionInstance>;
160
+ /** Optional recent blockhash; defaults to {@link PLACEHOLDER_BLOCKHASH}. */
161
+ readonly blockhash?: string;
162
+ }
163
+ /**
164
+ * Sign in with a transaction-signature proof of wallet control.
165
+ *
166
+ * Requests a tx-mode challenge, builds the memo transaction, has the caller's
167
+ * wallet sign it, and posts the signed artifact to `POST /v1/auth/verify-tx`,
168
+ * returning the minted session. Throws {@link ConnectError} on any failure.
169
+ *
170
+ * The wallet must have NO primary `@handle` for the tx path (it proves wallet
171
+ * control only); if it has one, the challenge comes back without a memo and this
172
+ * throws `TX_MODE_UNAVAILABLE` (use the handle-gated message path instead).
173
+ */
174
+ export declare function signinWithTx(cfg: ConnectClientConfig, params: SigninWithTxParams): Promise<ConnectSession>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * X1ID Connect — the TRANSACTION-signature sign-in client (#8452, verify-tx).
3
+ *
4
+ * # Why a transaction, not a message
5
+ *
6
+ * The message path ({@link import("./signin.js").signInWithX1ID}) asks the
7
+ * wallet to `signMessage` the challenge bytes. Two real wallets break that on
8
+ * X1:
9
+ * - a Ledger (via Phantom/Backpack) REFUSES off-chain message signing; and
10
+ * - Backpack on X1 returns `signMessage` signatures that DO NOT verify under
11
+ * the wallet's own pubkey, even though its TRANSACTION signatures are valid
12
+ * on-chain.
13
+ *
14
+ * So this flow proves wallet control a different, robust way: the wallet signs a
15
+ * throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
16
+ * `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
17
+ * `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
18
+ * it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
19
+ * is the wallet, that the memo binds our single-use nonce, and verifies the
20
+ * ed25519 transaction signature. A valid proof mints the same Bearer session the
21
+ * message path does.
22
+ *
23
+ * This is the WALLET-control (no primary `@handle`) path — the app.x1id.io model
24
+ * where the connected wallet IS the identity, with the `@handle` as optional
25
+ * metadata. A handle-gated sign-in keeps the full on-chain control proof.
26
+ *
27
+ * # web3.js is injected, not depended on
28
+ *
29
+ * `@solana/web3.js` is an OPTIONAL peer dependency of this package (see
30
+ * scoped.ts / universalSns.ts). {@link buildVerifyTxChallenge} therefore takes
31
+ * the `{ Transaction, TransactionInstruction, PublicKey }` constructors as an
32
+ * injected {@link VerifyTxWeb3} rather than importing them — the SDK core stays
33
+ * free of a hard web3 dependency and tests can supply a fake.
34
+ */
35
+ /** Memo prefix the verify-tx proof binds to. The signed memo instruction's
36
+ * UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server rejects any
37
+ * other data, which is what kills replay of an unrelated signed transaction. */
38
+ export const VERIFY_TX_MEMO_PREFIX = "x1id-verify:";
39
+ /** Canonical SPL Memo program id — the program the challenge memo rides on. */
40
+ export const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
41
+ /** The exact memo string for a given challenge nonce. */
42
+ export function verifyTxMemo(nonce) {
43
+ return VERIFY_TX_MEMO_PREFIX + nonce;
44
+ }
45
+ /** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
46
+ * NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
47
+ * a fresh one can pass its own in {@link BuildVerifyTxChallengeParams.blockhash}. */
48
+ export const PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111";
49
+ /**
50
+ * Build the memo-only challenge transaction the wallet signs for verify-tx.
51
+ *
52
+ * The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
53
+ * instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
54
+ * fee payer listed as a required signer on that instruction. Nothing else — no
55
+ * transfer, no compute-budget, no durable nonce — so the signed artifact can
56
+ * only ever be a sign-in proof, never a disguised value-moving transaction. The
57
+ * server enforces the same shape on receipt.
58
+ */
59
+ export function buildVerifyTxChallenge(web3, params) {
60
+ const { nonce, feePayer, blockhash } = params;
61
+ if (typeof nonce !== "string" || nonce.length === 0)
62
+ throw new Error("nonce is required");
63
+ if (typeof feePayer !== "string" || feePayer.length === 0)
64
+ throw new Error("feePayer is required");
65
+ const memo = params.memo ?? verifyTxMemo(nonce);
66
+ if (memo !== verifyTxMemo(nonce)) {
67
+ throw new Error(`memo must be exactly "${verifyTxMemo(nonce)}" for this nonce`);
68
+ }
69
+ const payer = new web3.PublicKey(feePayer);
70
+ const memoProgram = new web3.PublicKey(MEMO_PROGRAM_ID);
71
+ const ix = new web3.TransactionInstruction({
72
+ // The signer must appear in the instruction keys for the memo to be a
73
+ // "signed memo" and for the wallet to require THIS key's signature.
74
+ keys: [{ pubkey: payer, isSigner: true, isWritable: false }],
75
+ programId: memoProgram,
76
+ data: new TextEncoder().encode(memo),
77
+ });
78
+ const tx = new web3.Transaction();
79
+ tx.add(ix);
80
+ tx.feePayer = payer;
81
+ tx.recentBlockhash = blockhash ?? PLACEHOLDER_BLOCKHASH;
82
+ return tx;
83
+ }
84
+ // -----------------------------------------------------------------------------
85
+ // signinWithTx — one call: tx-mode challenge -> sign -> verify-tx -> session
86
+ // -----------------------------------------------------------------------------
87
+ /** Default hosted X1ID /v1 base URL. */
88
+ export const DEFAULT_CONNECT_BASE_URL = "https://api.x1id.io";
89
+ /** Thrown when sign-in could not complete (network, auth, a typed service
90
+ * error, a wallet failure, or tx-mode being unavailable for this wallet). */
91
+ export class ConnectError extends Error {
92
+ code;
93
+ status;
94
+ constructor(code, message, status) {
95
+ super(message);
96
+ this.code = code;
97
+ this.status = status;
98
+ this.name = "ConnectError";
99
+ }
100
+ }
101
+ function baseUrlOf(cfg) {
102
+ return (cfg.baseUrl ?? DEFAULT_CONNECT_BASE_URL).replace(/\/+$/, "");
103
+ }
104
+ function bytesToBase64(bytes) {
105
+ let bin = "";
106
+ const CHUNK = 0x8000;
107
+ for (let i = 0; i < bytes.length; i += CHUNK) {
108
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
109
+ }
110
+ // `btoa` is available in browsers and Node 18+ (the SDK's floor). register.ts
111
+ // already relies on the sibling `atob`.
112
+ return btoa(bin);
113
+ }
114
+ async function postJson(cfg, path, body) {
115
+ const doFetch = cfg.fetchImpl ?? globalThis.fetch;
116
+ if (typeof doFetch !== "function")
117
+ throw new ConnectError("NO_FETCH", "no fetch available; pass fetchImpl in the config");
118
+ if (!cfg.apiKey)
119
+ throw new ConnectError("NO_API_KEY", "an X1ID apiKey is required");
120
+ const controller = new AbortController();
121
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 15_000);
122
+ let res;
123
+ try {
124
+ res = await doFetch(`${baseUrlOf(cfg)}${path}`, {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json", "x-api-key": cfg.apiKey },
127
+ body: JSON.stringify(body),
128
+ signal: controller.signal,
129
+ });
130
+ }
131
+ catch (e) {
132
+ throw new ConnectError("REQUEST_FAILED", `request to ${path} failed: ${e instanceof Error ? e.message : String(e)}`);
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ let parsed;
138
+ try {
139
+ parsed = await res.json();
140
+ }
141
+ catch {
142
+ throw new ConnectError("BAD_RESPONSE", `${path} returned non-JSON (HTTP ${res.status})`, res.status);
143
+ }
144
+ if (!res.ok) {
145
+ const b = (parsed ?? {});
146
+ throw new ConnectError(b.code ?? "HTTP_ERROR", b.message ?? `${path} returned HTTP ${res.status}`, res.status);
147
+ }
148
+ return parsed;
149
+ }
150
+ /**
151
+ * Sign in with a transaction-signature proof of wallet control.
152
+ *
153
+ * Requests a tx-mode challenge, builds the memo transaction, has the caller's
154
+ * wallet sign it, and posts the signed artifact to `POST /v1/auth/verify-tx`,
155
+ * returning the minted session. Throws {@link ConnectError} on any failure.
156
+ *
157
+ * The wallet must have NO primary `@handle` for the tx path (it proves wallet
158
+ * control only); if it has one, the challenge comes back without a memo and this
159
+ * throws `TX_MODE_UNAVAILABLE` (use the handle-gated message path instead).
160
+ */
161
+ export async function signinWithTx(cfg, params) {
162
+ const challenge = await postJson(cfg, "/v1/auth/challenge", {
163
+ wallet: params.wallet,
164
+ mode: "tx",
165
+ });
166
+ if (!challenge.memo || !challenge.nonce) {
167
+ throw new ConnectError("TX_MODE_UNAVAILABLE", "the transaction sign-in path is for a wallet with no primary @handle; this wallet returned no tx memo");
168
+ }
169
+ const tx = buildVerifyTxChallenge(params.web3, {
170
+ nonce: challenge.nonce,
171
+ memo: challenge.memo,
172
+ feePayer: params.wallet,
173
+ ...(params.blockhash !== undefined ? { blockhash: params.blockhash } : {}),
174
+ });
175
+ let signed;
176
+ try {
177
+ signed = await params.signTransaction(tx);
178
+ }
179
+ catch (e) {
180
+ throw new ConnectError("WALLET_ERROR", `wallet failed to sign the challenge transaction: ${String(e)}`, undefined);
181
+ }
182
+ let signedTransaction;
183
+ try {
184
+ signedTransaction = bytesToBase64(signed.serialize());
185
+ }
186
+ catch (e) {
187
+ throw new ConnectError("WALLET_ERROR", `could not serialize the signed transaction: ${String(e)}`);
188
+ }
189
+ return postJson(cfg, "/v1/auth/verify-tx", {
190
+ nonce: challenge.nonce,
191
+ signedTransaction,
192
+ });
193
+ }
package/dist/index.d.ts CHANGED
@@ -51,6 +51,7 @@ export * from "./clearRecords.js";
51
51
  export * from "./commitReveal.js";
52
52
  export * from "./pnftTransfer.js";
53
53
  export * from "./signin.js";
54
+ export * from "./connect.js";
54
55
  export * from "./domainProof.js";
55
56
  export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, type ScopedCandidate, type ScopedHandleDeriver, } from "./scoped.js";
56
57
  import { type Chain, type Resolved } from "./types.js";
package/dist/index.js CHANGED
@@ -58,6 +58,7 @@ export * from "./clearRecords.js";
58
58
  export * from "./commitReveal.js";
59
59
  export * from "./pnftTransfer.js";
60
60
  export * from "./signin.js";
61
+ export * from "./connect.js";
61
62
  export * from "./domainProof.js";
62
63
  // Named (not `export *`): `NAMESPACE_MAX_LEN` is already exported by
63
64
  // adminMarket.js, and two `export *` sharing a name would make it ambiguous
@@ -100,8 +101,8 @@ const SUBNAME_RESERVED_SUFFIXES = new Set([
100
101
  * name (owner-approved disambiguation order: subname before scoped-TLD). The
101
102
  * shape is a single interior dot with non-empty halves, a `parent` that is not
102
103
  * 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
104
+ * sub-subname territory handled by {@link subSubnameCandidate}, NOT a one-level
105
+ * subname). Whether `@parent` is actually a registered handle is a chain
105
106
  * fact the resolver establishes separately. Both halves are normalized for real
106
107
  * at resolve; a parent that cannot be a handle (e.g. > 32 bytes) simply fails
107
108
  * the registration check and falls through to the scoped path.
@@ -116,11 +117,36 @@ function subnameCandidate(input) {
116
117
  const label = t.slice(0, dot);
117
118
  const parent = t.slice(dot + 1);
118
119
  if (label.includes("."))
119
- return null; // a.b.c — a true sub-subname, unsupported
120
+ return null; // a.b.c — a sub-subname (see subSubnameCandidate)
120
121
  if (SUBNAME_RESERVED_SUFFIXES.has(parent))
121
122
  return null; // never conflate with X1NS / external
122
123
  return { label, parent };
123
124
  }
125
+ /**
126
+ * Classify `input` as a `leaf.mid.root` SUB-SUBNAME candidate by SHAPE ONLY (no
127
+ * chain read) — a THIRD naming level (#7375 two-level), `deep.pay.jack`: a
128
+ * sub-subname `leaf` under a subname `mid.root` under the handle `@root`. Shape:
129
+ * EXACTLY three dot-separated non-empty segments, no leading `@`, and a `root`
130
+ * (last) segment that is not a reserved suffix (so `a.b.sol`/`a.b.x1` stay with
131
+ * the X1NS / universal path, never a native sub-subname). FOUR or more segments
132
+ * → `null` (the program is two levels deep only). Whether `@root` is a
133
+ * registered handle and `mid.root` a live subname are chain facts the resolver
134
+ * establishes separately ({@link Resolver.resolve}'s `resolveSubSubname`).
135
+ */
136
+ function subSubnameCandidate(input) {
137
+ const t = input.trim().toLowerCase();
138
+ if (!t || t.startsWith("@"))
139
+ return null;
140
+ const parts = t.split(".");
141
+ if (parts.length !== 3)
142
+ return null; // exactly three segments; 4+ → not a sub-subname
143
+ const [leaf, mid, root] = parts;
144
+ if (!leaf || !mid || !root)
145
+ return null; // no empty segment (e.g. `a..c`, `.a.b`)
146
+ if (SUBNAME_RESERVED_SUFFIXES.has(root))
147
+ return null; // never conflate with X1NS / external
148
+ return { leaf, mid, root };
149
+ }
124
150
  // `Primary` pointer account (`["primary", owner]` under the registry):
125
151
  // disc(8) | owner(32) | handle(32) | set_at(i64 LE, 8) | bump(1) = 81 bytes
126
152
  const PRIMARY_LEN = 81;
@@ -460,6 +486,156 @@ export function createResolver(config) {
460
486
  }
461
487
  return value;
462
488
  }
489
+ /**
490
+ * Resolve a `leaf.mid.root` SUB-SUBNAME (#7375 two-level, owner decision
491
+ * 2026-09-24) — a sub-subname `leaf` under a subname `mid.root` under the
492
+ * handle `@root`, e.g. `deep.pay.jack`. Called ONLY for an exactly-3-segment,
493
+ * non-`@`, non-X1NS input (the native parser's `unrecognized` fall-through).
494
+ *
495
+ * Returns `null` — the signal to fall through to `unrecognized` — when the
496
+ * input was never a sub-subname at all: `@root` is NOT a registered handle,
497
+ * or there is no `mid` subname account under it. Those cases are "this 3-dot
498
+ * name is not one of ours", not an error. Once the `mid` subname ACCOUNT
499
+ * exists we are COMMITTED: every outcome is a `Resolved` or a throw
500
+ * (`not-found` / `no-record-for-chain`), never a fall-through and never a
501
+ * wrong address.
502
+ *
503
+ * A sub-subname has NO owner (like every subname); it resolves PURELY through
504
+ * its per-chain `Record`s, EVERY chain included. ALL THREE staleness layers of
505
+ * docs/record-trust.md (generalized to three levels — see app's subSubname.ts)
506
+ * are enforced:
507
+ * 1. `mid.createdAt >= root.registeredAt` — the level-2 subname belongs to
508
+ * the handle's CURRENT owner (the subname scan's `.live` flag). A `mid`
509
+ * stranded by a parent transfer/re-registration → `not-found`.
510
+ * 2. `leaf.createdAt >= mid.createdAt` — the sub-subname belongs to the
511
+ * CURRENT incarnation of its immediate parent subname (that PDA is reused
512
+ * across revoke + re-create). The second scan's `.live` flag, re-checked
513
+ * inside `resolveSubnameRecords`.
514
+ * 3. `record.updatedAt >= leaf.createdAt` — the record belongs to the
515
+ * CURRENT incarnation of the sub-subname itself (ordinary record staleness
516
+ * with the leaf's `createdAt` as the epoch).
517
+ * Nothing is hand-derived: both levels are located by the program-scoped
518
+ * `getProgramAccounts` scan (`fetchSubnames`, which IS the ownership check),
519
+ * the mid subname's pubkey standing in for the parent at the lower level — the
520
+ * same account type and formula, one level deeper.
521
+ */
522
+ async function resolveSubSubname(leafRaw, midRaw, rootRaw, chain, input) {
523
+ // The root half must canonicalize as a handle; if it cannot, `@root` can
524
+ // never be a registered handle → not a sub-subname, fall through.
525
+ let rootCanonical;
526
+ try {
527
+ rootCanonical = normalizeHandle(rootRaw);
528
+ }
529
+ catch {
530
+ return null;
531
+ }
532
+ // The mid + leaf halves must also canonicalize as handle labels. Defer a
533
+ // non-canonical verdict until the branch (mid-account existence) is decided,
534
+ // exactly as `resolveSubname` defers its `subLabel`.
535
+ let midLabel = null;
536
+ let leafLabel = null;
537
+ try {
538
+ midLabel = normalizeHandle(midRaw);
539
+ }
540
+ catch {
541
+ midLabel = null;
542
+ }
543
+ try {
544
+ leafLabel = normalizeHandle(leafRaw);
545
+ }
546
+ catch {
547
+ leafLabel = null;
548
+ }
549
+ if (midLabel !== null && leafLabel !== null && ttl > 0) {
550
+ const hit = cache.get(`subsubname:${rootCanonical}:${midLabel}:${leafLabel}:${chain}`);
551
+ if (hit && hit.expires > Date.now())
552
+ return hit.value;
553
+ }
554
+ // Is `@root` a REGISTERED handle? If not, `a.b.c` was never a sub-subname.
555
+ // `not-found`/`invalid-handle` → fall through; a real error propagates.
556
+ let root;
557
+ try {
558
+ root = await fetchHandle(rootCanonical, input);
559
+ }
560
+ catch (e) {
561
+ if (e instanceof ResolveError && (e.code === "not-found" || e.code === "invalid-handle")) {
562
+ return null;
563
+ }
564
+ throw e;
565
+ }
566
+ // A non-canonical mid can never match a stored subname label → not a
567
+ // sub-subname (fall through), same as a missing mid account below.
568
+ if (midLabel === null)
569
+ return null;
570
+ // Locate the level-2 (mid) subname under the handle. Its EXISTENCE (a label
571
+ // match) is the COMMIT POINT: a missing mid subname means `a.b.c` names
572
+ // nothing of ours → fall through to `unrecognized`; a mid subname that
573
+ // EXISTS is the point of no return. `fetchSubnames` flags each `.live`
574
+ // against `root.registeredAt` — staleness LAYER 1.
575
+ const subs = await fetchSubnames(rpc, programBase58, root.pda, root.handle.registeredAt);
576
+ const mid = subs.find((s) => s.label === midLabel);
577
+ if (!mid)
578
+ return null; // no such mid subname → unrecognized
579
+ // COMMITTED to the sub-subname interpretation from here.
580
+ const leafDisplay = leafLabel ?? leafRaw.trim().toLowerCase();
581
+ const display = `${leafDisplay}.${midLabel}.${rootCanonical}`;
582
+ const midDisplay = `${midLabel}.${rootCanonical}`;
583
+ // Staleness LAYER 1 — a mid subname stranded by a parent transfer/
584
+ // re-registration belongs to a PREVIOUS owner; nothing under it may resolve.
585
+ if (!mid.live) {
586
+ throw new ResolveError("not-found", `${midDisplay} was created before the current @${rootCanonical} registration — ${display} belongs to a previous owner`, input);
587
+ }
588
+ if (leafLabel === null) {
589
+ throw new ResolveError("not-found", `"${input.trim()}" is not a sub-subname of ${midDisplay}`, input);
590
+ }
591
+ // Locate the level-3 (leaf) sub-subname under the mid subname — the SAME
592
+ // program scan + `Subname` account type, the mid subname's pubkey standing
593
+ // in for the parent and its `createdAt` for `parentRegisteredAt`. The
594
+ // resulting `.live` flag is staleness LAYER 2 (leaf.createdAt >= mid.createdAt).
595
+ const subsubs = await fetchSubnames(rpc, programBase58, mid.account, mid.createdAt);
596
+ const leaf = subsubs.find((s) => s.label === leafLabel);
597
+ if (!leaf) {
598
+ throw new ResolveError("not-found", `${display} is not a sub-subname of ${midDisplay}`, input);
599
+ }
600
+ // Staleness LAYER 2 — a leaf stranded by its parent subname being revoked +
601
+ // re-created belongs to a previous incarnation and must never resolve.
602
+ if (!leaf.live) {
603
+ throw new ResolveError("not-found", `${display} was created before the current ${midDisplay} subname — it belongs to a previous incarnation`, input);
604
+ }
605
+ // Read the leaf's LIVE records. `resolveSubnameRecords` re-applies LAYER 2
606
+ // (leaf.createdAt >= mid.createdAt) AND LAYER 3 (record.updatedAt >=
607
+ // leaf.createdAt), dropping any stale record — the only safe entry point.
608
+ const leafSubname = {
609
+ parent: leaf.parent,
610
+ label: leaf.label,
611
+ createdAt: leaf.createdAt,
612
+ bump: leaf.bump,
613
+ };
614
+ const records = await resolveSubnameRecords(rpc, programBase58, leaf.account, leafSubname, mid.createdAt);
615
+ const record = records.find((r) => !r.stale && r.coinType === CHAIN_COIN_TYPE[chain]);
616
+ if (!record) {
617
+ throw new ResolveError("no-record-for-chain", `${display} has no ${chain} record`, input);
618
+ }
619
+ const value = {
620
+ input,
621
+ name: display,
622
+ // A sub-subname is its own namespace — never "handle", "subname", or the
623
+ // root's — so a consumer branching on the namespace treats it distinctly.
624
+ namespace: "sub-subname",
625
+ address: record.address,
626
+ chain,
627
+ // Like a subname record: `unverified` unless the record itself is
628
+ // co-signed (sub-subname records are always unverified on chain in v1).
629
+ verification: record.verified ? "verified" : "unverified",
630
+ };
631
+ if (ttl > 0) {
632
+ cache.set(`subsubname:${rootCanonical}:${midLabel}:${leafLabel}:${chain}`, {
633
+ value,
634
+ expires: Date.now() + ttl,
635
+ });
636
+ }
637
+ return value;
638
+ }
463
639
  /** See `Resolver.records`. */
464
640
  async function records(input) {
465
641
  const parsed = parseName(input); // throws with a specific code
@@ -477,12 +653,17 @@ export function createResolver(config) {
477
653
  }
478
654
  catch (e) {
479
655
  // 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.
656
+ // parser — but it MAY be a one-level SUBNAME `label.parent` (#7375), a
657
+ // two-level SUB-SUBNAME `leaf.mid.root` (#7375 two-level, 3 segments), or
658
+ // a scoped customer-TLD name `name.tld` (#8484/#8485). Owner-approved
659
+ // disambiguation order: the subname interpretation FIRST (when `@parent`
660
+ // is a registered handle), then the sub-subname interpretation (when
661
+ // `@root` is a registered handle AND `mid.root` an existing subname), then
662
+ // the scoped path. `subnameCandidate`/`scopedTldCandidate` are both
663
+ // 2-segment-only and `subSubnameCandidate` is 3-segment-only, so each
664
+ // input matches at most one; each re-throws / falls through so
665
+ // `.sol`/`.eth`/unknown TLDs behave exactly as before. Any other code
666
+ // (ambiguous / invalid-*) is preserved verbatim.
486
667
  if (e instanceof ResolveError && e.code === "unrecognized") {
487
668
  const sub = subnameCandidate(input);
488
669
  if (sub) {
@@ -493,6 +674,15 @@ export function createResolver(config) {
493
674
  if (resolved !== null)
494
675
  return resolved;
495
676
  }
677
+ const subsub = subSubnameCandidate(input);
678
+ if (subsub) {
679
+ // `null` ⇒ `@root` is not a handle, or `mid.root` is not a subname ⇒
680
+ // not a sub-subname; fall through to `unrecognized`. A committed
681
+ // sub-subname path either returns a `Resolved` or throws.
682
+ const resolved = await resolveSubSubname(subsub.leaf, subsub.mid, subsub.root, chain, input);
683
+ if (resolved !== null)
684
+ return resolved;
685
+ }
496
686
  const cand = scopedTldCandidate(input);
497
687
  if (cand)
498
688
  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.15.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",