@x1id/resolve 0.8.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.
package/dist/agent.js CHANGED
@@ -192,7 +192,7 @@ export function x402AcceptsFromManifest(manifest, opts) {
192
192
  // Mainnet has no such id yet — X1 mainnet doesn't exist — so a manifest
193
193
  // claiming network: "mainnet" without its own explicit x402.network gets
194
194
  // an honestly-provisional placeholder rather than a fabricated genesis hash.
195
- const network = x402?.network ?? (manifest.x1id.network === "testnet" ? X1_TESTNET_NETWORK : "x1:mainnet-unassigned");
195
+ const network = x402?.network ?? (manifest.x1id.network === "testnet" ? X1_TESTNET_NETWORK : "solana:mainnet-unassigned");
196
196
  const payTo = opts?.payTo ?? manifest.payment?.addresses?.[0]?.address;
197
197
  return x402Endpoints.map((e) => {
198
198
  const hint = {
package/dist/base58.d.ts CHANGED
@@ -1,3 +1,11 @@
1
1
  export declare function encodeBase58(bytes: Uint8Array): string;
2
+ /**
3
+ * Decode base58 to its exact bytes (standard leading-`1`→leading-zero-byte
4
+ * handling), or null for a character outside the alphabet. Unlike
5
+ * {@link decodeBase58_32} this preserves length, so it decodes a 64-byte
6
+ * ed25519 signature (`signin.ts`) as faithfully as a 32-byte key. Pass
7
+ * `expectedLen` to reject anything but that length up front.
8
+ */
9
+ export declare function decodeBase58(s: string, expectedLen?: number): Uint8Array | null;
2
10
  /** Decode to exactly 32 bytes, or null if the input is not a valid address. */
3
11
  export declare function decodeBase58_32(s: string): Uint8Array | null;
package/dist/base58.js CHANGED
@@ -16,6 +16,37 @@ export function encodeBase58(bytes) {
16
16
  }
17
17
  return out === "" ? "1" : out;
18
18
  }
19
+ /**
20
+ * Decode base58 to its exact bytes (standard leading-`1`→leading-zero-byte
21
+ * handling), or null for a character outside the alphabet. Unlike
22
+ * {@link decodeBase58_32} this preserves length, so it decodes a 64-byte
23
+ * ed25519 signature (`signin.ts`) as faithfully as a 32-byte key. Pass
24
+ * `expectedLen` to reject anything but that length up front.
25
+ */
26
+ export function decodeBase58(s, expectedLen) {
27
+ if (s.length === 0)
28
+ return null;
29
+ let zeros = 0;
30
+ while (zeros < s.length && s[zeros] === "1")
31
+ zeros++;
32
+ let n = 0n;
33
+ for (const c of s) {
34
+ const i = A.indexOf(c);
35
+ if (i === -1)
36
+ return null;
37
+ n = n * 58n + BigInt(i);
38
+ }
39
+ const tail = [];
40
+ while (n > 0n) {
41
+ tail.unshift(Number(n & 255n));
42
+ n >>= 8n;
43
+ }
44
+ const out = new Uint8Array(zeros + tail.length);
45
+ out.set(tail, zeros);
46
+ if (expectedLen !== undefined && out.length !== expectedLen)
47
+ return null;
48
+ return out;
49
+ }
19
50
  /** Decode to exactly 32 bytes, or null if the input is not a valid address. */
20
51
  export function decodeBase58_32(s) {
21
52
  if (s.length === 0 || s.length > 44)
@@ -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/gate.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Attestation-gated allowlists (#8316) — gate access to your app by whether an
3
+ * `@handle` holds a verified on-chain attestation (any signal, or a SPECIFIC
4
+ * platform like "verified GitHub"). A project reuses X1ID's blue-tick oracle as
5
+ * its allowlist instead of running its own KYC.
6
+ *
7
+ * The gate operates on the attestations {@link fetchAttestations} returns; the
8
+ * staleness rule (an attestation only counts for the CURRENT owner —
9
+ * `attested_at >= handle.registered_at`) is already baked into `HandleAttestation.stale`,
10
+ * and this module always ignores stale ones. Nothing here trusts an off-chain
11
+ * claim: every input is an on-chain, attestor-signed `Attestation` account.
12
+ */
13
+ import { type HandleAttestation } from "./attestation.js";
14
+ import type { RpcFn } from "./accounts.js";
15
+ /** What a handle must prove to pass the gate:
16
+ * - `"any-verified"` — at least one non-stale attestation of any kind.
17
+ * - `{ kind }` — a non-stale attestation of exactly that kind.
18
+ * - `{ anyOf }` — a non-stale attestation of any listed kind (e.g. X OR GitHub). */
19
+ export type Gate = "any-verified" | {
20
+ readonly kind: number;
21
+ } | {
22
+ readonly anyOf: readonly number[];
23
+ };
24
+ /** Named platform kinds, re-exported so callers gate without magic numbers. */
25
+ export declare const GateKind: {
26
+ readonly dns: 0;
27
+ readonly social: 1;
28
+ readonly x: 2;
29
+ readonly discord: 3;
30
+ readonly github: 4;
31
+ readonly telegram: 5;
32
+ };
33
+ /** Pure check: do these (already-fetched) attestations satisfy the gate?
34
+ * Stale attestations never count. */
35
+ export declare function attestationsPassGate(attestations: readonly HandleAttestation[], gate: Gate): boolean;
36
+ /** Fetch a handle's attestations and test the gate in one call. `handleAccount`,
37
+ * `registeredAt` and `recordsClearedAt` come from the handle you resolved
38
+ * (name → `deriveHandleAccount` → decode). Returns the pass/fail plus the
39
+ * attestations so a caller can also render WHICH signals matched. */
40
+ export declare function handlePassesGate(rpc: RpcFn, programId: string, handleAccount: string, registeredAt: bigint, recordsClearedAt: bigint, gate: Gate): Promise<{
41
+ readonly passed: boolean;
42
+ readonly attestations: HandleAttestation[];
43
+ }>;
package/dist/gate.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Attestation-gated allowlists (#8316) — gate access to your app by whether an
3
+ * `@handle` holds a verified on-chain attestation (any signal, or a SPECIFIC
4
+ * platform like "verified GitHub"). A project reuses X1ID's blue-tick oracle as
5
+ * its allowlist instead of running its own KYC.
6
+ *
7
+ * The gate operates on the attestations {@link fetchAttestations} returns; the
8
+ * staleness rule (an attestation only counts for the CURRENT owner —
9
+ * `attested_at >= handle.registered_at`) is already baked into `HandleAttestation.stale`,
10
+ * and this module always ignores stale ones. Nothing here trusts an off-chain
11
+ * claim: every input is an on-chain, attestor-signed `Attestation` account.
12
+ */
13
+ import { fetchAttestations, isHandleVerified, ATTESTATION_KIND_DNS, ATTESTATION_KIND_SOCIAL, ATTESTATION_KIND_X, ATTESTATION_KIND_DISCORD, ATTESTATION_KIND_GITHUB, ATTESTATION_KIND_TELEGRAM, } from "./attestation.js";
14
+ /** Named platform kinds, re-exported so callers gate without magic numbers. */
15
+ export const GateKind = {
16
+ dns: ATTESTATION_KIND_DNS,
17
+ social: ATTESTATION_KIND_SOCIAL,
18
+ x: ATTESTATION_KIND_X,
19
+ discord: ATTESTATION_KIND_DISCORD,
20
+ github: ATTESTATION_KIND_GITHUB,
21
+ telegram: ATTESTATION_KIND_TELEGRAM,
22
+ };
23
+ /** Pure check: do these (already-fetched) attestations satisfy the gate?
24
+ * Stale attestations never count. */
25
+ export function attestationsPassGate(attestations, gate) {
26
+ if (gate === "any-verified")
27
+ return isHandleVerified(attestations);
28
+ const kinds = "kind" in gate ? [gate.kind] : gate.anyOf;
29
+ return attestations.some((a) => !a.stale && kinds.includes(a.kind));
30
+ }
31
+ /** Fetch a handle's attestations and test the gate in one call. `handleAccount`,
32
+ * `registeredAt` and `recordsClearedAt` come from the handle you resolved
33
+ * (name → `deriveHandleAccount` → decode). Returns the pass/fail plus the
34
+ * attestations so a caller can also render WHICH signals matched. */
35
+ export async function handlePassesGate(rpc, programId, handleAccount, registeredAt, recordsClearedAt, gate) {
36
+ const attestations = await fetchAttestations(rpc, programId, handleAccount, registeredAt, recordsClearedAt);
37
+ return { passed: attestationsPassGate(attestations, gate), attestations };
38
+ }
package/dist/index.d.ts CHANGED
@@ -27,13 +27,14 @@
27
27
  export * from "./types.js";
28
28
  export { normalizeHandle, parseName, looksLikeName, type ParsedName } from "./parse.js";
29
29
  export { WasmResolver, type WasmTld } from "./wasm.js";
30
- export { encodeBase58, decodeBase58_32 } from "./base58.js";
30
+ export { encodeBase58, decodeBase58, decodeBase58_32 } from "./base58.js";
31
31
  export { decodeRecord, liveRecords, chainForCoinType, valueToAddress, fetchRecords, RECORD_LEN, RECORD_DISC, type HandleRecord, } from "./records.js";
32
32
  export { buildControlChallenge, parseControlChallenge, generateControlNonce, createControlChallenge, verifyControlProof, verifyEd25519Strict, CONTROL_CHALLENGE_PREFIX, type ControlChallenge, type ControlConfig, type ControlProof, type ControlVerification, type ControlFailureReason, } from "./control.js";
33
33
  export * from "./delegate.js";
34
34
  export * from "./subname.js";
35
35
  export * from "./recordCount.js";
36
36
  export * from "./attestation.js";
37
+ export * from "./gate.js";
37
38
  export * from "./textRecords.js";
38
39
  export * from "./lock.js";
39
40
  export * from "./integrator.js";
@@ -46,6 +47,8 @@ export * from "./recordWrite.js";
46
47
  export * from "./clearRecords.js";
47
48
  export * from "./commitReveal.js";
48
49
  export * from "./pnftTransfer.js";
50
+ export * from "./signin.js";
51
+ export * from "./domainProof.js";
49
52
  import { type Chain, type Resolved } from "./types.js";
50
53
  import { WasmResolver } from "./wasm.js";
51
54
  import { type HandleRecord } from "./records.js";
package/dist/index.js CHANGED
@@ -27,13 +27,14 @@
27
27
  export * from "./types.js";
28
28
  export { normalizeHandle, parseName, looksLikeName } from "./parse.js";
29
29
  export { WasmResolver } from "./wasm.js";
30
- export { encodeBase58, decodeBase58_32 } from "./base58.js";
30
+ export { encodeBase58, decodeBase58, decodeBase58_32 } from "./base58.js";
31
31
  export { decodeRecord, liveRecords, chainForCoinType, valueToAddress, fetchRecords, RECORD_LEN, RECORD_DISC, } from "./records.js";
32
32
  export { buildControlChallenge, parseControlChallenge, generateControlNonce, createControlChallenge, verifyControlProof, verifyEd25519Strict, CONTROL_CHALLENGE_PREFIX, } from "./control.js";
33
33
  export * from "./delegate.js";
34
34
  export * from "./subname.js";
35
35
  export * from "./recordCount.js";
36
36
  export * from "./attestation.js";
37
+ export * from "./gate.js";
37
38
  export * from "./textRecords.js";
38
39
  export * from "./lock.js";
39
40
  export * from "./integrator.js";
@@ -53,6 +54,8 @@ export * from "./recordWrite.js";
53
54
  export * from "./clearRecords.js";
54
55
  export * from "./commitReveal.js";
55
56
  export * from "./pnftTransfer.js";
57
+ export * from "./signin.js";
58
+ export * from "./domainProof.js";
56
59
  import { ResolveError, CHAIN_COIN_TYPE } from "./types.js";
57
60
  import { parseName } from "./parse.js";
58
61
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
@@ -0,0 +1,318 @@
1
+ /**
2
+ * "Sign in with X1ID" — a client-side, wallet-signature proof of @handle
3
+ * ownership (WP #8313). An OAuth-style login *primitive*, framework-agnostic,
4
+ * that any third-party app can drop in to let a user authenticate as their
5
+ * `@handle`.
6
+ *
7
+ * # What this is — and, honestly, what it is not
8
+ *
9
+ * X1ID's app is a static export and `@x1id/resolve` is a pure client library:
10
+ * there is **no server-side token exchange** here. This module is therefore
11
+ * the SIWE-style ("Sign-In With Ethereum", EIP-4361) shape adapted to X1:
12
+ *
13
+ * 1. the relying app builds a **domain-bound** challenge naming the wallet,
14
+ * a fresh nonce, an issued-at and an expiry ({@link createSignInChallenge});
15
+ * 2. the wallet signs the raw UTF-8 bytes of that challenge (ed25519 — X1 and
16
+ * Solana share the curve and key format);
17
+ * 3. the relying app verifies the signature ({@link verifySignIn}) and then
18
+ * reverse-resolves the address to its primary `@handle` and verification
19
+ * summary ({@link resolveIdentity}).
20
+ *
21
+ * A single verified sign-in proves exactly: **"this wallet, which *currently*
22
+ * owns @handle, signed this challenge for this domain, before it expired."** It
23
+ * is NOT a session and NOT a bearer token — the relying app MUST bind the
24
+ * verified identity to its own session (a cookie, a JWT it mints, ...). And
25
+ * handle ownership can change (a sale, a transfer, `clear_primary`), so a long-
26
+ * lived session should re-run {@link resolveIdentity} periodically rather than
27
+ * trust a handle captured at login forever.
28
+ *
29
+ * A full hosted OAuth *authorization-code server* (real token issuance, a
30
+ * `/authorize` + `/token` pair, refresh tokens) needs a backend and is
31
+ * deliberately **out of scope** — see the README's "What needs a server".
32
+ *
33
+ * # Reuse, not reinvention
34
+ *
35
+ * - Signature verification is the SDK's existing RFC-8032-strict
36
+ * {@link import("./control.js").verifyEd25519Strict} (WebCrypto Ed25519),
37
+ * the same primitive the control-proof flow relies on. No new crypto.
38
+ * - Identity resolution is the SDK's own {@link import("./index.js").createResolver}
39
+ * `reverse()` (with all its staleness/authority trust rules) plus
40
+ * {@link import("./attestation.js").fetchAttestations} — never a re-derived
41
+ * PDA or a hand-rolled ownership check.
42
+ *
43
+ * # Cross-protocol separation
44
+ *
45
+ * A sign-in challenge is always a MULTI-LINE SIWE block (it contains `\n`).
46
+ * The record-verification challenge (`x1-handles:v1:...`) and the control-proof
47
+ * challenge (`x1-handles:ctl:v1:...`) are both single lines with no newline, so
48
+ * a signature obtained for one can never validate as another — the byte spaces
49
+ * are structurally disjoint.
50
+ */
51
+ /** Why a verification or a convenience sign-in failed. Mirrors `ResolveError`'s
52
+ * code-not-string style so a UI can branch on the cause. */
53
+ export type SignInFailureReason =
54
+ /** The challenge string is not a well-formed sign-in challenge. */
55
+ "invalid-challenge"
56
+ /** The signature is malformed (not base58, or not 64 bytes). */
57
+ | "invalid-signature"
58
+ /** The address argument is not a valid base58 X1 address. */
59
+ | "invalid-address"
60
+ /** The address argument does not match the address bound in the challenge. */
61
+ | "address-mismatch"
62
+ /** An `expectedDomain` was given and does not match the challenge's domain. */
63
+ | "domain-mismatch"
64
+ /** An `expectedNonce` was given and does not match the challenge's nonce. */
65
+ | "nonce-mismatch"
66
+ /** `now` is before the challenge's issued-at (minus skew) — clock problem
67
+ * or a challenge minted for the future. */
68
+ | "not-yet-valid"
69
+ /** `now` is past the challenge's expiry (plus skew). Replay window closed. */
70
+ | "expired"
71
+ /** The signature does not verify over the challenge bytes for this address. */
72
+ | "bad-signature";
73
+ /** Codes a thrown {@link SignInError} carries — every {@link SignInFailureReason}
74
+ * plus the convenience-flow-only outcomes. */
75
+ export type SignInErrorCode = SignInFailureReason | "no-handle" | "wallet-error" | "invalid-params";
76
+ /**
77
+ * Thrown by {@link signInWithX1ID} (the convenience flow) and by the argument
78
+ * validation in {@link createSignInChallenge}. Pure verification via
79
+ * {@link verifySignIn} does NOT throw for a verification outcome — it returns
80
+ * `{ ok: false, reason }` — this class is for the throw-based login call.
81
+ */
82
+ export declare class SignInError extends Error {
83
+ readonly code: SignInErrorCode;
84
+ readonly cause?: unknown | undefined;
85
+ constructor(code: SignInErrorCode, message: string, cause?: unknown | undefined);
86
+ }
87
+ export interface SignInChallengeParams {
88
+ /** The relying party's domain, e.g. `"app.example.com"`. Bound into the
89
+ * challenge; a signature for one domain never validates for another. */
90
+ readonly domain: string;
91
+ /** The X1 wallet address (base58) that will sign. Bound into the challenge so
92
+ * a signature cannot be lifted onto a different account's session. */
93
+ readonly address: string;
94
+ /** Optional human-readable statement shown to the user in their wallet. Single
95
+ * line only (a newline would corrupt the challenge structure). */
96
+ readonly statement?: string;
97
+ /** Resource URI. Defaults to `https://<domain>`. */
98
+ readonly uri?: string;
99
+ /** Single-use nonce. Generated ({@link generateSignInNonce}) when omitted. */
100
+ readonly nonce?: string;
101
+ /** Issued-at, ms since epoch. Defaults to `Date.now()`. */
102
+ readonly issuedAt?: number;
103
+ /** Lifetime in seconds from issued-at. Defaults to 300 (5 minutes). */
104
+ readonly expiresInSec?: number;
105
+ }
106
+ /** A built challenge: the exact string to sign, plus its parsed fields for the
107
+ * relying party to store (nonce single-use, expiry enforcement are the RP's). */
108
+ export interface SignInChallenge {
109
+ /** The exact string whose raw UTF-8 bytes the wallet signs. */
110
+ readonly challenge: string;
111
+ readonly domain: string;
112
+ readonly address: string;
113
+ readonly uri: string;
114
+ readonly statement: string | null;
115
+ readonly nonce: string;
116
+ /** Issued-at, ms since epoch. */
117
+ readonly issuedAt: number;
118
+ /** Expiry, ms since epoch. */
119
+ readonly expiresAt: number;
120
+ }
121
+ /**
122
+ * Generate a single-use sign-in nonce: `bytes` bytes (>= 8) from the platform
123
+ * CSPRNG, hex-encoded to stay inside the nonce charset. SINGLE-USE — the
124
+ * relying party stores it with the issued challenge and rejects a second proof
125
+ * against it (client-side code cannot enforce single use; the RP must).
126
+ */
127
+ export declare function generateSignInNonce(bytes?: number): string;
128
+ /**
129
+ * Build a deterministic, human-readable SIWE-style sign-in challenge.
130
+ *
131
+ * The exact bytes signed are the UTF-8 encoding of this string (no wallet
132
+ * prefix — the wallet's `signMessage` signs the message as given):
133
+ *
134
+ * <domain> wants you to sign in with your X1 account:
135
+ * <address>
136
+ *
137
+ * <statement> ← omitted, with its blank line, when no statement
138
+ *
139
+ * URI: <uri>
140
+ * Version: 1
141
+ * Chain: X1
142
+ * Nonce: <nonce>
143
+ * Issued At: <ISO-8601>
144
+ * Expiration Time: <ISO-8601>
145
+ *
146
+ * @throws {SignInError} `invalid-params` for a bad domain, address, statement,
147
+ * nonce, or time.
148
+ */
149
+ export declare function createSignInChallenge(params: SignInChallengeParams): SignInChallenge;
150
+ /** The fields recovered from a challenge string. */
151
+ export interface ParsedSignInChallenge {
152
+ readonly domain: string;
153
+ readonly address: string;
154
+ readonly uri: string;
155
+ readonly statement: string | null;
156
+ readonly nonce: string;
157
+ readonly issuedAt: number;
158
+ readonly expiresAt: number;
159
+ }
160
+ /**
161
+ * Parse a sign-in challenge string back to its fields, or null for anything
162
+ * that is not one — including a record-verification or control-proof challenge
163
+ * (both single-line, so they fail the header match). This is a pure structural
164
+ * parse; it proves nothing about the signature.
165
+ */
166
+ export declare function parseSignInChallenge(challenge: string): ParsedSignInChallenge | null;
167
+ export interface VerifySignInParams {
168
+ /** The challenge string the wallet signed (built by {@link createSignInChallenge}). */
169
+ readonly challenge: string;
170
+ /** The address that supposedly signed, base58. Must match the challenge's. */
171
+ readonly address: string;
172
+ /** The 64-byte ed25519 signature, base58-encoded (what a wallet's
173
+ * `signMessage` output looks like once base58'd, the common wire form). */
174
+ readonly signatureBase58: string;
175
+ /** If given, require the challenge's domain to equal this — the relying
176
+ * party's own domain. Strongly recommended: it turns the signed-in domain
177
+ * binding into an *enforced* check against phishing. */
178
+ readonly expectedDomain?: string;
179
+ /** If given, require the challenge's nonce to equal this — the nonce the RP
180
+ * issued and is about to burn. */
181
+ readonly expectedNonce?: string;
182
+ /** `Date.now()` override, ms — for tests and deterministic demos. */
183
+ readonly now?: number;
184
+ /** Allowed clock skew in seconds for issued-at/expiry. Default 60. */
185
+ readonly clockSkewSec?: number;
186
+ /** Ed25519 verifier override for runtimes whose WebCrypto lacks Ed25519.
187
+ * Defaults to the SDK's RFC-8032-strict {@link verifyEd25519Strict}. */
188
+ readonly verifyEd25519?: (publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => Promise<boolean>;
189
+ }
190
+ export type SignInVerification = {
191
+ readonly ok: true;
192
+ /** The verified signer, base58 (canonical spelling). */
193
+ readonly address: string;
194
+ readonly domain: string;
195
+ readonly nonce: string;
196
+ readonly issuedAt: number;
197
+ readonly expiresAt: number;
198
+ } | {
199
+ readonly ok: false;
200
+ readonly reason: SignInFailureReason;
201
+ readonly message: string;
202
+ };
203
+ /**
204
+ * Verify a sign-in proof: the signature is a valid ed25519 signature over the
205
+ * exact challenge bytes for `address`, the address is the one bound in the
206
+ * challenge, and `now` is inside `[issuedAt, expiresAt]` (± skew). Optionally
207
+ * enforces `expectedDomain` / `expectedNonce`.
208
+ *
209
+ * Verification outcomes come back as `{ ok: false, reason }` — this function
210
+ * never throws for one. It does NOT touch the chain: it proves only that the
211
+ * wallet signed. Whether that wallet currently owns a handle is a separate,
212
+ * on-chain question answered by {@link resolveIdentity}.
213
+ *
214
+ * Replay protection is nonce + expiry, and both need the relying party: pass
215
+ * `expectedNonce` (and never accept the same nonce twice), and keep the expiry
216
+ * window short. The domain binding needs `expectedDomain` to become a check.
217
+ */
218
+ export declare function verifySignIn(params: VerifySignInParams): Promise<SignInVerification>;
219
+ /** One live "verified by [platform]" signal on the signed-in handle. */
220
+ export interface IdentityVerification {
221
+ /** Platform slug (`"x"`, `"discord"`, `"github"`, `"telegram"`) or the legacy
222
+ * `"dns"` / `"social"` kind names, or null for a kind this SDK does not know. */
223
+ readonly platform: string | null;
224
+ /** Raw attestation kind byte. */
225
+ readonly kind: number;
226
+ /** Unix seconds the attestation was (last) stamped. */
227
+ readonly attestedAt: number;
228
+ }
229
+ /** The friendly identity behind an address. */
230
+ export interface ResolvedIdentity {
231
+ /** The address resolved, base58 (canonical). */
232
+ readonly address: string;
233
+ /** The address's primary `@handle` (canonical, no `@`), or null when it has
234
+ * no trustworthy handle primary. An X1NS-only primary comes back as null
235
+ * here — this is the @handle identity surface. */
236
+ readonly handle: string | null;
237
+ /** Ready-to-render label: `@<handle>` or null. */
238
+ readonly display: string | null;
239
+ /** Whether the handle carries at least one LIVE verification (the blue-tick
240
+ * condition, #7145). False when there is no handle. Trust caveat: a true
241
+ * means x1id's rotatable attestor key vouched for an off-chain review — read
242
+ * it as "verified by x1id", not trustless proof. */
243
+ readonly verified: boolean;
244
+ /** The live verifications, if any. */
245
+ readonly verifications: readonly IdentityVerification[];
246
+ }
247
+ export interface IdentityConfig {
248
+ /** X1 RPC endpoint. */
249
+ readonly rpcUrl: string;
250
+ /** The WASM resolver the app already loaded (needed for PDA derivation). */
251
+ readonly wasm: import("./wasm.js").WasmResolver;
252
+ /** Optional fetch override for testing or custom transport. */
253
+ readonly fetchImpl?: typeof fetch;
254
+ /** @handle registry program id. Defaults to the canonical X1 deployment. */
255
+ readonly handleProgramId?: string;
256
+ /** Resolver cache TTL in ms (passed through to `createResolver`). */
257
+ readonly cacheTtlMs?: number;
258
+ }
259
+ /**
260
+ * Reverse-resolve an address to its primary `@handle` and verification summary.
261
+ *
262
+ * Reuses the SDK's own `createResolver().reverse()` — with every staleness and
263
+ * current-authority rule it enforces — for the handle, then
264
+ * {@link import("./attestation.js").fetchAttestations} for the "verified by"
265
+ * summary, read with the same epoch/`records_cleared_at` staleness rule the
266
+ * rest of the SDK applies. Nothing here re-derives a PDA or re-checks ownership
267
+ * by hand.
268
+ *
269
+ * @throws {import("./types.js").ResolveError} `rpc-error` for transport
270
+ * problems (a handle with no primary is not an error — `handle` is null).
271
+ */
272
+ export declare function resolveIdentity(cfg: IdentityConfig, address: string): Promise<ResolvedIdentity>;
273
+ export interface SignInWithX1IDParams extends IdentityConfig {
274
+ /** The connected wallet's `signMessage`: signs the raw message bytes and
275
+ * returns the 64-byte ed25519 signature. Matches the X1ID app's wallet
276
+ * adapter (`app/src/lib/wallet` normalizes every provider to this shape). */
277
+ readonly signMessage: (message: Uint8Array) => Promise<Uint8Array>;
278
+ /** The connected wallet's address, base58 — known from wallet-connect before
279
+ * signing, bound into the challenge and used to verify. */
280
+ readonly address: string;
281
+ /** The relying party's domain. */
282
+ readonly domain: string;
283
+ readonly statement?: string;
284
+ readonly uri?: string;
285
+ readonly nonce?: string;
286
+ readonly issuedAt?: number;
287
+ readonly expiresInSec?: number;
288
+ /** Throw `no-handle` when the wallet has no primary @handle. Default true —
289
+ * "Sign in with X1ID" implies an X1ID. Set false to allow handle-less
290
+ * sign-in (returns `handle: null`). */
291
+ readonly requireHandle?: boolean;
292
+ readonly clockSkewSec?: number;
293
+ readonly now?: number;
294
+ readonly verifyEd25519?: VerifySignInParams["verifyEd25519"];
295
+ }
296
+ export interface SignInIdentity extends ResolvedIdentity {
297
+ /** The exact challenge that was signed — store it with the session if you
298
+ * want an audit trail of what the user agreed to. */
299
+ readonly challenge: string;
300
+ readonly nonce: string;
301
+ readonly issuedAt: number;
302
+ readonly expiresAt: number;
303
+ }
304
+ /**
305
+ * The whole flow in one call: build a domain-bound challenge, have the wallet
306
+ * sign it, verify the signature, and resolve the address to its `@handle`
307
+ * identity. Returns the resolved identity on success.
308
+ *
309
+ * The relying party still owns the *session*: bind the returned identity to
310
+ * your own session and re-check ownership periodically — this call proves the
311
+ * signature at THIS moment, nothing longer-lived.
312
+ *
313
+ * @throws {SignInError} `wallet-error` if the wallet rejects/fails signing, a
314
+ * verification `SignInFailureReason` code if the signature does not verify, or
315
+ * `no-handle` when `requireHandle` (default) and the wallet has no primary
316
+ * handle.
317
+ */
318
+ export declare function signInWithX1ID(params: SignInWithX1IDParams): Promise<SignInIdentity>;
package/dist/signin.js ADDED
Binary file
package/dist/x402.d.ts CHANGED
@@ -14,19 +14,26 @@
14
14
  * 2026-09-22 — NOT invented. See `docs/x402.md` for the full design,
15
15
  * including what's explicitly OUT of scope here (a facilitator).
16
16
  *
17
- * # The one x1id-specific choice: the network identifier
17
+ * # The network identifier — CAIP-30, for OSS interop (#8325)
18
18
  *
19
- * x402's Solana networks use `"solana:<genesis-hash>"` (CAIP-2-shaped). X1
20
- * is SVM-compatible but its OWN chain — not Solana — so it gets its own
21
- * namespace under the identical convention: `"x1:<genesis-hash>"`. This is
22
- * NOT an x402-foundation-registered network; it's a principled extension of
23
- * their own pattern to a new SVM-compatible chain, not a claim of official
24
- * support. {@link X1_TESTNET_NETWORK} is the live testnet genesis hash,
25
- * verified against `getGenesisHash` 2026-09-22.
19
+ * x402's SVM networks use `"solana:<first-32-of-genesis>"` (CAIP-30: the
20
+ * `solana:` namespace + the first 32 base58 chars of the genesis hash). X1 is
21
+ * its own SVM chain, but `@x402/core`'s reference client/facilitator only route
22
+ * the `solana:*` scheme family — so to let ANY standard, unmodified x402 client
23
+ * or wallet pay an x1id agent out of the box, X1 testnet uses the CAIP-30 form
24
+ * keyed on X1's genesis. (Fortiswap's self-hosted X1 facilitator uses the
25
+ * identical id.) An earlier release used a principled `"x1:<full-genesis>"`
26
+ * extension; it was OSS-incompatible, so this migrated to CAIP-30 —
27
+ * {@link X1_LEGACY_TESTNET_NETWORK} is kept only for reading older manifests.
28
+ * Verified against `getGenesisHash` (full hash
29
+ * `C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho`).
26
30
  */
27
- /** X1 testnet's x402 network identifier (`"x1:<genesis-hash>"`, verified
28
- * live 2026-09-22 via `getGenesisHash`). */
29
- export declare const X1_TESTNET_NETWORK = "x1:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho";
31
+ /** X1 testnet's x402 network id — CAIP-30 `"solana:" + first 32 chars of the
32
+ * genesis hash — so unmodified OSS x402 clients route to it (#8325). */
33
+ export declare const X1_TESTNET_NETWORK = "solana:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWw";
34
+ /** The pre-#8325 `"x1:<full-genesis>"` id. DEPRECATED — kept only so a reader
35
+ * can still recognise manifests published before the CAIP-30 migration. */
36
+ export declare const X1_LEGACY_TESTNET_NETWORK = "x1:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho";
30
37
  /** The x402 scheme x1id implements — "exact": pay a fixed amount of a named
31
38
  * asset to a named recipient. (x402 also defines "upto" for usage-based
32
39
  * pricing; not implemented here.) */
package/dist/x402.js CHANGED
@@ -14,19 +14,26 @@
14
14
  * 2026-09-22 — NOT invented. See `docs/x402.md` for the full design,
15
15
  * including what's explicitly OUT of scope here (a facilitator).
16
16
  *
17
- * # The one x1id-specific choice: the network identifier
17
+ * # The network identifier — CAIP-30, for OSS interop (#8325)
18
18
  *
19
- * x402's Solana networks use `"solana:<genesis-hash>"` (CAIP-2-shaped). X1
20
- * is SVM-compatible but its OWN chain — not Solana — so it gets its own
21
- * namespace under the identical convention: `"x1:<genesis-hash>"`. This is
22
- * NOT an x402-foundation-registered network; it's a principled extension of
23
- * their own pattern to a new SVM-compatible chain, not a claim of official
24
- * support. {@link X1_TESTNET_NETWORK} is the live testnet genesis hash,
25
- * verified against `getGenesisHash` 2026-09-22.
19
+ * x402's SVM networks use `"solana:<first-32-of-genesis>"` (CAIP-30: the
20
+ * `solana:` namespace + the first 32 base58 chars of the genesis hash). X1 is
21
+ * its own SVM chain, but `@x402/core`'s reference client/facilitator only route
22
+ * the `solana:*` scheme family — so to let ANY standard, unmodified x402 client
23
+ * or wallet pay an x1id agent out of the box, X1 testnet uses the CAIP-30 form
24
+ * keyed on X1's genesis. (Fortiswap's self-hosted X1 facilitator uses the
25
+ * identical id.) An earlier release used a principled `"x1:<full-genesis>"`
26
+ * extension; it was OSS-incompatible, so this migrated to CAIP-30 —
27
+ * {@link X1_LEGACY_TESTNET_NETWORK} is kept only for reading older manifests.
28
+ * Verified against `getGenesisHash` (full hash
29
+ * `C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho`).
26
30
  */
27
- /** X1 testnet's x402 network identifier (`"x1:<genesis-hash>"`, verified
28
- * live 2026-09-22 via `getGenesisHash`). */
29
- export const X1_TESTNET_NETWORK = "x1:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho";
31
+ /** X1 testnet's x402 network id — CAIP-30 `"solana:" + first 32 chars of the
32
+ * genesis hash — so unmodified OSS x402 clients route to it (#8325). */
33
+ export const X1_TESTNET_NETWORK = "solana:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWw";
34
+ /** The pre-#8325 `"x1:<full-genesis>"` id. DEPRECATED — kept only so a reader
35
+ * can still recognise manifests published before the CAIP-30 migration. */
36
+ export const X1_LEGACY_TESTNET_NETWORK = "x1:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho";
30
37
  /** The x402 scheme x1id implements — "exact": pay a fixed amount of a named
31
38
  * asset to a named recipient. (x402 also defines "upto" for usage-based
32
39
  * pricing; not implemented here.) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.8.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",
@@ -25,7 +25,7 @@
25
25
  "build": "tsc -p tsconfig.json",
26
26
  "test": "node --test test/*.test.js",
27
27
  "lint:package": "publint",
28
- "prepublishOnly": "npm run build"
28
+ "prepublishOnly": "cargo build --release -p x1-resolve-wasm --target wasm32-unknown-unknown --manifest-path ../Cargo.toml && node ../web/verify-wasm.mjs wasm/x1_resolve_wasm.wasm ../target/wasm32-unknown-unknown/release/x1_resolve_wasm.wasm && npm run build"
29
29
  },
30
30
  "keywords": [
31
31
  "x1",
@@ -59,12 +59,5 @@
59
59
  "@solana/web3.js": {
60
60
  "optional": true
61
61
  }
62
- },
63
- "repository": {
64
- "type": "git",
65
- "url": "git+https://github.com/fortiblox/x1id-sdk.git"
66
- },
67
- "bugs": {
68
- "url": "https://x1id.io"
69
62
  }
70
63
  }
Binary file
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Fortiblox
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.