@x1id/resolve 0.19.2 → 0.22.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/siws.js ADDED
@@ -0,0 +1,607 @@
1
+ /**
2
+ * Sign In With Solana (SIWS, the Wallet Standard `solana:signIn` feature)
3
+ * against the hosted X1ID Connect API (`POST /v1/auth/challenge` +
4
+ * `POST /v1/auth/verify-signin`).
5
+ *
6
+ * # Why SIWS
7
+ *
8
+ * `solana:signIn` lets the WALLET build and display a structured sign-in
9
+ * message (domain, address, statement, nonce, ...) instead of an opaque blob.
10
+ * It is also the most reliable wallet-control proof on X1 in practice:
11
+ * Backpack's injected script hijacks OTHER wallets' `signMessage` but not
12
+ * `signIn`. This is the same path developers.x1id.io uses
13
+ * (portal/src/lib/wallet/standardWallet.ts) — the input fields here mirror it.
14
+ *
15
+ * # Flow
16
+ *
17
+ * 1. `requestSignInChallenge` — `POST /v1/auth/challenge {wallet, mode:"signin"}`
18
+ * returns a single-use `nonce` bound to your API key and the wallet.
19
+ * 2. `buildSignInInput` — the `solana:signIn` input naming YOUR domain, the
20
+ * wallet address and that nonce.
21
+ * 3. the wallet signs the SIWS message it renders from that input.
22
+ * 4. `verifySolanaSignIn` — `POST /v1/auth/verify-signin` with the exact
23
+ * signed bytes (base64) and the 64-byte signature (base58). dev-api checks
24
+ * line 1 (`<domain> wants you to sign in with your Solana account:`, domain
25
+ * on its allowlist), line 2 (the challenged wallet), a `Nonce: <nonce>`
26
+ * line, and the ed25519 signature (raw bytes or the SIMD-0048 v0
27
+ * off-chain envelope). It returns a Bearer {@link ConnectSession}.
28
+ *
29
+ * `signInWithSolana` does all four. Framework-agnostic: it takes any Wallet
30
+ * Standard wallet object, with no wallet-adapter or web3.js dependency.
31
+ *
32
+ * # Fallbacks: `signInWithWallet`
33
+ *
34
+ * Not every wallet implements `solana:signIn`, and some implement it badly.
35
+ * `signInWithWallet` tries, in the same order as developers.x1id.io
36
+ * (portal/src/lib/wallet/standardWallet.ts `proveForPortalSession`):
37
+ *
38
+ * 1. SIWS (`solana:signIn` → `/v1/auth/verify-signin`);
39
+ * 2. a signed, NEVER-SENT memo transaction (`solana:signTransaction` →
40
+ * `/v1/auth/verify-tx`) whose only instruction is an SPL-Memo carrying the
41
+ * server's audience-bound `x1id-verify:<audience>:<nonce>` verbatim. With
42
+ * `signinFeePayer` set it uses dev-api's no-fee layout (display-only fee
43
+ * payer at signer index 0, the user at index 1); otherwise the user is the
44
+ * fee payer. Works for Ledger, which refuses message signing;
45
+ * 3. `solana:signMessage` over the server's v2 challenge string
46
+ * (`x1id-connect:v2:wallet-control:<audience>:<address>:<nonce>`) →
47
+ * `/v1/auth/verify`.
48
+ *
49
+ * A user rejection or a wallet timeout stops the chain (the user said no; a
50
+ * second prompt would be hostile). Any other failure moves to the next path.
51
+ * Challenge strings and memos are always signed exactly as the server issued
52
+ * them, never rebuilt here (R3-L2P-1 audience binding).
53
+ *
54
+ * The session proves WALLET CONTROL (source `"wallet"`); the wallet's primary
55
+ * `@handle`, if any, is reported as metadata and re-resolved on every
56
+ * `GET /v1/auth/session`.
57
+ *
58
+ * Not to be confused with {@link import("./signin.js").signInWithX1ID}: that is
59
+ * the server-less, relying-party-verified `@handle` proof. This module talks to
60
+ * the hosted API and gets a session back.
61
+ */
62
+ import { decodeBase58, encodeBase58 } from "./base58.js";
63
+ import { DEFAULT_CONNECT_BASE_URL, MEMO_PROGRAM_ID, PLACEHOLDER_BLOCKHASH, VERIFY_TX_MEMO_PREFIX, } from "./connect.js";
64
+ export class SiwsError extends Error {
65
+ code;
66
+ serverCode;
67
+ status;
68
+ constructor(code, message,
69
+ /** dev-api's own error code (e.g. `CHALLENGE_EXPIRED`), when it sent one. */
70
+ serverCode,
71
+ /** HTTP status, when the error came from a response. */
72
+ status) {
73
+ super(message);
74
+ this.code = code;
75
+ this.serverCode = serverCode;
76
+ this.status = status;
77
+ this.name = "SiwsError";
78
+ }
79
+ }
80
+ /** Map a dev-api error response onto a {@link SiwsErrorCode}. */
81
+ export function siwsErrorCodeFor(serverCode, message) {
82
+ const c = (serverCode ?? "").toUpperCase();
83
+ if (c === "CHALLENGE_EXPIRED" || c === "CHALLENGE_NOT_FOUND")
84
+ return "nonce-expired";
85
+ // dev-api reports a disallowed domain as CONTROL_PROOF_INVALID with
86
+ // `... which is not allowed here` (or, for a portal key, `... not this
87
+ // application's domain`); a dedicated code (any *DOMAIN* code) is accepted
88
+ // too so a future, more specific server code maps the same way.
89
+ if (c.includes("DOMAIN"))
90
+ return "domain-not-allowed";
91
+ if (c === "CONTROL_PROOF_INVALID" && /not allowed|not this application's domain/i.test(message ?? "")) {
92
+ return "domain-not-allowed";
93
+ }
94
+ if (c === "REQUEST_FAILED" || c === "BAD_RESPONSE" || c === "NO_FETCH")
95
+ return "network";
96
+ return "api-error";
97
+ }
98
+ /** True if `wallet` implements `solana:signIn`. */
99
+ export function walletSupportsSignIn(wallet) {
100
+ const f = wallet?.features?.["solana:signIn"];
101
+ return typeof f?.signIn === "function";
102
+ }
103
+ // -----------------------------------------------------------------------------
104
+ // Message input
105
+ // -----------------------------------------------------------------------------
106
+ /** The SIWS message line-1 suffix dev-api's verifier matches on. */
107
+ export const SIWS_HEADER_SUFFIX = " wants you to sign in with your Solana account:";
108
+ /** Default statement shown in the wallet. */
109
+ export const DEFAULT_SIWS_STATEMENT = "Sign in with your wallet. This does not send a transaction.";
110
+ // Any control char (incl. CR/LF) would let a field inject extra SIWS lines.
111
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/;
112
+ function locationField(k) {
113
+ const loc = globalThis.location;
114
+ const v = loc?.[k];
115
+ return typeof v === "string" && v ? v : undefined;
116
+ }
117
+ /**
118
+ * Build the Wallet Standard `solana:signIn` input for an X1ID challenge —
119
+ * exactly the fields developers.x1id.io sends: domain, address, statement, uri,
120
+ * version `"1"`, nonce, issuedAt.
121
+ */
122
+ export function buildSignInInput(params) {
123
+ const domain = params.domain ?? locationField("host");
124
+ if (!domain) {
125
+ throw new SiwsError("invalid-params", "`domain` is required outside a browser (no globalThis.location.host)");
126
+ }
127
+ if (CONTROL_RE.test(domain) || /\s/.test(domain)) {
128
+ throw new SiwsError("invalid-params", "`domain` must be a bare host (no whitespace or control characters)");
129
+ }
130
+ if (!params.address || CONTROL_RE.test(params.address))
131
+ throw new SiwsError("invalid-params", "`address` is required");
132
+ if (!/^[0-9A-Za-z]{1,64}$/.test(params.nonce ?? "")) {
133
+ throw new SiwsError("invalid-params", "`nonce` must be the alphanumeric nonce from the challenge");
134
+ }
135
+ const statement = params.statement ?? DEFAULT_SIWS_STATEMENT;
136
+ if (CONTROL_RE.test(statement)) {
137
+ throw new SiwsError("invalid-params", "`statement` must be a single line (no control characters)");
138
+ }
139
+ const uri = params.uri ?? locationField("origin") ?? `https://${domain}`;
140
+ if (CONTROL_RE.test(uri))
141
+ throw new SiwsError("invalid-params", "`uri` must not contain control characters");
142
+ const issuedAt = params.issuedAt ?? new Date().toISOString();
143
+ if (CONTROL_RE.test(issuedAt))
144
+ throw new SiwsError("invalid-params", "`issuedAt` must not contain control characters");
145
+ return {
146
+ domain,
147
+ address: params.address,
148
+ ...(statement ? { statement } : {}),
149
+ uri,
150
+ version: "1",
151
+ nonce: params.nonce,
152
+ issuedAt,
153
+ };
154
+ }
155
+ /**
156
+ * Render a `solana:signIn` input as SIWS message text, per the SIWS spec
157
+ * (identical to `createSignInMessageText` in @solana/wallet-standard-util).
158
+ * Wallets render this themselves; it is exported for tests, previews, and
159
+ * server-side expectations.
160
+ */
161
+ export function buildSignInMessageText(input) {
162
+ let message = `${input.domain}${SIWS_HEADER_SUFFIX}\n`;
163
+ message += input.address;
164
+ if (input.statement)
165
+ message += `\n\n${input.statement}`;
166
+ const fields = [];
167
+ if (input.uri)
168
+ fields.push(`URI: ${input.uri}`);
169
+ if (input.version)
170
+ fields.push(`Version: ${input.version}`);
171
+ if (input.chainId)
172
+ fields.push(`Chain ID: ${input.chainId}`);
173
+ if (input.nonce)
174
+ fields.push(`Nonce: ${input.nonce}`);
175
+ if (input.issuedAt)
176
+ fields.push(`Issued At: ${input.issuedAt}`);
177
+ if (input.expirationTime)
178
+ fields.push(`Expiration Time: ${input.expirationTime}`);
179
+ if (input.notBefore)
180
+ fields.push(`Not Before: ${input.notBefore}`);
181
+ if (input.requestId)
182
+ fields.push(`Request ID: ${input.requestId}`);
183
+ if (input.resources) {
184
+ fields.push("Resources:");
185
+ for (const r of input.resources)
186
+ fields.push(`- ${r}`);
187
+ }
188
+ if (fields.length)
189
+ message += `\n\n${fields.join("\n")}`;
190
+ return message;
191
+ }
192
+ function apiBaseOf(cfg) {
193
+ return (cfg.apiBase ?? DEFAULT_CONNECT_BASE_URL).replace(/\/+$/, "").replace(/\/v1$/, "");
194
+ }
195
+ async function postJson(cfg, path, body) {
196
+ if (!cfg.apiKey)
197
+ throw new SiwsError("invalid-params", "an X1ID apiKey is required");
198
+ const doFetch = cfg.fetchImpl ?? globalThis.fetch;
199
+ if (typeof doFetch !== "function")
200
+ throw new SiwsError("network", "no fetch available; pass fetchImpl");
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 15_000);
203
+ let res;
204
+ try {
205
+ res = await doFetch(`${apiBaseOf(cfg)}${path}`, {
206
+ method: "POST",
207
+ headers: { "content-type": "application/json", "x-api-key": cfg.apiKey },
208
+ body: JSON.stringify(body),
209
+ signal: controller.signal,
210
+ });
211
+ }
212
+ catch (e) {
213
+ throw new SiwsError("network", `request to ${path} failed: ${e instanceof Error ? e.message : String(e)}`);
214
+ }
215
+ finally {
216
+ clearTimeout(timer);
217
+ }
218
+ let parsed;
219
+ try {
220
+ parsed = await res.json();
221
+ }
222
+ catch {
223
+ throw new SiwsError("network", `${path} returned non-JSON (HTTP ${res.status})`, undefined, res.status);
224
+ }
225
+ if (!res.ok) {
226
+ const b = (parsed ?? {});
227
+ const serverCode = typeof b.code === "string" ? b.code : undefined;
228
+ const message = typeof b.message === "string" ? b.message : `${path} returned HTTP ${res.status}`;
229
+ throw new SiwsError(siwsErrorCodeFor(serverCode, message), message, serverCode, res.status);
230
+ }
231
+ return parsed;
232
+ }
233
+ /** `POST /v1/auth/challenge {wallet, mode: "signin"}` — a single-use nonce. */
234
+ export async function requestSignInChallenge(params) {
235
+ return requestConnectChallenge({ ...params, mode: "signin" });
236
+ }
237
+ /**
238
+ * `POST /v1/auth/challenge {wallet, mode?}` for any wallet-proof mode:
239
+ * `"signin"` (SIWS), `"tx"` (memo transaction; the response carries `memo`),
240
+ * or omitted (signMessage over `challenge`).
241
+ */
242
+ export async function requestConnectChallenge(params) {
243
+ if (!params.wallet)
244
+ throw new SiwsError("invalid-params", "`wallet` (base58 address) is required");
245
+ const r = await postJson(params, "/v1/auth/challenge", {
246
+ wallet: params.wallet,
247
+ ...(params.mode ? { mode: params.mode } : {}),
248
+ });
249
+ if (!r || typeof r.nonce !== "string" || !r.nonce || typeof r.challenge !== "string") {
250
+ throw new SiwsError("api-error", "challenge response carried no nonce/challenge");
251
+ }
252
+ return r;
253
+ }
254
+ function bytesToBase64(bytes) {
255
+ let bin = "";
256
+ for (let i = 0; i < bytes.length; i += 0x8000) {
257
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
258
+ }
259
+ return btoa(bin);
260
+ }
261
+ function toBytes(v) {
262
+ if (v instanceof Uint8Array)
263
+ return v;
264
+ if (ArrayBuffer.isView(v))
265
+ return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
266
+ if (Array.isArray(v) && v.every((b) => Number.isInteger(b) && b >= 0 && b < 256))
267
+ return Uint8Array.from(v);
268
+ return null;
269
+ }
270
+ /**
271
+ * `POST /v1/auth/verify-signin` — the raw verify call. Accepts the wallet's
272
+ * byte outputs and encodes them as dev-api expects (signedMessage base64,
273
+ * signature base58), or pre-encoded strings passed through as-is.
274
+ */
275
+ export async function verifySolanaSignIn(params) {
276
+ const signedMessage = typeof params.signedMessage === "string" ? params.signedMessage : bytesToBase64(params.signedMessage);
277
+ let signature;
278
+ if (typeof params.signature === "string") {
279
+ signature = params.signature;
280
+ }
281
+ else {
282
+ if (params.signature.length !== 64) {
283
+ throw new SiwsError("wallet-error", `expected a 64-byte ed25519 signature, got ${params.signature.length} bytes`);
284
+ }
285
+ signature = encodeBase58(params.signature);
286
+ }
287
+ const body = { nonce: params.nonce, signedMessage, signature };
288
+ return postJson(params, "/v1/auth/verify-signin", body);
289
+ }
290
+ // -----------------------------------------------------------------------------
291
+ // One call
292
+ // -----------------------------------------------------------------------------
293
+ /** `POST /v1/auth/verify-tx` with the fully signed memo transaction (base64). */
294
+ export async function verifySignInTransaction(params) {
295
+ const signedTransaction = typeof params.signedTransaction === "string" ? params.signedTransaction : bytesToBase64(params.signedTransaction);
296
+ return postJson(params, "/v1/auth/verify-tx", { nonce: params.nonce, signedTransaction });
297
+ }
298
+ /** `POST /v1/auth/verify` with a signMessage signature over the challenge
299
+ * string (signature base58, `signer` = the wallet address). */
300
+ export async function verifySignInMessage(params) {
301
+ let signature;
302
+ if (typeof params.signature === "string") {
303
+ signature = params.signature;
304
+ }
305
+ else {
306
+ if (params.signature.length !== 64) {
307
+ throw new SiwsError("wallet-error", `expected a 64-byte ed25519 signature, got ${params.signature.length} bytes`);
308
+ }
309
+ signature = encodeBase58(params.signature);
310
+ }
311
+ return postJson(params, "/v1/auth/verify", { nonce: params.nonce, signature, signer: params.signer });
312
+ }
313
+ // -----------------------------------------------------------------------------
314
+ // Memo transaction (no web3.js)
315
+ // -----------------------------------------------------------------------------
316
+ /** X1ID's display-only sign-in fee payer on X1 MAINNET (System-owned, tiny
317
+ * balance so wallet simulations pass; it never signs and the tx is never
318
+ * sent). Pass it as `signinFeePayer` only against a deployment whose
319
+ * `CONNECT_SIGNIN_FEE_PAYER` is this address (api.x1id.io on mainnet). */
320
+ export const X1ID_MAINNET_SIGNIN_FEE_PAYER = "9kFEzXbNBJLTsvZwrTAcpxr6iLRqHPyu9yp8K4m2YwC3";
321
+ function compactU16(n) {
322
+ const out = [];
323
+ let v = n;
324
+ for (;;) {
325
+ const b = v & 0x7f;
326
+ v >>= 7;
327
+ if (v === 0) {
328
+ out.push(b);
329
+ return out;
330
+ }
331
+ out.push(b | 0x80);
332
+ }
333
+ }
334
+ /**
335
+ * The unsigned, serialized LEGACY memo-only transaction the wallet signs for
336
+ * `/v1/auth/verify-tx`: exactly one SPL-Memo instruction carrying `memo`, with
337
+ * the user as the memo's signer. Byte-identical to the portal's
338
+ * `buildMemoTx` (portal/src/lib/wallet/memoTx.ts). It is never broadcast.
339
+ */
340
+ export function buildSignInMemoTransaction(params) {
341
+ const u = decodeBase58(params.user, 32);
342
+ const m = decodeBase58(MEMO_PROGRAM_ID, 32);
343
+ const bh = decodeBase58(params.blockhash ?? PLACEHOLDER_BLOCKHASH, 32);
344
+ const p = params.feePayer ? decodeBase58(params.feePayer, 32) : null;
345
+ if (!u)
346
+ throw new SiwsError("invalid-params", "`user` must be a base58 32-byte address");
347
+ if (!m || !bh)
348
+ throw new SiwsError("invalid-params", "`blockhash` must be base58 of 32 bytes");
349
+ if (params.feePayer && !p)
350
+ throw new SiwsError("invalid-params", "`feePayer` must be a base58 32-byte address");
351
+ const data = new TextEncoder().encode(params.memo);
352
+ const msg = p
353
+ ? [
354
+ // 2 signers (payer, user), 1 readonly signed (user), 1 readonly unsigned (memo program)
355
+ 2, 1, 1,
356
+ ...compactU16(3), ...p, ...u, ...m,
357
+ ...bh,
358
+ ...compactU16(1), 2, ...compactU16(1), 1, ...compactU16(data.length), ...data,
359
+ ]
360
+ : [
361
+ 1, 0, 1,
362
+ ...compactU16(2), ...u, ...m,
363
+ ...bh,
364
+ ...compactU16(1), 1, ...compactU16(1), 0, ...compactU16(data.length), ...data,
365
+ ];
366
+ const sigCount = p ? 2 : 1;
367
+ return new Uint8Array([...compactU16(sigCount), ...new Uint8Array(64 * sigCount), ...msg]);
368
+ }
369
+ async function latestBlockhash(rpcUrl, doFetch) {
370
+ let bh;
371
+ try {
372
+ const res = await doFetch(rpcUrl, {
373
+ method: "POST",
374
+ headers: { "content-type": "application/json" },
375
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestBlockhash", params: [{ commitment: "confirmed" }] }),
376
+ });
377
+ bh = (await res.json())?.result?.value?.blockhash;
378
+ }
379
+ catch (e) {
380
+ throw new SiwsError("network", `could not fetch a recent blockhash: ${e instanceof Error ? e.message : String(e)}`);
381
+ }
382
+ if (typeof bh !== "string")
383
+ throw new SiwsError("network", "the RPC returned no blockhash");
384
+ return bh;
385
+ }
386
+ function isUserRejection(e) {
387
+ const err = e;
388
+ const m = `${(err && (err.message || err.reason || err.name)) ?? ""}`;
389
+ return err?.code === 4001 || /reject|denied|cancel/i.test(m);
390
+ }
391
+ /** The extension's background worker was asleep/unreachable (not a reject). */
392
+ function isExtensionUnreachable(e) {
393
+ const msg = String(e?.message ?? e ?? "");
394
+ return /unexpected error|receiving end does not exist|could not establish connection|extension context|disconnected port/i.test(msg);
395
+ }
396
+ function withTimeout(p, ms) {
397
+ let t;
398
+ return Promise.race([
399
+ p.finally(() => clearTimeout(t)),
400
+ new Promise((_, rej) => {
401
+ t = setTimeout(() => rej(new SiwsError("wallet-timeout", "the wallet did not respond")), ms);
402
+ }),
403
+ ]);
404
+ }
405
+ function walletFailure(e, what) {
406
+ if (e instanceof SiwsError)
407
+ return e;
408
+ if (isUserRejection(e))
409
+ return new SiwsError("user-rejected", "the request was rejected in the wallet");
410
+ const msg = e?.message;
411
+ const err = new SiwsError("wallet-error", `${what}: ${typeof msg === "string" && msg ? msg : String(e)}`);
412
+ if (isExtensionUnreachable(e))
413
+ err.unreachable = true;
414
+ return err;
415
+ }
416
+ /** Stop the fallback chain: the user said no, or the wallet is gone. */
417
+ function isFinal(e) {
418
+ return e.code === "user-rejected" || e.code === "wallet-timeout" || e.unreachable === true;
419
+ }
420
+ async function resolveAccount(wallet, account, timeoutMs) {
421
+ let acct = account ?? wallet.accounts?.[0];
422
+ if (acct)
423
+ return acct;
424
+ const connectFeat = wallet.features["standard:connect"];
425
+ if (typeof connectFeat?.connect !== "function") {
426
+ throw new SiwsError("wallet-error", "the wallet has no connected account and no standard:connect");
427
+ }
428
+ try {
429
+ const out = await withTimeout(connectFeat.connect(), timeoutMs);
430
+ acct = out?.accounts?.[0] ?? wallet.accounts?.[0];
431
+ }
432
+ catch (e) {
433
+ throw walletFailure(e, "wallet connect failed");
434
+ }
435
+ if (!acct)
436
+ throw new SiwsError("wallet-error", "the wallet connected without an account");
437
+ return acct;
438
+ }
439
+ function sigBytes(v) {
440
+ const o = v;
441
+ const b = toBytes(v) ?? (o && typeof o === "object" ? toBytes(o.signature) : null);
442
+ return b && b.length === 64 ? b : null;
443
+ }
444
+ // --- path 1: SIWS ------------------------------------------------------------
445
+ async function proveWithSignIn(params, account, timeoutMs) {
446
+ const signInFeat = params.wallet.features["solana:signIn"];
447
+ const address = account.address;
448
+ // Validate domain/statement/uri BEFORE spending a challenge.
449
+ const input = buildSignInInput({
450
+ address,
451
+ nonce: "0",
452
+ ...(params.domain !== undefined ? { domain: params.domain } : {}),
453
+ ...(params.statement !== undefined ? { statement: params.statement } : {}),
454
+ ...(params.uri !== undefined ? { uri: params.uri } : {}),
455
+ });
456
+ const challenge = await requestSignInChallenge({ ...params, wallet: address });
457
+ let out;
458
+ try {
459
+ [out] = await withTimeout(signInFeat.signIn({ ...input, nonce: challenge.nonce }), timeoutMs);
460
+ }
461
+ catch (e) {
462
+ throw walletFailure(e, "solana:signIn failed");
463
+ }
464
+ const signed = toBytes(out?.signedMessage);
465
+ const sig = toBytes(out?.signature);
466
+ if (!out || !signed || !sig)
467
+ throw new SiwsError("wallet-error", "the wallet returned no sign-in signature");
468
+ if (sig.length !== 64)
469
+ throw new SiwsError("wallet-error", `the wallet returned a ${sig.length}-byte signature; expected 64`);
470
+ if (out.account?.address && out.account.address !== address) {
471
+ throw new SiwsError("wallet-error", "the wallet signed in with a different account than the challenge was issued for");
472
+ }
473
+ return verifySolanaSignIn({ ...params, nonce: challenge.nonce, signedMessage: signed, signature: sig });
474
+ }
475
+ /** True if `memo` is a verify-tx memo for this nonce that the client may sign:
476
+ * the audience-bound `x1id-verify:<audience>:<nonce>` (matching `audience`
477
+ * when the server sent one), or the v2 wallet-control challenge for this
478
+ * wallet + nonce (dev-api's accepted alternative when no memo is returned). */
479
+ function acceptableMemo(memo, ch, address) {
480
+ if (ch.audience !== undefined && memo === `${VERIFY_TX_MEMO_PREFIX}${ch.audience}:${ch.nonce}`)
481
+ return true;
482
+ if (ch.audience === undefined && memo.startsWith(VERIFY_TX_MEMO_PREFIX) && memo.endsWith(`:${ch.nonce}`))
483
+ return true;
484
+ return isV2WalletControl(memo, address, ch.nonce, ch.audience);
485
+ }
486
+ function isV2WalletControl(s, address, nonce, audience) {
487
+ if (audience !== undefined)
488
+ return s === `x1id-connect:v2:wallet-control:${audience}:${address}:${nonce}`;
489
+ return s.startsWith("x1id-connect:v2:wallet-control:") && s.endsWith(`:${address}:${nonce}`);
490
+ }
491
+ async function proveWithMemoTx(params, account, timeoutMs) {
492
+ const txFeat = params.wallet.features["solana:signTransaction"];
493
+ const address = account.address;
494
+ const ch = await requestConnectChallenge({ ...params, wallet: address, mode: "tx" });
495
+ // Signed verbatim — never rebuilt locally (R3-L2P-1).
496
+ const memo = ch.memo ?? ch.challenge;
497
+ if (!acceptableMemo(memo, ch, address)) {
498
+ throw new SiwsError("api-error", "the challenge memo is not an audience-bound sign-in memo for this nonce; refusing to sign it");
499
+ }
500
+ const doFetch = params.fetchImpl ?? globalThis.fetch;
501
+ const blockhash = params.blockhash ?? (params.rpcUrl ? await latestBlockhash(params.rpcUrl, doFetch) : PLACEHOLDER_BLOCKHASH);
502
+ const tx = buildSignInMemoTransaction({ user: address, memo, blockhash, feePayer: params.signinFeePayer ?? null });
503
+ let signed;
504
+ try {
505
+ const [out] = await withTimeout(txFeat.signTransaction({ account, transaction: tx }), timeoutMs);
506
+ signed = toBytes(out?.signedTransaction);
507
+ }
508
+ catch (e) {
509
+ throw walletFailure(e, "solana:signTransaction failed");
510
+ }
511
+ if (!signed)
512
+ throw new SiwsError("wallet-error", "the wallet returned no signed transaction");
513
+ return verifySignInTransaction({ ...params, nonce: ch.nonce, signedTransaction: signed });
514
+ }
515
+ async function proveWithSignMessage(params, account, timeoutMs) {
516
+ const msgFeat = params.wallet.features["solana:signMessage"];
517
+ const address = account.address;
518
+ const ch = await requestConnectChallenge({ ...params, wallet: address });
519
+ // A wallet with a primary @handle gets a handle-control challenge
520
+ // (`x1id-connect:v2:handle-control:<audience>:…`); both are v2 and
521
+ // audience-bound. Refuse anything else (an unbound v1 string).
522
+ const s = ch.challenge;
523
+ const okShape = isV2WalletControl(s, address, ch.nonce, ch.audience) ||
524
+ (s.startsWith(`x1id-connect:v2:handle-control:${ch.audience ?? ""}`) && s.endsWith(`:${ch.nonce}`));
525
+ if (!okShape) {
526
+ throw new SiwsError("api-error", "the challenge is not an audience-bound v2 Connect challenge; refusing to sign it");
527
+ }
528
+ let sig;
529
+ try {
530
+ const [out] = await withTimeout(msgFeat.signMessage({ account, message: new TextEncoder().encode(s) }), timeoutMs);
531
+ sig = sigBytes(out?.signature);
532
+ }
533
+ catch (e) {
534
+ throw walletFailure(e, "solana:signMessage failed");
535
+ }
536
+ if (!sig)
537
+ throw new SiwsError("wallet-error", "the wallet did not return a 64-byte ed25519 signature");
538
+ return verifySignInMessage({ ...params, nonce: ch.nonce, signature: sig, signer: address });
539
+ }
540
+ // -----------------------------------------------------------------------------
541
+ // One call
542
+ // -----------------------------------------------------------------------------
543
+ /**
544
+ * Sign in with a Wallet Standard wallet via `solana:signIn` ONLY and return the
545
+ * X1ID Connect session. Throws {@link SiwsError} (`wallet-unsupported` when the
546
+ * wallet lacks `solana:signIn`). For automatic fallbacks use
547
+ * {@link signInWithWallet}.
548
+ *
549
+ * ```ts
550
+ * const session = await signInWithSolana({ wallet, apiKey: "x1id_..." });
551
+ * // session.token → `Authorization: Bearer …`; session.address; session.handle
552
+ * ```
553
+ */
554
+ export async function signInWithSolana(params) {
555
+ const { wallet } = params;
556
+ if (!walletSupportsSignIn(wallet)) {
557
+ throw new SiwsError("wallet-unsupported", `${wallet?.name ?? "this wallet"} does not support Sign In With Solana (solana:signIn)`);
558
+ }
559
+ const timeoutMs = params.signTimeoutMs ?? 60_000;
560
+ const account = await resolveAccount(wallet, params.account, timeoutMs);
561
+ return proveWithSignIn(params, account, timeoutMs);
562
+ }
563
+ /**
564
+ * Sign in with any Wallet Standard wallet, falling back across the three
565
+ * wallet-control proofs the hosted API accepts: SIWS → never-sent memo
566
+ * transaction → signMessage (see the module docs). A user rejection or a
567
+ * wallet timeout stops immediately; otherwise the LAST path's error is thrown
568
+ * if every path fails. `wallet-unsupported` when the wallet implements none of
569
+ * `solana:signIn`, `solana:signTransaction`, `solana:signMessage`.
570
+ */
571
+ export async function signInWithWallet(params) {
572
+ const { wallet } = params;
573
+ const features = wallet?.features ?? {};
574
+ const has = (k, fn) => typeof features[k]?.[fn] === "function";
575
+ const available = {
576
+ siws: has("solana:signIn", "signIn"),
577
+ "memo-tx": has("solana:signTransaction", "signTransaction"),
578
+ "sign-message": has("solana:signMessage", "signMessage"),
579
+ };
580
+ const order = (params.methods ?? ["siws", "memo-tx", "sign-message"]).filter((m) => available[m]);
581
+ if (order.length === 0) {
582
+ throw new SiwsError("wallet-unsupported", `${wallet?.name ?? "this wallet"} supports none of solana:signIn, solana:signTransaction, solana:signMessage`);
583
+ }
584
+ const timeoutMs = params.signTimeoutMs ?? 60_000;
585
+ const account = await resolveAccount(wallet, params.account, timeoutMs);
586
+ let last;
587
+ for (const method of order) {
588
+ params.onAttempt?.(method);
589
+ try {
590
+ const session = method === "siws"
591
+ ? await proveWithSignIn(params, account, timeoutMs)
592
+ : method === "memo-tx"
593
+ ? await proveWithMemoTx(params, account, timeoutMs)
594
+ : await proveWithSignMessage(params, account, timeoutMs);
595
+ return { ...session, method };
596
+ }
597
+ catch (e) {
598
+ const err = e instanceof SiwsError ? e : walletFailure(e, `${method} failed`);
599
+ // Bad caller input (e.g. no domain outside a browser) is not a wallet
600
+ // problem — surface it rather than silently degrading.
601
+ if (isFinal(err) || err.code === "invalid-params")
602
+ throw err;
603
+ last = err;
604
+ }
605
+ }
606
+ throw last ?? new SiwsError("wallet-error", "sign-in failed");
607
+ }
package/dist/voucher.d.ts CHANGED
@@ -22,21 +22,29 @@
22
22
  * escrows the mint price (a snapshot of the live price) into
23
23
  * `["voucher", name]` and reserves the voucher slot. `recipient` bound ⇒ the
24
24
  * claim registers the handle to that wallet no matter who submits; `null` ⇒
25
- * open-code (the claimer owns it) and `claimSecretHash` (= `sha256(secret)`)
26
- * is REQUIRED (M72) — the claimer must present the matching `secret`. An optional integrator referrer (the same trailing accounts as
25
+ * a bound recipient (audit S6 — open/recipient-less vouchers were removed as
26
+ * front-runnable; `claimSecretHash` is vestigial and rejected). An optional integrator referrer (the same trailing accounts as
27
27
  * `register`, from `integrator.ts`) is snapshotted so the claim can split
28
- * the escrow. `expiresAt` must be strictly in the future.
28
+ * the escrow. `expiresAt` must be strictly in the future and at most
29
+ * `MAX_VOUCHER_DURATION_SECS` (366 days) away (audit R4-A-2).
29
30
  * - `claim_voucher(name)` registers the handle, drawing the fee from the
30
31
  * escrow (split integrator/treasury per the snapshot), and closes the
31
32
  * voucher — rent back to the original payer. The `claimer` pays only the tx
32
- * fee and the handle's rent, never the mint fee; a relayer may claim for a
33
- * bound recipient. Supply the integrator wallet iff the voucher carries one.
33
+ * fee and the handle's rent, never the mint fee. This untokenized claim must
34
+ * be signed by the bound recipient (audit R4-A-3); a relayer uses
35
+ * `claim_voucher_tokenized`. Supply the integrator wallet iff the voucher
36
+ * carries one.
34
37
  * - `refund_voucher(name)` returns the whole escrow (+ rent) to the payer,
35
- * payer-signed, only at/after `expiresAt`.
38
+ * payer-signed, at/after `expiresAt`, or earlier once the name has been
39
+ * registered by someone else (pass `handle`, audit R4-A-2).
36
40
  */
37
41
  import type { AddressLike, BuiltInstruction, InstructionKey } from "./delegate.js";
38
42
  /** Seed prefix of the voucher PDA: `["voucher", name]`. */
39
43
  export declare const VOUCHER_SEED = "voucher";
44
+ /** Longest a voucher may stay open: `create_voucher` refuses an `expiresAt`
45
+ * more than this many seconds ahead (`VoucherExpiryTooFar`, 6112; audit
46
+ * R4-A-2). Mirrors lib.rs `MAX_VOUCHER_DURATION_SECS`. */
47
+ export declare const MAX_VOUCHER_DURATION_SECS: number;
40
48
  /** Anchor instruction discriminator `sha256("global:create_voucher")[0..8]`. */
41
49
  export declare const CREATE_VOUCHER_DISCRIMINATOR: Uint8Array;
42
50
  /** Anchor instruction discriminator `sha256("global:claim_voucher")[0..8]`. */
@@ -130,8 +138,11 @@ export interface CreateVoucherParams {
130
138
  export declare function buildCreateVoucherIx(p: CreateVoucherParams): BuiltInstruction;
131
139
  export interface ClaimVoucherParams {
132
140
  readonly programId: AddressLike;
133
- /** Pays the tx fee + the handle's rent (NOT the mint fee). Signer. May be a
134
- * relayer when the voucher binds a recipient. */
141
+ /** Pays the tx fee + the handle's rent (NOT the mint fee). Signer. For a
142
+ * bound voucher this MUST be the recipient (audit R4-A-3,
143
+ * `VoucherClaimNotRecipient` 6109): this untokenized claim skips the
144
+ * bundled pNFT, so only the recipient may opt out of it. Relayers use
145
+ * `buildClaimVoucherTokenizedIx`, which stays permissionless. */
135
146
  readonly claimer: AddressLike;
136
147
  readonly config: AddressLike;
137
148
  /** `Config.treasury`. */
@@ -160,8 +171,15 @@ export interface RefundVoucherParams {
160
171
  readonly payer: AddressLike;
161
172
  readonly voucher: AddressLike;
162
173
  readonly name: string;
174
+ /** The `["handle", name]` PDA (audit R4-A-2), appended as the trailing
175
+ * optional account. When that handle is already registered (someone else
176
+ * took the name, so the claim can never succeed) the payer may refund
177
+ * BEFORE `expiresAt`. Omit to send the original two-account form. */
178
+ readonly handle?: AddressLike;
163
179
  }
164
- /** Build `refund_voucher` — payer-signed, only valid at/after `expiresAt`. */
180
+ /** Build `refund_voucher` — payer-signed. Valid at/after `expiresAt`, or
181
+ * earlier when `handle` is passed and that name is already registered
182
+ * (audit R4-A-2). */
165
183
  export declare function buildRefundVoucherIx(p: RefundVoucherParams): BuiltInstruction;
166
184
  /** `sha256("global:claim_voucher_tokenized")[0..8]`. */
167
185
  export declare const CLAIM_VOUCHER_TOKENIZED_DISCRIMINATOR: Uint8Array;