@x1id/resolve 0.9.0 → 0.10.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,166 @@
1
+ /**
2
+ * Independent, re-verifiable domain->wallet proof via DNS-over-HTTPS (#8318,
3
+ * Approach C — the "auditable attestor" honesty upgrade).
4
+ *
5
+ * # Why this exists
6
+ *
7
+ * A `dns`-kind {@link import("./attestation.js").HandleAttestation} records only
8
+ * that x1id's admin-rotatable attestor key *signed off* on an off-chain DNS
9
+ * lookup ("attestation.ts" says this plainly): the chain stores the attestor's
10
+ * say-so, not a proof. The fully-trustless answers (an on-chain DNSSEC verifier,
11
+ * or a zkTLS proof — Approaches A/B in `docs/trustless-domain-proof-plan.md`)
12
+ * are BLOCKED on this X1 fork because the runtime does not have the crypto
13
+ * primitives they need active (`big_mod_exp` / `secp256r1` / `alt_bn128` are all
14
+ * feature-gated off). Until one of those lands, this module is the shippable
15
+ * middle ground: it lets ANYONE independently re-resolve the domain's DNS record
16
+ * and confirm the binding, so the attestor can no longer lie *undetected*. Trust
17
+ * moves from "the x1id attestor" to "DNS + the domain owner" — a real, cheap
18
+ * upgrade that needs no on-chain change.
19
+ *
20
+ * It is dependency-free and runs in both the browser and Node: a DoH query is a
21
+ * plain HTTPS GET, and the JSON shape ("application/dns-json", the Cloudflare /
22
+ * Google convention) is parsed by hand, like the rest of this package.
23
+ *
24
+ * # The TXT record convention (the interoperability contract)
25
+ *
26
+ * A domain owner proves control of `<domain>` by publishing a TXT record at the
27
+ * name **`_x1id.<domain>`** whose value is:
28
+ *
29
+ * x1id-wallet=<base58 wallet pubkey>
30
+ *
31
+ * - The prefix is the exported {@link TXT_BINDING_PREFIX} (`x1id-wallet=`).
32
+ * - `<base58 wallet pubkey>` is the 32-byte X1/SVM wallet address, base58, that
33
+ * the domain is bound to (the handle's authority wallet). Multiple TXT records
34
+ * under the same name are allowed — a domain may bind more than one wallet
35
+ * (e.g. during a key rotation); {@link resolveDomainBinding} returns them all.
36
+ * - A single TXT record split into several character-strings (RFC 1035 §3.3.14 /
37
+ * RFC 7208 §3.3) is concatenated with no separator before the prefix is
38
+ * matched, exactly as a mail SPF/DKIM verifier would.
39
+ *
40
+ * This is the contract a domain owner follows, so it is deliberately simple and
41
+ * stable. It mirrors the `_dnslink`/`_atproto`/`_domainkey` underscore-prefixed
42
+ * convention rather than putting x1id data on the apex TXT (which would collide
43
+ * with SPF/verification tokens from every other service).
44
+ *
45
+ * # NOTE — the paired attestor-side follow-up (NOT done here)
46
+ *
47
+ * This SDK-only change gives verifiers the re-check primitive. The *other* half
48
+ * of Approach C — having the attestor publish the exact `_x1id.<domain>` TXT it
49
+ * checked inside the attestation's `evidence_hash` preimage bundle, so a
50
+ * back-dated attestation whose TXT was later removed is still disprovable — is a
51
+ * change to the admin-api attest tooling (`tools/`), tracked as a FOLLOW-UP. See
52
+ * `docs/trustless-domain-proof-plan.md` §4. Until that ships,
53
+ * {@link verifyDomainBinding} can only confirm a binding that is *still live* in
54
+ * DNS; it cannot audit a removed one.
55
+ */
56
+ import type { RpcFn } from "./accounts.js";
57
+ /** The canonical prefix of the `_x1id.<domain>` TXT binding value. A TXT string
58
+ * that (after character-string concatenation) starts with this, followed by a
59
+ * valid base58 32-byte address, is an x1id binding record. */
60
+ export declare const TXT_BINDING_PREFIX = "x1id-wallet=";
61
+ /** The DNS label prefixed to a domain to locate its x1id binding TXT records —
62
+ * the query name is `${TXT_NAME_PREFIX}.<domain>`. */
63
+ export declare const TXT_NAME_PREFIX = "_x1id";
64
+ /** Built-in DoH endpoints (both speak the `application/dns-json` shape). */
65
+ declare const DOH_PROVIDERS: Readonly<{
66
+ readonly cloudflare: "https://cloudflare-dns.com/dns-query";
67
+ readonly google: "https://dns.google/resolve";
68
+ }>;
69
+ export interface DomainProofOptions {
70
+ /** Which built-in DoH provider to use when {@link dohUrl} is not given.
71
+ * Default `"cloudflare"`. */
72
+ readonly provider?: keyof typeof DOH_PROVIDERS;
73
+ /** A full DoH endpoint base URL (overrides {@link provider}), e.g. a
74
+ * self-hosted resolver. The query string (`?name=...&type=TXT`) is appended;
75
+ * a URL that already has query params is respected (`&` is used). Must speak
76
+ * the `application/dns-json` response shape. */
77
+ readonly dohUrl?: string;
78
+ /** `fetch` override — inject one in tests (return canned DoH JSON) or to use a
79
+ * custom transport. Defaults to `globalThis.fetch`. */
80
+ readonly fetchImpl?: typeof fetch;
81
+ /** Abort the DoH request after this many ms. Default 6000. */
82
+ readonly timeoutMs?: number;
83
+ }
84
+ /** The outcome of resolving a domain's `_x1id` TXT bindings. `ok` is about the
85
+ * LOOKUP succeeding, not about any wallet being found: a domain that simply has
86
+ * no binding is `ok: true` with an empty `wallets`. `ok: false` means the DNS
87
+ * query itself failed (network, timeout, DoH error) and the answer is unknown —
88
+ * never a throw. */
89
+ export interface DomainBindingResult {
90
+ /** The DNS lookup completed and its answer was parsed. */
91
+ readonly ok: boolean;
92
+ /** The domain queried (lowercased, canonical). */
93
+ readonly domain: string;
94
+ /** Every distinct wallet the `_x1id.<domain>` TXT records bind, base58
95
+ * (canonical spelling), first-seen order, de-duplicated. Empty when the
96
+ * domain publishes no valid binding (or when `ok` is false). */
97
+ readonly wallets: string[];
98
+ /** Why the lookup failed, or a note when it succeeded with nothing usable.
99
+ * Absent on a clean success that found bindings. */
100
+ readonly reason?: string;
101
+ }
102
+ /**
103
+ * Resolve the wallet(s) a domain binds via its `_x1id.<domain>` TXT records,
104
+ * independently of x1id's attestor — a DoH GET, parsed and validated defensively.
105
+ *
106
+ * Never throws: a network failure, timeout, or malformed answer comes back as
107
+ * `{ ok: false, reason }`; a domain with no binding as `{ ok: true, wallets: [] }`.
108
+ */
109
+ export declare function resolveDomainBinding(domain: string, opts?: DomainProofOptions): Promise<DomainBindingResult>;
110
+ /** The outcome of checking a domain against an expected wallet. */
111
+ export interface VerifyDomainBindingResult {
112
+ /** True iff the domain's `_x1id` TXT binds `expectedWallet` and the lookup
113
+ * succeeded. */
114
+ readonly ok: boolean;
115
+ /** Every wallet the domain binds (base58, canonical) — for context/UX even on
116
+ * a mismatch. Empty on a lookup failure. */
117
+ readonly found: string[];
118
+ /** Why `ok` is false: `"invalid-domain"` / `"invalid-wallet"` for bad inputs,
119
+ * a DoH failure reason (`"timeout"`, `"network-error"`, ...), `"not-found"`
120
+ * when the domain publishes no binding, or `"mismatch"` when it binds other
121
+ * wallets but not the expected one. Absent when `ok` is true. */
122
+ readonly reason?: string;
123
+ }
124
+ /**
125
+ * The core "anyone can check it" primitive: does `<domain>` independently bind
126
+ * `expectedWallet` in DNS? Returns `{ ok, found, reason? }` and never throws.
127
+ *
128
+ * A verifier calling this trusts DNS and the domain owner — NOT x1id's attestor.
129
+ */
130
+ export declare function verifyDomainBinding(domain: string, expectedWallet: string, opts?: DomainProofOptions): Promise<VerifyDomainBindingResult>;
131
+ /** The combined on-chain-attestation + live-DNS check. */
132
+ export interface VerifyDomainAttestationResult {
133
+ /** Both halves hold: x1id attested the domain (a non-stale `dns` attestation)
134
+ * AND DNS still independently confirms the binding. This is the strongest
135
+ * signal this SDK can give for a domain today. */
136
+ readonly ok: boolean;
137
+ /** The handle holds a NON-STALE `dns`-kind attestation for the current owner
138
+ * ("x1id attested it"). */
139
+ readonly attested: boolean;
140
+ /** DNS independently confirms the binding right now ("still current, attestor
141
+ * couldn't have lied"). */
142
+ readonly dnsConfirmed: boolean;
143
+ /** The wallet(s) the domain currently binds in DNS. */
144
+ readonly found: string[];
145
+ /** A note when `ok` is false — e.g. `"attestation-error"` if the chain read
146
+ * threw, or the {@link verifyDomainBinding} reason for the DNS half. */
147
+ readonly reason?: string;
148
+ }
149
+ /**
150
+ * Convenience: confirm BOTH that x1id attested a domain AND that DNS still
151
+ * independently backs it up — so a caller learns not just "x1id says so" but
152
+ * "and it's verifiably still true", closing the attestor's ability to have lied.
153
+ *
154
+ * `registeredAt` / `recordsClearedAt` are the owning Handle's fields the caller
155
+ * already decoded (see {@link import("./accounts.js").parseHandleAccount}); they
156
+ * are MANDATORY here for the same reason every attestation read demands them —
157
+ * the staleness rule (`attested_at >= registered_at`, and `>= records_cleared_at`)
158
+ * decides whether an attestation vouches for the *current* owner at all. The
159
+ * two halves are evaluated independently, so a caller can see which one holds:
160
+ * a chain-read failure does not suppress the DNS answer, and vice-versa.
161
+ *
162
+ * Never throws: a failed chain read degrades to `attested: false` with
163
+ * `reason: "attestation-error"`, mirroring `signin.ts`'s best-effort summary.
164
+ */
165
+ export declare function verifyDomainAttestation(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, domain: string, expectedWallet: string, opts?: DomainProofOptions): Promise<VerifyDomainAttestationResult>;
166
+ export {};
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Independent, re-verifiable domain->wallet proof via DNS-over-HTTPS (#8318,
3
+ * Approach C — the "auditable attestor" honesty upgrade).
4
+ *
5
+ * # Why this exists
6
+ *
7
+ * A `dns`-kind {@link import("./attestation.js").HandleAttestation} records only
8
+ * that x1id's admin-rotatable attestor key *signed off* on an off-chain DNS
9
+ * lookup ("attestation.ts" says this plainly): the chain stores the attestor's
10
+ * say-so, not a proof. The fully-trustless answers (an on-chain DNSSEC verifier,
11
+ * or a zkTLS proof — Approaches A/B in `docs/trustless-domain-proof-plan.md`)
12
+ * are BLOCKED on this X1 fork because the runtime does not have the crypto
13
+ * primitives they need active (`big_mod_exp` / `secp256r1` / `alt_bn128` are all
14
+ * feature-gated off). Until one of those lands, this module is the shippable
15
+ * middle ground: it lets ANYONE independently re-resolve the domain's DNS record
16
+ * and confirm the binding, so the attestor can no longer lie *undetected*. Trust
17
+ * moves from "the x1id attestor" to "DNS + the domain owner" — a real, cheap
18
+ * upgrade that needs no on-chain change.
19
+ *
20
+ * It is dependency-free and runs in both the browser and Node: a DoH query is a
21
+ * plain HTTPS GET, and the JSON shape ("application/dns-json", the Cloudflare /
22
+ * Google convention) is parsed by hand, like the rest of this package.
23
+ *
24
+ * # The TXT record convention (the interoperability contract)
25
+ *
26
+ * A domain owner proves control of `<domain>` by publishing a TXT record at the
27
+ * name **`_x1id.<domain>`** whose value is:
28
+ *
29
+ * x1id-wallet=<base58 wallet pubkey>
30
+ *
31
+ * - The prefix is the exported {@link TXT_BINDING_PREFIX} (`x1id-wallet=`).
32
+ * - `<base58 wallet pubkey>` is the 32-byte X1/SVM wallet address, base58, that
33
+ * the domain is bound to (the handle's authority wallet). Multiple TXT records
34
+ * under the same name are allowed — a domain may bind more than one wallet
35
+ * (e.g. during a key rotation); {@link resolveDomainBinding} returns them all.
36
+ * - A single TXT record split into several character-strings (RFC 1035 §3.3.14 /
37
+ * RFC 7208 §3.3) is concatenated with no separator before the prefix is
38
+ * matched, exactly as a mail SPF/DKIM verifier would.
39
+ *
40
+ * This is the contract a domain owner follows, so it is deliberately simple and
41
+ * stable. It mirrors the `_dnslink`/`_atproto`/`_domainkey` underscore-prefixed
42
+ * convention rather than putting x1id data on the apex TXT (which would collide
43
+ * with SPF/verification tokens from every other service).
44
+ *
45
+ * # NOTE — the paired attestor-side follow-up (NOT done here)
46
+ *
47
+ * This SDK-only change gives verifiers the re-check primitive. The *other* half
48
+ * of Approach C — having the attestor publish the exact `_x1id.<domain>` TXT it
49
+ * checked inside the attestation's `evidence_hash` preimage bundle, so a
50
+ * back-dated attestation whose TXT was later removed is still disprovable — is a
51
+ * change to the admin-api attest tooling (`tools/`), tracked as a FOLLOW-UP. See
52
+ * `docs/trustless-domain-proof-plan.md` §4. Until that ships,
53
+ * {@link verifyDomainBinding} can only confirm a binding that is *still live* in
54
+ * DNS; it cannot audit a removed one.
55
+ */
56
+ import { decodeBase58_32, encodeBase58 } from "./base58.js";
57
+ import { fetchAttestations } from "./attestation.js";
58
+ import { attestationsPassGate, GateKind } from "./gate.js";
59
+ /** The canonical prefix of the `_x1id.<domain>` TXT binding value. A TXT string
60
+ * that (after character-string concatenation) starts with this, followed by a
61
+ * valid base58 32-byte address, is an x1id binding record. */
62
+ export const TXT_BINDING_PREFIX = "x1id-wallet=";
63
+ /** The DNS label prefixed to a domain to locate its x1id binding TXT records —
64
+ * the query name is `${TXT_NAME_PREFIX}.<domain>`. */
65
+ export const TXT_NAME_PREFIX = "_x1id";
66
+ /** DoH JSON `type` code for a TXT record (RFC 1035). */
67
+ const DNS_TYPE_TXT = 16;
68
+ /** Built-in DoH endpoints (both speak the `application/dns-json` shape). */
69
+ const DOH_PROVIDERS = Object.freeze({
70
+ cloudflare: "https://cloudflare-dns.com/dns-query",
71
+ google: "https://dns.google/resolve",
72
+ });
73
+ const DEFAULT_TIMEOUT_MS = 6000;
74
+ /** A domain is a case-insensitive dotted hostname (1..253 chars, labels 1..63 of
75
+ * `[a-z0-9-]`, no leading/trailing hyphen). Strict enough that the name can be
76
+ * safely interpolated into a query and can never smuggle a second query param. */
77
+ const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
78
+ function normalizeDomain(domain) {
79
+ if (typeof domain !== "string")
80
+ return null;
81
+ // Tolerate a trailing dot (fully-qualified form) and case; reject everything
82
+ // else so the name is safe to interpolate into the query.
83
+ const d = domain.trim().replace(/\.$/, "").toLowerCase();
84
+ if (!DOMAIN_RE.test(d))
85
+ return null;
86
+ return d;
87
+ }
88
+ function buildDohUrl(base, name) {
89
+ const q = `name=${encodeURIComponent(name)}&type=TXT`;
90
+ return base.includes("?") ? `${base}&${q}` : `${base}?${q}`;
91
+ }
92
+ /**
93
+ * Concatenate a DoH TXT `data` field into its logical string.
94
+ *
95
+ * DoH returns a TXT record's RDATA as its character-strings, each wrapped in
96
+ * double quotes; a record split into several character-strings comes back as
97
+ * several quoted runs (e.g. `"\"x1id-ver\" \"ification=...\""`). Per RFC 1035
98
+ * §3.3.14 they concatenate with NO separator. Escaped `\"` and `\\` inside a
99
+ * run are unescaped. A `data` with no quotes at all (some resolvers) is used
100
+ * verbatim.
101
+ */
102
+ function unquoteTxtData(data) {
103
+ if (!data.includes('"'))
104
+ return data;
105
+ let out = "";
106
+ let inStr = false;
107
+ for (let i = 0; i < data.length; i++) {
108
+ const c = data[i];
109
+ if (!inStr) {
110
+ if (c === '"')
111
+ inStr = true;
112
+ // characters between quoted runs (whitespace) are dropped
113
+ continue;
114
+ }
115
+ if (c === "\\") {
116
+ // escaped char: take the next byte literally
117
+ const next = data[i + 1];
118
+ if (next !== undefined) {
119
+ out += next;
120
+ i++;
121
+ }
122
+ continue;
123
+ }
124
+ if (c === '"') {
125
+ inStr = false;
126
+ continue;
127
+ }
128
+ out += c;
129
+ }
130
+ return out;
131
+ }
132
+ /** Extract a canonical base58 wallet from one concatenated TXT string, or null
133
+ * if it is not a well-formed x1id binding. */
134
+ function walletFromTxt(txt) {
135
+ if (!txt.startsWith(TXT_BINDING_PREFIX))
136
+ return null;
137
+ const raw = txt.slice(TXT_BINDING_PREFIX.length).trim();
138
+ const bytes = decodeBase58_32(raw);
139
+ if (!bytes)
140
+ return null;
141
+ // Re-encode so a non-canonical base58 spelling and the caller's wallet compare
142
+ // byte-for-byte, exactly like the rest of the SDK does.
143
+ return encodeBase58(bytes);
144
+ }
145
+ async function fetchDohJson(url, fetchImpl, timeoutMs) {
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
148
+ try {
149
+ const res = await fetchImpl(url, {
150
+ signal: controller.signal,
151
+ // Cloudflare's /dns-query REQUIRES this to return JSON; harmless for Google.
152
+ headers: { accept: "application/dns-json" },
153
+ });
154
+ if (!res.ok)
155
+ return { ok: false, reason: `doh-http-${res.status}` };
156
+ const json = (await res.json());
157
+ return { ok: true, json };
158
+ }
159
+ catch (e) {
160
+ const reason = e?.name === "AbortError" ? "timeout" : "network-error";
161
+ return { ok: false, reason };
162
+ }
163
+ finally {
164
+ clearTimeout(timer);
165
+ }
166
+ }
167
+ /**
168
+ * Resolve the wallet(s) a domain binds via its `_x1id.<domain>` TXT records,
169
+ * independently of x1id's attestor — a DoH GET, parsed and validated defensively.
170
+ *
171
+ * Never throws: a network failure, timeout, or malformed answer comes back as
172
+ * `{ ok: false, reason }`; a domain with no binding as `{ ok: true, wallets: [] }`.
173
+ */
174
+ export async function resolveDomainBinding(domain, opts) {
175
+ const canonical = normalizeDomain(domain);
176
+ if (canonical === null) {
177
+ return { ok: false, domain: String(domain), wallets: [], reason: "invalid-domain" };
178
+ }
179
+ const fetchImpl = opts?.fetchImpl ?? globalThis.fetch;
180
+ if (typeof fetchImpl !== "function") {
181
+ return { ok: false, domain: canonical, wallets: [], reason: "no-fetch" };
182
+ }
183
+ const base = opts?.dohUrl ?? DOH_PROVIDERS[opts?.provider ?? "cloudflare"];
184
+ const url = buildDohUrl(base, `${TXT_NAME_PREFIX}.${canonical}`);
185
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
186
+ const res = await fetchDohJson(url, fetchImpl, timeoutMs);
187
+ if (!res.ok)
188
+ return { ok: false, domain: canonical, wallets: [], reason: res.reason };
189
+ const body = res.json;
190
+ if (typeof body !== "object" || body === null) {
191
+ return { ok: false, domain: canonical, wallets: [], reason: "malformed-response" };
192
+ }
193
+ const b = body;
194
+ const status = typeof b.Status === "number" ? b.Status : -1;
195
+ // 0 = NOERROR (parse answers), 3 = NXDOMAIN (the name simply doesn't exist =
196
+ // no binding, a clean empty result). Anything else (SERVFAIL, etc.) is an
197
+ // unknown answer, not a proven absence.
198
+ if (status === 3)
199
+ return { ok: true, domain: canonical, wallets: [] };
200
+ if (status !== 0)
201
+ return { ok: false, domain: canonical, wallets: [], reason: `doh-status-${status}` };
202
+ const answers = Array.isArray(b.Answer) ? b.Answer : [];
203
+ const wallets = [];
204
+ for (const ans of answers) {
205
+ if (typeof ans !== "object" || ans === null)
206
+ continue;
207
+ const a = ans;
208
+ if (a.type !== DNS_TYPE_TXT)
209
+ continue; // skip CNAME/other chained answers
210
+ if (typeof a.data !== "string")
211
+ continue;
212
+ const wallet = walletFromTxt(unquoteTxtData(a.data));
213
+ if (wallet && !wallets.includes(wallet))
214
+ wallets.push(wallet);
215
+ }
216
+ return { ok: true, domain: canonical, wallets };
217
+ }
218
+ /**
219
+ * The core "anyone can check it" primitive: does `<domain>` independently bind
220
+ * `expectedWallet` in DNS? Returns `{ ok, found, reason? }` and never throws.
221
+ *
222
+ * A verifier calling this trusts DNS and the domain owner — NOT x1id's attestor.
223
+ */
224
+ export async function verifyDomainBinding(domain, expectedWallet, opts) {
225
+ const wanted = typeof expectedWallet === "string" ? decodeBase58_32(expectedWallet) : null;
226
+ if (!wanted)
227
+ return { ok: false, found: [], reason: "invalid-wallet" };
228
+ const wantedBase58 = encodeBase58(wanted);
229
+ const res = await resolveDomainBinding(domain, opts);
230
+ if (!res.ok)
231
+ return { ok: false, found: [], reason: res.reason ?? "lookup-failed" };
232
+ if (res.wallets.length === 0)
233
+ return { ok: false, found: [], reason: "not-found" };
234
+ if (!res.wallets.includes(wantedBase58)) {
235
+ return { ok: false, found: res.wallets, reason: "mismatch" };
236
+ }
237
+ return { ok: true, found: res.wallets };
238
+ }
239
+ /**
240
+ * Convenience: confirm BOTH that x1id attested a domain AND that DNS still
241
+ * independently backs it up — so a caller learns not just "x1id says so" but
242
+ * "and it's verifiably still true", closing the attestor's ability to have lied.
243
+ *
244
+ * `registeredAt` / `recordsClearedAt` are the owning Handle's fields the caller
245
+ * already decoded (see {@link import("./accounts.js").parseHandleAccount}); they
246
+ * are MANDATORY here for the same reason every attestation read demands them —
247
+ * the staleness rule (`attested_at >= registered_at`, and `>= records_cleared_at`)
248
+ * decides whether an attestation vouches for the *current* owner at all. The
249
+ * two halves are evaluated independently, so a caller can see which one holds:
250
+ * a chain-read failure does not suppress the DNS answer, and vice-versa.
251
+ *
252
+ * Never throws: a failed chain read degrades to `attested: false` with
253
+ * `reason: "attestation-error"`, mirroring `signin.ts`'s best-effort summary.
254
+ */
255
+ export async function verifyDomainAttestation(rpc, programId, handleAccount, registeredAt, recordsClearedAt, domain, expectedWallet, opts) {
256
+ // Half 1 (on-chain): a non-stale dns attestation for the current owner. A
257
+ // read failure must not sink the DNS half — degrade, never throw.
258
+ let attested = false;
259
+ let attestationReason;
260
+ try {
261
+ const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt);
262
+ attested = attestationsPassGate(attestations, { kind: GateKind.dns });
263
+ }
264
+ catch {
265
+ attestationReason = "attestation-error";
266
+ }
267
+ // Half 2 (off-chain, independent): DNS still binds the expected wallet.
268
+ const dns = await verifyDomainBinding(domain, expectedWallet, opts);
269
+ const ok = attested && dns.ok;
270
+ const reason = ok ? undefined : (attestationReason ?? (attested ? dns.reason : "not-attested"));
271
+ return {
272
+ ok,
273
+ attested,
274
+ dnsConfirmed: dns.ok,
275
+ found: dns.found,
276
+ ...(reason !== undefined ? { reason } : {}),
277
+ };
278
+ }
package/dist/index.d.ts CHANGED
@@ -48,6 +48,7 @@ export * from "./clearRecords.js";
48
48
  export * from "./commitReveal.js";
49
49
  export * from "./pnftTransfer.js";
50
50
  export * from "./signin.js";
51
+ export * from "./domainProof.js";
51
52
  import { type Chain, type Resolved } from "./types.js";
52
53
  import { WasmResolver } from "./wasm.js";
53
54
  import { type HandleRecord } from "./records.js";
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ export * from "./clearRecords.js";
55
55
  export * from "./commitReveal.js";
56
56
  export * from "./pnftTransfer.js";
57
57
  export * from "./signin.js";
58
+ export * from "./domainProof.js";
58
59
  import { ResolveError, CHAIN_COIN_TYPE } from "./types.js";
59
60
  import { parseName } from "./parse.js";
60
61
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",