@x1id/resolve 0.11.0 → 0.12.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.
@@ -90,6 +90,14 @@ export const ATTESTATION_KIND_TELEGRAM = 5;
90
90
  * `>=` this on-chain; the SDK's builder enforces the same bound. Mirrors
91
91
  * `Attestation::KIND_COUNT` (programs/x1-handles/src/state.rs). */
92
92
  export const ATTESTATION_KIND_COUNT = 6;
93
+ /** `Attestation.kind` — validator control proven TRUSTLESSLY on-chain (#8486):
94
+ * the vote account's `authorized_withdrawer` co-signed `attest_validator`, or
95
+ * signed the challenge that `attest_validator_signed` verifies via the Ed25519
96
+ * precompile. Value 6 — equal to {@link ATTESTATION_KIND_COUNT}, i.e. OUTSIDE
97
+ * the attestor-mintable range (`create_attestation` refuses `>= KIND_COUNT`),
98
+ * so only the cryptographic validator path can mint it. `evidenceHash` holds
99
+ * the vote account pubkey; surfaced as a gold shield, not the blue tick. */
100
+ export const ATTESTATION_KIND_VALIDATOR = 6;
93
101
  /** Anchor account discriminator: `sha256("account:Attestation")[0..8]`.
94
102
  * Pinned (this SDK is zero-dependency and cannot assume WebCrypto SHA-256
95
103
  * everywhere it runs); asserted against a re-derivation in the test suite
@@ -113,6 +121,22 @@ export const CREATE_ATTESTATION_DISCRIMINATOR = Uint8Array.from([
113
121
  export const CLOSE_ATTESTATION_DISCRIMINATOR = Uint8Array.from([
114
122
  249, 84, 133, 23, 48, 175, 252, 221,
115
123
  ]);
124
+ /** Anchor instruction discriminator: `sha256("global:attest_validator")[0..8]` — the co-sign path. */
125
+ export const ATTEST_VALIDATOR_DISCRIMINATOR = Uint8Array.from([
126
+ 170, 159, 195, 248, 59, 130, 28, 168,
127
+ ]);
128
+ /** Anchor instruction discriminator: `sha256("global:attest_validator_signed")[0..8]` — the detached path. */
129
+ export const ATTEST_VALIDATOR_SIGNED_DISCRIMINATOR = Uint8Array.from([
130
+ 160, 18, 26, 117, 233, 252, 230, 221,
131
+ ]);
132
+ /** The SVM Ed25519 signature-verification precompile — carries the withdraw
133
+ * authority's detached signature for `attest_validator_signed`. */
134
+ export const ED25519_PROGRAM_ID = "Ed25519SigVerify111111111111111111111111111";
135
+ /** The Instructions sysvar — `attest_validator_signed` introspects it. */
136
+ export const SYSVAR_INSTRUCTIONS_ID = "Sysvar1nstructions1111111111111111111111111";
137
+ /** Domain-separator prefix of the validator-attestation challenge. MUST byte-
138
+ * match `VALIDATOR_ATTEST_CHALLENGE_PREFIX` in programs/x1-handles/src/lib.rs. */
139
+ export const VALIDATOR_ATTEST_CHALLENGE_PREFIX = new TextEncoder().encode("x1id:validator-attest:v1");
116
140
  /** `Attestation` account size — every field is fixed-width, so unlike a
117
141
  * `Handle` the account is exactly this long:
118
142
  * disc(8) + handle(32) + kind(1) + evidence_hash(32) + attested_at(8)
@@ -157,6 +181,8 @@ export function attestationKindName(kind) {
157
181
  return "github";
158
182
  case ATTESTATION_KIND_TELEGRAM:
159
183
  return "telegram";
184
+ case ATTESTATION_KIND_VALIDATOR:
185
+ return "validator";
160
186
  default:
161
187
  return null;
162
188
  }
@@ -177,6 +203,8 @@ export function platformSlug(kind) {
177
203
  return "github";
178
204
  case ATTESTATION_KIND_TELEGRAM:
179
205
  return "telegram";
206
+ case ATTESTATION_KIND_VALIDATOR:
207
+ return "validator";
180
208
  default:
181
209
  return null;
182
210
  }
@@ -365,6 +393,102 @@ export function buildCloseAttestationIx(p) {
365
393
  data: CLOSE_ATTESTATION_DISCRIMINATOR.slice(),
366
394
  };
367
395
  }
396
+ /**
397
+ * Build `attest_validator` (co-sign path). Two signers — `owner` (authorizes
398
+ * the link) and `withdrawAuthority` (the proof) — which MAY be the same key. No
399
+ * instruction args: the co-signature IS the proof.
400
+ */
401
+ export function buildAttestValidatorIx(p) {
402
+ const keys = [
403
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: true },
404
+ { pubkey: toBase58(p.withdrawAuthority, "withdrawAuthority"), isSigner: true, isWritable: false },
405
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
406
+ { pubkey: toBase58(p.voteAccount, "voteAccount"), isSigner: false, isWritable: false },
407
+ { pubkey: toBase58(p.attestation, "attestation"), isSigner: false, isWritable: true },
408
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
409
+ ];
410
+ if (p.holderTokenAccount !== undefined) {
411
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
412
+ }
413
+ return { programId: toBase58(p.programId, "programId"), keys, data: ATTEST_VALIDATOR_DISCRIMINATOR.slice() };
414
+ }
415
+ /**
416
+ * The exact 96-byte challenge the withdraw authority signs for the DETACHED
417
+ * path: `PREFIX(24) ‖ handle(32) ‖ voteAccount(32) ‖ registeredAt i64 LE(8)`.
418
+ * MUST byte-match the program. `registeredAt` is the handle's on-chain
419
+ * `registered_at` (seconds) — pass the exact value from the Handle account.
420
+ */
421
+ export function validatorAttestChallenge(handle, voteAccount, registeredAt) {
422
+ const out = new Uint8Array(24 + 32 + 32 + 8);
423
+ out.set(VALIDATOR_ATTEST_CHALLENGE_PREFIX, 0);
424
+ out.set(toBytes32(handle, "handle"), 24);
425
+ out.set(toBytes32(voteAccount, "voteAccount"), 56);
426
+ new DataView(out.buffer, out.byteOffset, out.byteLength).setBigInt64(88, BigInt(registeredAt), true);
427
+ return out;
428
+ }
429
+ /**
430
+ * Build the Ed25519 precompile instruction (no accounts) proving `signature` is
431
+ * `publicKey`'s over `message`. SELF-CONTAINED single signature — all three
432
+ * instruction-index fields are `0xFFFF` — matching the exact layout
433
+ * `attest_validator_signed` requires. Place it IMMEDIATELY BEFORE the attest ix
434
+ * (use {@link buildAttestValidatorSignedIxs}).
435
+ */
436
+ export function buildEd25519VerifyIx(p) {
437
+ const pk = toBytes32(p.publicKey, "publicKey");
438
+ if (p.signature.length !== 64)
439
+ throw new Error("signature must be exactly 64 bytes");
440
+ const SELF = 0xffff;
441
+ const PK_OFF = 16;
442
+ const SIG_OFF = 48;
443
+ const MSG_OFF = 112;
444
+ const data = new Uint8Array(MSG_OFF + p.message.length);
445
+ data[0] = 1; // num_signatures
446
+ data[1] = 0; // padding
447
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
448
+ dv.setUint16(2, SIG_OFF, true);
449
+ dv.setUint16(4, SELF, true); // signature_instruction_index
450
+ dv.setUint16(6, PK_OFF, true);
451
+ dv.setUint16(8, SELF, true); // public_key_instruction_index
452
+ dv.setUint16(10, MSG_OFF, true);
453
+ dv.setUint16(12, p.message.length, true); // message_data_size
454
+ dv.setUint16(14, SELF, true); // message_instruction_index
455
+ data.set(pk, PK_OFF);
456
+ data.set(p.signature, SIG_OFF);
457
+ data.set(p.message, MSG_OFF);
458
+ return { programId: ED25519_PROGRAM_ID, keys: [], data };
459
+ }
460
+ /**
461
+ * Build the `attest_validator_signed` ix ALONE. The withdraw authority's proof
462
+ * is NOT here — it rides in the Ed25519 precompile ix that MUST immediately
463
+ * precede this one. Prefer {@link buildAttestValidatorSignedIxs}, which returns
464
+ * both, in order. No instruction args.
465
+ */
466
+ export function buildAttestValidatorSignedIx(p) {
467
+ const keys = [
468
+ { pubkey: toBase58(p.owner, "owner"), isSigner: true, isWritable: true },
469
+ { pubkey: toBase58(p.handle, "handle"), isSigner: false, isWritable: false },
470
+ { pubkey: toBase58(p.voteAccount, "voteAccount"), isSigner: false, isWritable: false },
471
+ { pubkey: toBase58(p.attestation, "attestation"), isSigner: false, isWritable: true },
472
+ { pubkey: SYSVAR_INSTRUCTIONS_ID, isSigner: false, isWritable: false },
473
+ { pubkey: SYSTEM_PROGRAM, isSigner: false, isWritable: false },
474
+ ];
475
+ if (p.holderTokenAccount !== undefined) {
476
+ keys.push({ pubkey: toBase58(p.holderTokenAccount, "holderTokenAccount"), isSigner: false, isWritable: false });
477
+ }
478
+ return { programId: toBase58(p.programId, "programId"), keys, data: ATTEST_VALIDATOR_SIGNED_DISCRIMINATOR.slice() };
479
+ }
480
+ /**
481
+ * Build BOTH instructions for the detached path, IN ORDER:
482
+ * `[ed25519VerifyIx, attestValidatorSignedIx]`. Submit them as ONE transaction
483
+ * in this order — the program loads the ed25519 ix at `current_index - 1`.
484
+ * `owner` is the only signer.
485
+ */
486
+ export function buildAttestValidatorSignedIxs(p) {
487
+ const message = validatorAttestChallenge(p.handle, p.voteAccount, p.registeredAt);
488
+ const ed = buildEd25519VerifyIx({ publicKey: p.withdrawAuthority, message, signature: p.signature });
489
+ const attest = buildAttestValidatorSignedIx(p);
490
+ return [ed, attest];
491
+ }
368
492
  /**
369
493
  * The platform slugs a handle is LIVE-verified on — e.g. `["x", "github"]` —
370
494
  * de-duplicated, in first-seen order. Only entries with `verified === true`
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Gate middleware — the drop-in "gate this route" client for X1ID Gate
3
+ * (gating-as-a-service, #8453). A tiny, framework-agnostic helper that calls the
4
+ * hosted `POST /v1/gate/check` decision endpoint (api.x1id.io) so a dapp does not
5
+ * run its own attestation reads. Pairs with the off-chain {@link Gate} /
6
+ * {@link GateKind} this package already exports.
7
+ *
8
+ * Zero-dependency (uses `fetch`); runs anywhere a server runtime has `fetch`
9
+ * (Node 18+, Next route handlers, edge, Workers). It is a SERVER helper — your
10
+ * X1ID API key must never ship to a browser.
11
+ *
12
+ * Trust: a `passed: true` means the handle carries a live, x1id-attestor-signed
13
+ * attestation matching the policy — "verified by x1id", NOT trustless proof and
14
+ * NOT legal personhood. Gate on it accordingly.
15
+ */
16
+ import type { Gate } from "./gate.js";
17
+ /** Default hosted Gate base URL. Override for staging / self-host. */
18
+ export declare const DEFAULT_GATE_BASE_URL = "https://api.x1id.io";
19
+ export interface GateClientConfig {
20
+ /** Your X1ID API key (`x1id_…` / `x1idlive_…`). SERVER-ONLY — never expose it. */
21
+ readonly apiKey: string;
22
+ /** Base URL of the /v1 API. Defaults to {@link DEFAULT_GATE_BASE_URL}. */
23
+ readonly baseUrl?: string;
24
+ /** Inject a `fetch` (tests, non-global-fetch runtimes). */
25
+ readonly fetchImpl?: typeof fetch;
26
+ /** Per-request timeout in ms (default 10_000). */
27
+ readonly timeoutMs?: number;
28
+ }
29
+ /** Exactly one of `handle` / `wallet`. */
30
+ export type GateSubject = {
31
+ readonly handle: string;
32
+ readonly wallet?: never;
33
+ } | {
34
+ readonly wallet: string;
35
+ readonly handle?: never;
36
+ };
37
+ /** A policy to evaluate: an inline {@link Gate}, or a stored policy id (`gp_…`). */
38
+ export type GatePolicy = Gate | string;
39
+ export interface GateAttestationView {
40
+ readonly platform: string;
41
+ readonly kind: number;
42
+ readonly attested_at: number;
43
+ }
44
+ /** The decision the service returned. */
45
+ export interface GateCheckResult {
46
+ readonly passed: boolean;
47
+ readonly subject: {
48
+ readonly handle: string | null;
49
+ readonly wallet: string | null;
50
+ };
51
+ readonly policy: Gate;
52
+ readonly attestations: readonly GateAttestationView[];
53
+ readonly reason?: "no-primary-handle" | "handle-not-registered";
54
+ }
55
+ /** Thrown when the decision could not be obtained (network, auth, 5xx, bad
56
+ * input). NOT thrown for a clean `passed: false` — that is a valid decision. */
57
+ export declare class GateError extends Error {
58
+ readonly code: string;
59
+ readonly status?: number | undefined;
60
+ constructor(code: string, message: string, status?: number | undefined);
61
+ }
62
+ /**
63
+ * Low-level: ask the hosted service whether `subject` passes `policy`. Resolves
64
+ * to the decision (including a clean `passed: false`); throws {@link GateError}
65
+ * only when no decision could be obtained.
66
+ */
67
+ export declare function checkGate(cfg: GateClientConfig, subject: GateSubject, policy: GatePolicy): Promise<GateCheckResult>;
68
+ export interface GuardResult {
69
+ /** Whether the subject passes the policy. */
70
+ readonly allowed: boolean;
71
+ /** The full decision (attestations, reason, …). */
72
+ readonly decision: GateCheckResult;
73
+ }
74
+ /**
75
+ * Build a reusable guard for one policy. The returned function takes a subject
76
+ * and resolves to `{ allowed, decision }`.
77
+ *
78
+ * Fail-closed by default: if the decision cannot be obtained (network/5xx), the
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.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * import { requireGate, GateKind } from "@x1id/resolve";
85
+ * const gate = requireGate({ apiKey: process.env.X1ID_API_KEY! }, { kind: GateKind.github });
86
+ *
87
+ * // Next.js Route Handler
88
+ * export async function POST(req: Request) {
89
+ * const { handle } = await req.json();
90
+ * const { allowed } = await gate({ handle });
91
+ * if (!allowed) return Response.json({ error: "verified GitHub required" }, { status: 403 });
92
+ * // …proceed
93
+ * }
94
+ * ```
95
+ */
96
+ export declare function requireGate(cfg: GateClientConfig, policy: GatePolicy, opts?: {
97
+ readonly onError?: "throw" | "allow" | "deny";
98
+ }): (subject: GateSubject) => Promise<GuardResult>;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Gate middleware — the drop-in "gate this route" client for X1ID Gate
3
+ * (gating-as-a-service, #8453). A tiny, framework-agnostic helper that calls the
4
+ * hosted `POST /v1/gate/check` decision endpoint (api.x1id.io) so a dapp does not
5
+ * run its own attestation reads. Pairs with the off-chain {@link Gate} /
6
+ * {@link GateKind} this package already exports.
7
+ *
8
+ * Zero-dependency (uses `fetch`); runs anywhere a server runtime has `fetch`
9
+ * (Node 18+, Next route handlers, edge, Workers). It is a SERVER helper — your
10
+ * X1ID API key must never ship to a browser.
11
+ *
12
+ * Trust: a `passed: true` means the handle carries a live, x1id-attestor-signed
13
+ * attestation matching the policy — "verified by x1id", NOT trustless proof and
14
+ * NOT legal personhood. Gate on it accordingly.
15
+ */
16
+ /** Default hosted Gate base URL. Override for staging / self-host. */
17
+ export const DEFAULT_GATE_BASE_URL = "https://api.x1id.io";
18
+ /** Thrown when the decision could not be obtained (network, auth, 5xx, bad
19
+ * input). NOT thrown for a clean `passed: false` — that is a valid decision. */
20
+ export class GateError extends Error {
21
+ code;
22
+ status;
23
+ constructor(code, message, status) {
24
+ super(message);
25
+ this.code = code;
26
+ this.status = status;
27
+ this.name = "GateError";
28
+ }
29
+ }
30
+ function baseUrlOf(cfg) {
31
+ return (cfg.baseUrl ?? DEFAULT_GATE_BASE_URL).replace(/\/+$/, "");
32
+ }
33
+ /**
34
+ * Low-level: ask the hosted service whether `subject` passes `policy`. Resolves
35
+ * to the decision (including a clean `passed: false`); throws {@link GateError}
36
+ * only when no decision could be obtained.
37
+ */
38
+ export async function checkGate(cfg, subject, policy) {
39
+ const doFetch = cfg.fetchImpl ?? globalThis.fetch;
40
+ if (typeof doFetch !== "function")
41
+ throw new GateError("NO_FETCH", "no fetch available; pass fetchImpl in the config");
42
+ if (!cfg.apiKey)
43
+ throw new GateError("NO_API_KEY", "an X1ID apiKey is required");
44
+ const url = `${baseUrlOf(cfg)}/v1/gate/check`;
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 10_000);
47
+ let res;
48
+ try {
49
+ res = await doFetch(url, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json", "x-api-key": cfg.apiKey },
52
+ body: JSON.stringify({ ...subject, policy }),
53
+ signal: controller.signal,
54
+ });
55
+ }
56
+ catch (e) {
57
+ throw new GateError("REQUEST_FAILED", `gate request failed: ${e instanceof Error ? e.message : String(e)}`);
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ let body;
63
+ try {
64
+ body = await res.json();
65
+ }
66
+ catch {
67
+ throw new GateError("BAD_RESPONSE", `gate returned non-JSON (HTTP ${res.status})`, res.status);
68
+ }
69
+ if (!res.ok) {
70
+ const b = (body ?? {});
71
+ throw new GateError(b.code ?? "GATE_HTTP_ERROR", b.message ?? `gate returned HTTP ${res.status}`, res.status);
72
+ }
73
+ return body;
74
+ }
75
+ /**
76
+ * Build a reusable guard for one policy. The returned function takes a subject
77
+ * and resolves to `{ allowed, decision }`.
78
+ *
79
+ * Fail-closed by default: if the decision cannot be obtained (network/5xx), the
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.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import { requireGate, GateKind } from "@x1id/resolve";
86
+ * const gate = requireGate({ apiKey: process.env.X1ID_API_KEY! }, { kind: GateKind.github });
87
+ *
88
+ * // Next.js Route Handler
89
+ * export async function POST(req: Request) {
90
+ * const { handle } = await req.json();
91
+ * const { allowed } = await gate({ handle });
92
+ * if (!allowed) return Response.json({ error: "verified GitHub required" }, { status: 403 });
93
+ * // …proceed
94
+ * }
95
+ * ```
96
+ */
97
+ export function requireGate(cfg, policy, opts = {}) {
98
+ const onError = opts.onError ?? "throw";
99
+ return async (subject) => {
100
+ try {
101
+ const decision = await checkGate(cfg, subject, policy);
102
+ return { allowed: decision.passed, decision };
103
+ }
104
+ catch (e) {
105
+ if (onError === "throw")
106
+ throw e;
107
+ const handle = "handle" in subject && subject.handle ? subject.handle : null;
108
+ const wallet = "wallet" in subject && subject.wallet ? subject.wallet : null;
109
+ const decision = {
110
+ passed: onError === "allow",
111
+ subject: { handle, wallet },
112
+ policy: policy,
113
+ attestations: [],
114
+ };
115
+ return { allowed: onError === "allow", decision };
116
+ }
117
+ };
118
+ }
package/dist/index.d.ts CHANGED
@@ -35,12 +35,14 @@ export * from "./subname.js";
35
35
  export * from "./recordCount.js";
36
36
  export * from "./attestation.js";
37
37
  export * from "./gate.js";
38
+ export * from "./gateClient.js";
38
39
  export * from "./textRecords.js";
39
40
  export * from "./lock.js";
40
41
  export * from "./integrator.js";
41
42
  export * from "./voucher.js";
42
43
  export * from "./agent.js";
43
44
  export * from "./register.js";
45
+ export * from "./adminConfig.js";
44
46
  export * from "./lease.js";
45
47
  export * from "./accounts.js";
46
48
  export * from "./x402.js";
@@ -50,7 +52,9 @@ export * from "./commitReveal.js";
50
52
  export * from "./pnftTransfer.js";
51
53
  export * from "./signin.js";
52
54
  export * from "./domainProof.js";
55
+ export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, type ScopedCandidate, type ScopedHandleDeriver, } from "./scoped.js";
53
56
  import { type Chain, type Resolved } from "./types.js";
57
+ import { type ScopedHandleDeriver } from "./scoped.js";
54
58
  import { WasmResolver } from "./wasm.js";
55
59
  import { type HandleRecord } from "./records.js";
56
60
  export interface ResolverConfig {
@@ -69,6 +73,18 @@ export interface ResolverConfig {
69
73
  readonly cacheTtlMs?: number;
70
74
  /** @handle registry program id. Defaults to the canonical X1 deployment. */
71
75
  readonly handleProgramId?: string;
76
+ /**
77
+ * Deriver for a SCOPED customer-TLD name's `["handle", tld, name]` PDA
78
+ * (`alices.testtld`, #8484/#8485). Optional and only used for scoped names:
79
+ * `@handle` and X1NS resolution never touch it. The WASM module has no
80
+ * two-seed scoped-handle derivation and this package never hand-rolls the
81
+ * on-curve check (see scoped.ts), so a scoped name is resolvable only when a
82
+ * deriver is injected — build the default with
83
+ * {@link makeScopedHandleDeriver} (needs `@solana/web3.js`), or pass a mock.
84
+ * Absent → a scoped name whose `.tld` IS a launched namespace throws
85
+ * `not-configured`; unlaunched `.tld`s stay `unrecognized`, unchanged.
86
+ */
87
+ readonly scopedHandleDeriver?: ScopedHandleDeriver;
72
88
  }
73
89
  export interface ResolveOptions {
74
90
  /** Which chain's address to return. Default "X1". */
@@ -103,3 +119,6 @@ export interface Resolver {
103
119
  clearCache(): void;
104
120
  }
105
121
  export declare function createResolver(config: ResolverConfig): Resolver;
122
+ export * from "./adminMarket.js";
123
+ export * from "./namespaceOverride.js";
124
+ export * from "./universal.js";
package/dist/index.js CHANGED
@@ -35,12 +35,14 @@ export * from "./subname.js";
35
35
  export * from "./recordCount.js";
36
36
  export * from "./attestation.js";
37
37
  export * from "./gate.js";
38
+ export * from "./gateClient.js";
38
39
  export * from "./textRecords.js";
39
40
  export * from "./lock.js";
40
41
  export * from "./integrator.js";
41
42
  export * from "./voucher.js";
42
43
  export * from "./agent.js";
43
44
  export * from "./register.js";
45
+ export * from "./adminConfig.js";
44
46
  export * from "./lease.js";
45
47
  // Was previously imported here for internal use only, never re-exported —
46
48
  // promoted to public API 2026-09-22 because a second real package (mcp/)
@@ -57,8 +59,14 @@ export * from "./commitReveal.js";
57
59
  export * from "./pnftTransfer.js";
58
60
  export * from "./signin.js";
59
61
  export * from "./domainProof.js";
62
+ // Named (not `export *`): `NAMESPACE_MAX_LEN` is already exported by
63
+ // adminMarket.js, and two `export *` sharing a name would make it ambiguous
64
+ // (dropped from the package's public surface). Re-export the scoped API, minus
65
+ // that one equal-valued constant.
66
+ export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, } from "./scoped.js";
60
67
  import { ResolveError, CHAIN_COIN_TYPE } from "./types.js";
61
- import { parseName } from "./parse.js";
68
+ import { parseName, normalizeHandle } from "./parse.js";
69
+ import { scopedTldCandidate, namespaceIsActive, NAMESPACE_MAX_LEN, } from "./scoped.js";
62
70
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
63
71
  import { DEFAULT_HANDLE_PROGRAM, TOKEN_ACCOUNT_MIN_LEN, bytesEqual, makeAccountReader, nftHolder, parseHandleAccount, readI64, readU64, } from "./accounts.js";
64
72
  import { fetchRecords, liveRecords } from "./records.js";
@@ -91,6 +99,7 @@ export function createResolver(config) {
91
99
  // Re-encoded (not the caller's string) so a non-canonical base58 spelling of
92
100
  // the same key still compares equal to the RPC's `owner` field.
93
101
  const programBase58 = encodeBase58(handleProgram);
102
+ const scopedDeriver = config.scopedHandleDeriver ?? null;
94
103
  async function resolveX1ns(canonical, label, tld, chain, input) {
95
104
  const account = config.wasm.deriveX1nsAccount(label, tld);
96
105
  if (!account) {
@@ -178,6 +187,109 @@ export function createResolver(config) {
178
187
  : await nftHolder(reader, config.wasm, canonical, handle.nftMint, input);
179
188
  return { input, name: canonical, namespace: "handle", address, chain, verification };
180
189
  }
190
+ /**
191
+ * Resolve a SCOPED customer-TLD name `name.tld` (#8484/#8485), or throw
192
+ * `unrecognized` when `.tld` is not one of OUR launched namespaces — so an
193
+ * unlaunched `.tld` behaves exactly as before (never a scoped hit). The
194
+ * contract mirrors `tools/api`'s `resolve_scoped`: canonicalize both halves
195
+ * as the program does, confirm `["namespace", tld]` is a program-owned Active
196
+ * `Namespace`, then derive `["handle", tld, name]` and read its CURRENT
197
+ * authority through the SAME owner/tokenization/record path a bare `@handle`
198
+ * uses — only the `name`/`namespace` fields differ (the TLD label).
199
+ */
200
+ async function resolveScoped(subRaw, tldRaw, chain, input) {
201
+ // The exact fall-through the native parser would have produced: a
202
+ // non-canonical half, or a `.tld` that is not a launched namespace, is
203
+ // simply "not a handle or a known domain" — never a misleading
204
+ // invalid-handle, and never a scoped hit on a name we do not own.
205
+ const notScoped = () => new ResolveError("unrecognized", `"${input.trim()}" is not a handle or a known domain`, input);
206
+ // Canonicalize both halves exactly as the program's `handle_normalize`
207
+ // does. A half that does not normalize can never name one of our scoped
208
+ // names — fall through rather than erroring (matches resolve_scoped).
209
+ let name;
210
+ let label;
211
+ try {
212
+ name = normalizeHandle(subRaw);
213
+ label = normalizeHandle(tldRaw);
214
+ }
215
+ catch {
216
+ throw notScoped();
217
+ }
218
+ if (label.length > NAMESPACE_MAX_LEN)
219
+ throw notScoped();
220
+ if (ttl > 0) {
221
+ const hit = cache.get(`scoped:${label}:${name}:${chain}`);
222
+ if (hit && hit.expires > Date.now())
223
+ return hit.value;
224
+ }
225
+ // Is `.label` a launched, Active X1ID namespace? Derive `["namespace",
226
+ // label]` via the WASM (canonical, always available) and read it. It must
227
+ // exist, be owned by the registry program, and carry `status == Active`.
228
+ const nsAccountKey = config.wasm.deriveNamespaceAccount(label, handleProgram);
229
+ if (!nsAccountKey)
230
+ throw notScoped();
231
+ const ns = await accountInfo(encodeBase58(nsAccountKey));
232
+ if (!ns || ns.owner !== programBase58 || !namespaceIsActive(ns.data)) {
233
+ // Not one of our launched namespaces — preserve today's behavior so an
234
+ // unlaunched `.tld` (and `.sol`/`.eth`, handled elsewhere) stays
235
+ // "unrecognized", never resolved through the wrong path.
236
+ throw notScoped();
237
+ }
238
+ // Confirmed a scoped X1ID name. From here any failure is a REAL error,
239
+ // never a silent fall-through (mirrors resolve_scoped's comment).
240
+ if (!scopedDeriver) {
241
+ 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
+ }
243
+ let pda;
244
+ try {
245
+ pda = await scopedDeriver.scopedHandleKey(label, name, programBase58);
246
+ }
247
+ catch (e) {
248
+ throw new ResolveError("rpc-error", `scoped handle derivation failed: ${String(e)}`, input);
249
+ }
250
+ const h = await accountInfo(pda);
251
+ // An account at the PDA the registry does not own is not a handle — the
252
+ // scoped name is unregistered under this (live) TLD.
253
+ if (!h || h.owner !== programBase58) {
254
+ throw new ResolveError("not-found", `${name}.${label} is not registered`, input);
255
+ }
256
+ const handle = parseHandleAccount(h.data);
257
+ if (!handle) {
258
+ throw new ResolveError("rpc-error", `${name}.${label} returned a malformed account`, input);
259
+ }
260
+ const display = `${name}.${label}`;
261
+ let value;
262
+ if (chain !== "X1" && chain !== "SOL") {
263
+ // ETH/BTC addresses live in per-chain `Record` accounts — same staleness
264
+ // rule as a bare handle (fetchRecords → liveRecords).
265
+ const live = liveRecords(await fetchRecords(rpc, programBase58, pda, handle.registeredAt, handle.recordsClearedAt));
266
+ const record = live.find((r) => r.coinType === CHAIN_COIN_TYPE[chain]);
267
+ if (!record) {
268
+ throw new ResolveError("no-record-for-chain", `${display} has no ${chain} record`, input);
269
+ }
270
+ value = {
271
+ input,
272
+ name: display,
273
+ namespace: label,
274
+ address: record.address,
275
+ chain,
276
+ verification: record.verified ? "verified" : "unverified",
277
+ };
278
+ }
279
+ else {
280
+ // Same authority rule as a bare handle: untokenized → `Handle.owner`
281
+ // (verified); tokenized → whoever holds the NFT now (verified only in its
282
+ // ATA). A scoped name is always tokenized in practice, but both branches
283
+ // are kept so the shape is byte-identical to resolveHandle.
284
+ const { address, verification } = handle.nftMint === null
285
+ ? { address: encodeBase58(handle.owner), verification: "verified" }
286
+ : await nftHolder(reader, config.wasm, display, handle.nftMint, input);
287
+ value = { input, name: display, namespace: label, address, chain, verification };
288
+ }
289
+ if (ttl > 0)
290
+ cache.set(`scoped:${label}:${name}:${chain}`, { value, expires: Date.now() + ttl });
291
+ return value;
292
+ }
181
293
  /** See `Resolver.records`. */
182
294
  async function records(input) {
183
295
  const parsed = parseName(input); // throws with a specific code
@@ -189,7 +301,24 @@ export function createResolver(config) {
189
301
  }
190
302
  async function resolve(input, opts) {
191
303
  const chain = opts?.chain ?? "X1";
192
- const parsed = parseName(input); // throws with a specific code
304
+ let parsed;
305
+ try {
306
+ parsed = parseName(input); // throws with a specific code
307
+ }
308
+ catch (e) {
309
+ // 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
313
+ // `.sol`/`.eth`/unknown TLDs behave exactly as before. Any other code
314
+ // (ambiguous / invalid-*) is preserved verbatim.
315
+ if (e instanceof ResolveError && e.code === "unrecognized") {
316
+ const cand = scopedTldCandidate(input);
317
+ if (cand)
318
+ return resolveScoped(cand.sub, cand.tld, chain, input);
319
+ }
320
+ throw e;
321
+ }
193
322
  const key = `${parsed.namespace}:${parsed.canonical}:${chain}`;
194
323
  if (ttl > 0) {
195
324
  const hit = cache.get(key);
@@ -304,3 +433,9 @@ export function createResolver(config) {
304
433
  clearCache: () => cache.clear(),
305
434
  };
306
435
  }
436
+ export * from "./adminMarket.js";
437
+ export * from "./namespaceOverride.js";
438
+ // The X1ID Universal Resolver (#8475): cross-namespace resolution (.sol via SNS,
439
+ // .eth via ENS) alongside the native path. Additive — the native resolver above
440
+ // is untouched; `createUniversalResolver` composes it with external adapters.
441
+ export * from "./universal.js";