@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 +38 -0
- package/dist/gateClient.d.ts +11 -1
- package/dist/gateClient.js +11 -1
- package/dist/index.js +381 -5
- package/dist/signin.d.ts +15 -3
- package/dist/signin.js +0 -0
- package/package.json +1 -1
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
|
package/dist/gateClient.d.ts
CHANGED
|
@@ -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.
|
|
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
|
package/dist/gateClient.js
CHANGED
|
@@ -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.
|
|
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
|
|
311
|
-
//
|
|
312
|
-
//
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|