@x1id/resolve 0.19.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/connect.d.ts +20 -8
- package/dist/connect.js +34 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/siws.d.ts +310 -0
- package/dist/siws.js +607 -0
- package/dist/voucher.d.ts +2 -2
- package/dist/voucher.js +14 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -620,6 +620,86 @@ of this zero-dependency package). Use the MCP server's `prepare_x402_payment`
|
|
|
620
620
|
> `confirmed` gets a `402 "payment not yet confirmed; finalize it and retry"`.
|
|
621
621
|
> Poll the signature to `finalized` before the retry.
|
|
622
622
|
|
|
623
|
+
## Sign in with Solana
|
|
624
|
+
|
|
625
|
+
Sign a user in with any [Wallet Standard](https://github.com/wallet-standard/wallet-standard)
|
|
626
|
+
wallet that implements `solana:signIn` (SIWS), against the hosted X1ID Connect
|
|
627
|
+
API. You get back a Bearer session proving **wallet control**; the wallet's
|
|
628
|
+
primary `@handle`, if it has one, comes along as metadata.
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
import { signInWithSolana, SiwsError } from "@x1id/resolve";
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
const session = await signInWithSolana({
|
|
635
|
+
wallet, // a Wallet Standard wallet object (e.g. from getWallets().get())
|
|
636
|
+
apiKey: "x1id_…", // your integrator key from developers.x1id.io
|
|
637
|
+
// domain defaults to location.host. It must be on your deployment's SIWS allowlist.
|
|
638
|
+
statement: "Sign in to Example.",
|
|
639
|
+
});
|
|
640
|
+
// session.token (send as `Authorization: Bearer …`), session.address, session.handle
|
|
641
|
+
} catch (e) {
|
|
642
|
+
if (e instanceof SiwsError) {
|
|
643
|
+
// e.code: "wallet-unsupported" | "user-rejected" | "domain-not-allowed"
|
|
644
|
+
// | "nonce-expired" | "invalid-params" | "wallet-timeout" | "wallet-error" | "network" | "api-error"
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
### With fallbacks: `signInWithWallet`
|
|
650
|
+
|
|
651
|
+
Some wallets lack `solana:signIn` or implement it badly, and a Ledger refuses
|
|
652
|
+
message signing. `signInWithWallet` takes the same arguments and tries, in the
|
|
653
|
+
developers.x1id.io order:
|
|
654
|
+
|
|
655
|
+
1. SIWS (`solana:signIn`);
|
|
656
|
+
2. a signed, **never-sent** memo transaction (`solana:signTransaction`) whose
|
|
657
|
+
only instruction is an SPL-Memo carrying the server's
|
|
658
|
+
`x1id-verify:<audience>:<nonce>`;
|
|
659
|
+
3. `solana:signMessage` over the server's
|
|
660
|
+
`x1id-connect:v2:wallet-control:<audience>:<address>:<nonce>`.
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
import { signInWithWallet, X1ID_MAINNET_SIGNIN_FEE_PAYER } from "@x1id/resolve";
|
|
664
|
+
|
|
665
|
+
const { method, ...session } = await signInWithWallet({
|
|
666
|
+
wallet,
|
|
667
|
+
apiKey: "x1id_…",
|
|
668
|
+
rpcUrl: "https://rpc.mainnet.x1.xyz", // optional: recent blockhash for the memo tx
|
|
669
|
+
signinFeePayer: X1ID_MAINNET_SIGNIN_FEE_PAYER, // optional: no-fee layout (api.x1id.io mainnet)
|
|
670
|
+
});
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
A user rejection or a wallet timeout stops the chain, so the user is never
|
|
674
|
+
prompted twice after saying no. The audience (your key id) is part of every
|
|
675
|
+
signed string, and the SDK signs the server's strings verbatim. It refuses to
|
|
676
|
+
sign a memo or challenge that is not bound to an audience. Without
|
|
677
|
+
`signinFeePayer` the user is the memo transaction's fee payer, which every
|
|
678
|
+
deployment accepts. The transaction is never sent either way. Outside a
|
|
679
|
+
browser, pass `domain` or set `methods: ["memo-tx", "sign-message"]`.
|
|
680
|
+
|
|
681
|
+
The steps are also exported on their own, for wallets you drive yourself:
|
|
682
|
+
|
|
683
|
+
| function | does |
|
|
684
|
+
|---|---|
|
|
685
|
+
| `requestSignInChallenge({ apiKey, wallet, apiBase? })` | `POST /v1/auth/challenge {wallet, mode:"signin"}` → `{ nonce, expires_at, … }` |
|
|
686
|
+
| `buildSignInInput({ domain?, address, nonce, statement?, uri?, issuedAt? })` | the `solana:signIn` input: domain, address, statement, uri, version `"1"`, nonce, issuedAt |
|
|
687
|
+
| `verifySolanaSignIn({ apiKey, nonce, signedMessage, signature, apiBase? })` | `POST /v1/auth/verify-signin` (signed bytes as base64, signature as base58) → session |
|
|
688
|
+
| `buildSignInMessageText(input)` | the SIWS message text a wallet renders from that input |
|
|
689
|
+
| `requestConnectChallenge({ apiKey, wallet, mode? })` | challenge for `"signin"`, `"tx"` (adds `memo`), or signMessage (no mode) |
|
|
690
|
+
| `buildSignInMemoTransaction({ user, memo, blockhash?, feePayer? })` | the unsigned memo-only legacy transaction (no web3.js) |
|
|
691
|
+
| `verifySignInTransaction({ apiKey, nonce, signedTransaction })` | `POST /v1/auth/verify-tx` |
|
|
692
|
+
| `verifySignInMessage({ apiKey, nonce, signature, signer })` | `POST /v1/auth/verify` |
|
|
693
|
+
|
|
694
|
+
The server checks that line 1 is `<domain> wants you to sign in with your Solana account:`
|
|
695
|
+
with an allowed domain, that line 2 is the challenged wallet, that a `Nonce:` line
|
|
696
|
+
carries the single-use challenge nonce, and the ed25519 signature (raw bytes or
|
|
697
|
+
the SIMD-0048 off-chain envelope). A nonce is single-use and expires, so on
|
|
698
|
+
`nonce-expired` call `signInWithSolana` again.
|
|
699
|
+
|
|
700
|
+
This is the hosted-session flow. For a server-less proof your own backend
|
|
701
|
+
verifies, see `signInWithX1ID` in [SIGNIN.md](./SIGNIN.md).
|
|
702
|
+
|
|
623
703
|
## Errors
|
|
624
704
|
|
|
625
705
|
`ResolveError` carries a `code` a UI can branch on:
|
package/dist/connect.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* So this flow proves wallet control a different, robust way: the wallet signs a
|
|
15
15
|
* throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
|
|
16
|
-
* `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
|
|
16
|
+
* `x1id-verify:<audience>:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
|
|
17
17
|
* `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
|
|
18
18
|
* it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
|
|
19
19
|
* is the wallet, that the memo binds our single-use nonce, and verifies the
|
|
@@ -33,13 +33,19 @@
|
|
|
33
33
|
* free of a hard web3 dependency and tests can supply a fake.
|
|
34
34
|
*/
|
|
35
35
|
/** Memo prefix the verify-tx proof binds to. The signed memo instruction's
|
|
36
|
-
* UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server
|
|
37
|
-
* other data, which is what kills replay of an unrelated signed
|
|
36
|
+
* UTF-8 data MUST be EXACTLY `x1id-verify:<audience>:<nonce>` — the server
|
|
37
|
+
* rejects any other data, which is what kills replay of an unrelated signed
|
|
38
|
+
* transaction. The AUDIENCE (your API key id, or developers.x1id.io for the
|
|
39
|
+
* Developer Portal) is returned by the challenge as `audience` and binds the
|
|
40
|
+
* signature to the application it was made for (R3-L2P-1); the hosted API no
|
|
41
|
+
* longer accepts the unbound `x1id-verify:<nonce>`. */
|
|
38
42
|
export declare const VERIFY_TX_MEMO_PREFIX: "x1id-verify:";
|
|
39
43
|
/** Canonical SPL Memo program id — the program the challenge memo rides on. */
|
|
40
44
|
export declare const MEMO_PROGRAM_ID: "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
41
|
-
/** The exact memo string for a
|
|
42
|
-
|
|
45
|
+
/** The exact memo string for a challenge nonce and its audience. Without an
|
|
46
|
+
* audience this is the legacy unbound form, which the current hosted API
|
|
47
|
+
* REJECTS — always pass the challenge's `audience` (or use its `memo`). */
|
|
48
|
+
export declare function verifyTxMemo(nonce: string, audience?: string): string;
|
|
43
49
|
/** The slice of a web3.js `PublicKey` used here. */
|
|
44
50
|
export interface PublicKeyInstance {
|
|
45
51
|
toBytes(): Uint8Array;
|
|
@@ -87,10 +93,14 @@ export declare const PLACEHOLDER_BLOCKHASH: "11111111111111111111111111111111";
|
|
|
87
93
|
export interface BuildVerifyTxChallengeParams {
|
|
88
94
|
/** The single-use nonce from the tx-mode challenge. */
|
|
89
95
|
readonly nonce: string;
|
|
90
|
-
/** The memo the server returned (`x1id-verify:<nonce>`)
|
|
91
|
-
*
|
|
96
|
+
/** The memo the server returned (`x1id-verify:<audience>:<nonce>`) —
|
|
97
|
+
* RECOMMENDED: pass it verbatim. When omitted it is derived from `nonce` +
|
|
98
|
+
* `audience`. When supplied it MUST be an `x1id-verify:` memo for THIS
|
|
99
|
+
* nonce (and, if `audience` is given, for that audience), or the build
|
|
92
100
|
* throws (catches a client that paired the wrong nonce/memo). */
|
|
93
101
|
readonly memo?: string;
|
|
102
|
+
/** The challenge's `audience` (R3-L2P-1). */
|
|
103
|
+
readonly audience?: string;
|
|
94
104
|
/** The signing wallet, base58 — becomes the fee payer AND the memo signer. */
|
|
95
105
|
readonly feePayer: string;
|
|
96
106
|
/** A recent blockhash. Defaults to {@link PLACEHOLDER_BLOCKHASH} (the tx is
|
|
@@ -101,7 +111,7 @@ export interface BuildVerifyTxChallengeParams {
|
|
|
101
111
|
* Build the memo-only challenge transaction the wallet signs for verify-tx.
|
|
102
112
|
*
|
|
103
113
|
* The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
|
|
104
|
-
* instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
|
|
114
|
+
* instruction whose data is the UTF-8 bytes of `x1id-verify:<audience>:<nonce>`, with the
|
|
105
115
|
* fee payer listed as a required signer on that instruction. Nothing else — no
|
|
106
116
|
* transfer, no compute-budget, no durable nonce — so the signed artifact can
|
|
107
117
|
* only ever be a sign-in proof, never a disguised value-moving transaction. The
|
|
@@ -136,6 +146,8 @@ export interface ConnectSession {
|
|
|
136
146
|
export interface TxChallenge {
|
|
137
147
|
readonly nonce: string;
|
|
138
148
|
readonly memo: string;
|
|
149
|
+
/** The audience the memo names (R3-L2P-1); absent from older deployments. */
|
|
150
|
+
readonly audience?: string;
|
|
139
151
|
readonly handle: string | null;
|
|
140
152
|
readonly expires_at: number;
|
|
141
153
|
}
|
package/dist/connect.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* So this flow proves wallet control a different, robust way: the wallet signs a
|
|
15
15
|
* throwaway MEMO TRANSACTION — one SPL-Memo instruction carrying
|
|
16
|
-
* `x1id-verify:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
|
|
16
|
+
* `x1id-verify:<audience>:<nonce>`, fee-paid by the wallet, never broadcast. The hosted
|
|
17
17
|
* `POST /v1/auth/verify-tx` endpoint deserializes the signed artifact, asserts
|
|
18
18
|
* it is EXACTLY that memo (no disguised transfer/approval), that the fee payer
|
|
19
19
|
* is the wallet, that the memo binds our single-use nonce, and verifies the
|
|
@@ -33,14 +33,26 @@
|
|
|
33
33
|
* free of a hard web3 dependency and tests can supply a fake.
|
|
34
34
|
*/
|
|
35
35
|
/** Memo prefix the verify-tx proof binds to. The signed memo instruction's
|
|
36
|
-
* UTF-8 data MUST be EXACTLY `x1id-verify:<nonce>` — the server
|
|
37
|
-
* other data, which is what kills replay of an unrelated signed
|
|
36
|
+
* UTF-8 data MUST be EXACTLY `x1id-verify:<audience>:<nonce>` — the server
|
|
37
|
+
* rejects any other data, which is what kills replay of an unrelated signed
|
|
38
|
+
* transaction. The AUDIENCE (your API key id, or developers.x1id.io for the
|
|
39
|
+
* Developer Portal) is returned by the challenge as `audience` and binds the
|
|
40
|
+
* signature to the application it was made for (R3-L2P-1); the hosted API no
|
|
41
|
+
* longer accepts the unbound `x1id-verify:<nonce>`. */
|
|
38
42
|
export const VERIFY_TX_MEMO_PREFIX = "x1id-verify:";
|
|
39
43
|
/** Canonical SPL Memo program id — the program the challenge memo rides on. */
|
|
40
44
|
export const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
/** An audience: a key id (16 base64url chars) or a bare DNS name. */
|
|
46
|
+
const AUDIENCE_RE = /^[A-Za-z0-9._-]{1,253}$/;
|
|
47
|
+
/** The exact memo string for a challenge nonce and its audience. Without an
|
|
48
|
+
* audience this is the legacy unbound form, which the current hosted API
|
|
49
|
+
* REJECTS — always pass the challenge's `audience` (or use its `memo`). */
|
|
50
|
+
export function verifyTxMemo(nonce, audience) {
|
|
51
|
+
if (audience === undefined)
|
|
52
|
+
return VERIFY_TX_MEMO_PREFIX + nonce;
|
|
53
|
+
if (!AUDIENCE_RE.test(audience))
|
|
54
|
+
throw new Error(`invalid audience "${audience}"`);
|
|
55
|
+
return `${VERIFY_TX_MEMO_PREFIX}${audience}:${nonce}`;
|
|
44
56
|
}
|
|
45
57
|
/** 32 zero-bytes in base58 — a structurally valid blockhash. The transaction is
|
|
46
58
|
* NEVER broadcast, so a placeholder blockhash is fine; a wallet that insists on
|
|
@@ -50,7 +62,7 @@ export const PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111";
|
|
|
50
62
|
* Build the memo-only challenge transaction the wallet signs for verify-tx.
|
|
51
63
|
*
|
|
52
64
|
* The result is a legacy `Transaction` with EXACTLY one instruction: an SPL-Memo
|
|
53
|
-
* instruction whose data is the UTF-8 bytes of `x1id-verify:<nonce>`, with the
|
|
65
|
+
* instruction whose data is the UTF-8 bytes of `x1id-verify:<audience>:<nonce>`, with the
|
|
54
66
|
* fee payer listed as a required signer on that instruction. Nothing else — no
|
|
55
67
|
* transfer, no compute-budget, no durable nonce — so the signed artifact can
|
|
56
68
|
* only ever be a sign-in proof, never a disguised value-moving transaction. The
|
|
@@ -62,9 +74,20 @@ export function buildVerifyTxChallenge(web3, params) {
|
|
|
62
74
|
throw new Error("nonce is required");
|
|
63
75
|
if (typeof feePayer !== "string" || feePayer.length === 0)
|
|
64
76
|
throw new Error("feePayer is required");
|
|
65
|
-
const memo = params.memo ?? verifyTxMemo(nonce);
|
|
66
|
-
if (
|
|
67
|
-
|
|
77
|
+
const memo = params.memo ?? verifyTxMemo(nonce, params.audience);
|
|
78
|
+
if (params.audience !== undefined) {
|
|
79
|
+
const want = verifyTxMemo(nonce, params.audience);
|
|
80
|
+
if (memo !== want)
|
|
81
|
+
throw new Error(`memo must be exactly "${want}" for this nonce`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// No audience given: accept the server's audience-bound memo for this
|
|
85
|
+
// nonce, or the legacy unbound form (older deployments).
|
|
86
|
+
const rest = memo.startsWith(VERIFY_TX_MEMO_PREFIX) ? memo.slice(VERIFY_TX_MEMO_PREFIX.length) : null;
|
|
87
|
+
const aud = rest !== null && rest.endsWith(`:${nonce}`) ? rest.slice(0, -(nonce.length + 1)) : null;
|
|
88
|
+
if (rest !== nonce && !(aud !== null && AUDIENCE_RE.test(aud))) {
|
|
89
|
+
throw new Error(`memo must be "${VERIFY_TX_MEMO_PREFIX}<audience>:${nonce}" for this nonce`);
|
|
90
|
+
}
|
|
68
91
|
}
|
|
69
92
|
const payer = new web3.PublicKey(feePayer);
|
|
70
93
|
const memoProgram = new web3.PublicKey(MEMO_PROGRAM_ID);
|
|
@@ -169,6 +192,7 @@ export async function signinWithTx(cfg, params) {
|
|
|
169
192
|
const tx = buildVerifyTxChallenge(params.web3, {
|
|
170
193
|
nonce: challenge.nonce,
|
|
171
194
|
memo: challenge.memo,
|
|
195
|
+
...(typeof challenge.audience === "string" ? { audience: challenge.audience } : {}),
|
|
172
196
|
feePayer: params.wallet,
|
|
173
197
|
...(params.blockhash !== undefined ? { blockhash: params.blockhash } : {}),
|
|
174
198
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -55,6 +55,7 @@ export * from "./pnftTransfer.js";
|
|
|
55
55
|
export * from "./payment.js";
|
|
56
56
|
export * from "./signin.js";
|
|
57
57
|
export * from "./connect.js";
|
|
58
|
+
export * from "./siws.js";
|
|
58
59
|
export * from "./domainProof.js";
|
|
59
60
|
export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, type ScopedCandidate, type ScopedHandleDeriver, } from "./scoped.js";
|
|
60
61
|
import { type Chain, type Resolved } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export * from "./pnftTransfer.js";
|
|
|
62
62
|
export * from "./payment.js";
|
|
63
63
|
export * from "./signin.js";
|
|
64
64
|
export * from "./connect.js";
|
|
65
|
+
export * from "./siws.js";
|
|
65
66
|
export * from "./domainProof.js";
|
|
66
67
|
// Named (not `export *`): `NAMESPACE_MAX_LEN` is already exported by
|
|
67
68
|
// adminMarket.js, and two `export *` sharing a name would make it ambiguous
|
package/dist/siws.d.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
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 { type ConnectSession } from "./connect.js";
|
|
63
|
+
/**
|
|
64
|
+
* - `wallet-unsupported` — the wallet does not implement `solana:signIn` (or,
|
|
65
|
+
* for `signInWithWallet`, none of signIn / signTransaction / signMessage).
|
|
66
|
+
* - `user-rejected` — the user declined in the wallet.
|
|
67
|
+
* - `domain-not-allowed` — dev-api rejected the domain named in the message
|
|
68
|
+
* (it is not on the deployment's SIWS allowlist).
|
|
69
|
+
* - `nonce-expired` — the challenge expired, was already used, or is unknown;
|
|
70
|
+
* request a fresh one and retry.
|
|
71
|
+
* - `invalid-params` — bad input (missing apiKey/domain, injection in a field).
|
|
72
|
+
* - `wallet-timeout` — the wallet never answered.
|
|
73
|
+
* - `wallet-error` — the wallet failed or returned an unusable result.
|
|
74
|
+
* - `network` — the request did not complete (no fetch, timeout, non-JSON).
|
|
75
|
+
* - `api-error` — any other dev-api error; see `serverCode` / `status`.
|
|
76
|
+
*/
|
|
77
|
+
export type SiwsErrorCode = "wallet-unsupported" | "user-rejected" | "domain-not-allowed" | "nonce-expired" | "invalid-params" | "wallet-timeout" | "wallet-error" | "network" | "api-error";
|
|
78
|
+
export declare class SiwsError extends Error {
|
|
79
|
+
readonly code: SiwsErrorCode;
|
|
80
|
+
/** dev-api's own error code (e.g. `CHALLENGE_EXPIRED`), when it sent one. */
|
|
81
|
+
readonly serverCode?: string | undefined;
|
|
82
|
+
/** HTTP status, when the error came from a response. */
|
|
83
|
+
readonly status?: number | undefined;
|
|
84
|
+
constructor(code: SiwsErrorCode, message: string,
|
|
85
|
+
/** dev-api's own error code (e.g. `CHALLENGE_EXPIRED`), when it sent one. */
|
|
86
|
+
serverCode?: string | undefined,
|
|
87
|
+
/** HTTP status, when the error came from a response. */
|
|
88
|
+
status?: number | undefined);
|
|
89
|
+
}
|
|
90
|
+
/** Map a dev-api error response onto a {@link SiwsErrorCode}. */
|
|
91
|
+
export declare function siwsErrorCodeFor(serverCode: string | undefined, message: string | undefined): SiwsErrorCode;
|
|
92
|
+
/** `solana:signIn` input (Phantom's SIWS spec / @solana/wallet-standard-features). */
|
|
93
|
+
export interface SolanaSignInInput {
|
|
94
|
+
readonly domain?: string;
|
|
95
|
+
readonly address?: string;
|
|
96
|
+
readonly statement?: string;
|
|
97
|
+
readonly uri?: string;
|
|
98
|
+
readonly version?: string;
|
|
99
|
+
readonly chainId?: string;
|
|
100
|
+
readonly nonce?: string;
|
|
101
|
+
readonly issuedAt?: string;
|
|
102
|
+
readonly expirationTime?: string;
|
|
103
|
+
readonly notBefore?: string;
|
|
104
|
+
readonly requestId?: string;
|
|
105
|
+
readonly resources?: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
export interface WalletStandardAccount {
|
|
108
|
+
readonly address: string;
|
|
109
|
+
readonly publicKey?: Uint8Array;
|
|
110
|
+
}
|
|
111
|
+
export interface SolanaSignInOutput {
|
|
112
|
+
readonly account: WalletStandardAccount;
|
|
113
|
+
readonly signedMessage: Uint8Array;
|
|
114
|
+
readonly signature: Uint8Array;
|
|
115
|
+
readonly signatureType?: string;
|
|
116
|
+
}
|
|
117
|
+
/** The slice of a Wallet Standard wallet this module uses. */
|
|
118
|
+
export interface SignInCapableWallet {
|
|
119
|
+
readonly name?: string;
|
|
120
|
+
readonly accounts?: readonly WalletStandardAccount[];
|
|
121
|
+
readonly features: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
/** True if `wallet` implements `solana:signIn`. */
|
|
124
|
+
export declare function walletSupportsSignIn(wallet: SignInCapableWallet | null | undefined): boolean;
|
|
125
|
+
/** The SIWS message line-1 suffix dev-api's verifier matches on. */
|
|
126
|
+
export declare const SIWS_HEADER_SUFFIX: " wants you to sign in with your Solana account:";
|
|
127
|
+
/** Default statement shown in the wallet. */
|
|
128
|
+
export declare const DEFAULT_SIWS_STATEMENT: "Sign in with your wallet. This does not send a transaction.";
|
|
129
|
+
export interface BuildSignInInputParams {
|
|
130
|
+
/** The host the user is signing in to (e.g. `app.example.com`). Must be on
|
|
131
|
+
* your deployment's SIWS allowlist. Defaults to `globalThis.location.host`. */
|
|
132
|
+
readonly domain?: string;
|
|
133
|
+
/** The wallet address (base58) the challenge was issued for. */
|
|
134
|
+
readonly address: string;
|
|
135
|
+
/** The challenge nonce from {@link requestSignInChallenge}. */
|
|
136
|
+
readonly nonce: string;
|
|
137
|
+
/** Human-readable statement the wallet displays. Defaults to
|
|
138
|
+
* {@link DEFAULT_SIWS_STATEMENT}; pass `""` to omit it. */
|
|
139
|
+
readonly statement?: string;
|
|
140
|
+
/** Defaults to `globalThis.location.origin`, else `https://<domain>`. */
|
|
141
|
+
readonly uri?: string;
|
|
142
|
+
/** ISO-8601; defaults to now. */
|
|
143
|
+
readonly issuedAt?: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build the Wallet Standard `solana:signIn` input for an X1ID challenge —
|
|
147
|
+
* exactly the fields developers.x1id.io sends: domain, address, statement, uri,
|
|
148
|
+
* version `"1"`, nonce, issuedAt.
|
|
149
|
+
*/
|
|
150
|
+
export declare function buildSignInInput(params: BuildSignInInputParams): SolanaSignInInput;
|
|
151
|
+
/**
|
|
152
|
+
* Render a `solana:signIn` input as SIWS message text, per the SIWS spec
|
|
153
|
+
* (identical to `createSignInMessageText` in @solana/wallet-standard-util).
|
|
154
|
+
* Wallets render this themselves; it is exported for tests, previews, and
|
|
155
|
+
* server-side expectations.
|
|
156
|
+
*/
|
|
157
|
+
export declare function buildSignInMessageText(input: SolanaSignInInput & {
|
|
158
|
+
domain: string;
|
|
159
|
+
address: string;
|
|
160
|
+
}): string;
|
|
161
|
+
export interface SiwsApiConfig {
|
|
162
|
+
/** Your X1ID integrator API key (sent as `x-api-key`). */
|
|
163
|
+
readonly apiKey: string;
|
|
164
|
+
/** API origin. Defaults to `https://api.x1id.io`; a trailing `/v1` is tolerated. */
|
|
165
|
+
readonly apiBase?: string;
|
|
166
|
+
/** Inject a `fetch` (tests, non-global-fetch runtimes). */
|
|
167
|
+
readonly fetchImpl?: typeof fetch;
|
|
168
|
+
/** Per-request timeout in ms (default 15_000). */
|
|
169
|
+
readonly timeoutMs?: number;
|
|
170
|
+
}
|
|
171
|
+
/** `POST /v1/auth/challenge` response for a `mode: "signin"` wallet challenge. */
|
|
172
|
+
export interface SignInChallengeResponse {
|
|
173
|
+
readonly nonce: string;
|
|
174
|
+
readonly handle: string | null;
|
|
175
|
+
/** The exact string to sign for the signMessage path
|
|
176
|
+
* (`x1id-connect:v2:wallet-control:<audience>:<address>:<nonce>`). */
|
|
177
|
+
readonly challenge: string;
|
|
178
|
+
/** The audience the signed bytes name (R3-L2P-1): your API key id, or the
|
|
179
|
+
* portal's domain for the portal key. Absent from a pre-#608 API. */
|
|
180
|
+
readonly audience?: string;
|
|
181
|
+
/** Tx mode only: the exact memo (`x1id-verify:<audience>:<nonce>`). */
|
|
182
|
+
readonly memo?: string;
|
|
183
|
+
readonly signature_encoding: string;
|
|
184
|
+
readonly expires_at: number;
|
|
185
|
+
readonly proves: string;
|
|
186
|
+
readonly disclaimer: string;
|
|
187
|
+
}
|
|
188
|
+
/** Body of `POST /v1/auth/verify-signin`. */
|
|
189
|
+
export interface VerifySignInBody {
|
|
190
|
+
readonly nonce: string;
|
|
191
|
+
/** base64 of the EXACT bytes the wallet signed. */
|
|
192
|
+
readonly signedMessage: string;
|
|
193
|
+
/** base58 of the 64-byte ed25519 signature. */
|
|
194
|
+
readonly signature: string;
|
|
195
|
+
}
|
|
196
|
+
/** `POST /v1/auth/challenge {wallet, mode: "signin"}` — a single-use nonce. */
|
|
197
|
+
export declare function requestSignInChallenge(params: SiwsApiConfig & {
|
|
198
|
+
readonly wallet: string;
|
|
199
|
+
}): Promise<SignInChallengeResponse>;
|
|
200
|
+
/**
|
|
201
|
+
* `POST /v1/auth/challenge {wallet, mode?}` for any wallet-proof mode:
|
|
202
|
+
* `"signin"` (SIWS), `"tx"` (memo transaction; the response carries `memo`),
|
|
203
|
+
* or omitted (signMessage over `challenge`).
|
|
204
|
+
*/
|
|
205
|
+
export declare function requestConnectChallenge(params: SiwsApiConfig & {
|
|
206
|
+
readonly wallet: string;
|
|
207
|
+
readonly mode?: "signin" | "tx";
|
|
208
|
+
}): Promise<SignInChallengeResponse>;
|
|
209
|
+
/**
|
|
210
|
+
* `POST /v1/auth/verify-signin` — the raw verify call. Accepts the wallet's
|
|
211
|
+
* byte outputs and encodes them as dev-api expects (signedMessage base64,
|
|
212
|
+
* signature base58), or pre-encoded strings passed through as-is.
|
|
213
|
+
*/
|
|
214
|
+
export declare function verifySolanaSignIn(params: SiwsApiConfig & {
|
|
215
|
+
readonly nonce: string;
|
|
216
|
+
readonly signedMessage: Uint8Array | string;
|
|
217
|
+
readonly signature: Uint8Array | string;
|
|
218
|
+
}): Promise<ConnectSession>;
|
|
219
|
+
/** `POST /v1/auth/verify-tx` with the fully signed memo transaction (base64). */
|
|
220
|
+
export declare function verifySignInTransaction(params: SiwsApiConfig & {
|
|
221
|
+
readonly nonce: string;
|
|
222
|
+
readonly signedTransaction: Uint8Array | string;
|
|
223
|
+
}): Promise<ConnectSession>;
|
|
224
|
+
/** `POST /v1/auth/verify` with a signMessage signature over the challenge
|
|
225
|
+
* string (signature base58, `signer` = the wallet address). */
|
|
226
|
+
export declare function verifySignInMessage(params: SiwsApiConfig & {
|
|
227
|
+
readonly nonce: string;
|
|
228
|
+
readonly signature: Uint8Array | string;
|
|
229
|
+
readonly signer: string;
|
|
230
|
+
}): Promise<ConnectSession>;
|
|
231
|
+
/** X1ID's display-only sign-in fee payer on X1 MAINNET (System-owned, tiny
|
|
232
|
+
* balance so wallet simulations pass; it never signs and the tx is never
|
|
233
|
+
* sent). Pass it as `signinFeePayer` only against a deployment whose
|
|
234
|
+
* `CONNECT_SIGNIN_FEE_PAYER` is this address (api.x1id.io on mainnet). */
|
|
235
|
+
export declare const X1ID_MAINNET_SIGNIN_FEE_PAYER: "9kFEzXbNBJLTsvZwrTAcpxr6iLRqHPyu9yp8K4m2YwC3";
|
|
236
|
+
export interface BuildSignInMemoTransactionParams {
|
|
237
|
+
/** The signing wallet (base58). */
|
|
238
|
+
readonly user: string;
|
|
239
|
+
/** The memo, verbatim from the challenge (`x1id-verify:<audience>:<nonce>`). */
|
|
240
|
+
readonly memo: string;
|
|
241
|
+
/** A recent blockhash; defaults to the all-ones placeholder (never sent). */
|
|
242
|
+
readonly blockhash?: string;
|
|
243
|
+
/** Display-only fee payer for dev-api's no-fee layout (user = signer index 1).
|
|
244
|
+
* Null/omitted = the user pays and signs alone (always accepted). */
|
|
245
|
+
readonly feePayer?: string | null;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The unsigned, serialized LEGACY memo-only transaction the wallet signs for
|
|
249
|
+
* `/v1/auth/verify-tx`: exactly one SPL-Memo instruction carrying `memo`, with
|
|
250
|
+
* the user as the memo's signer. Byte-identical to the portal's
|
|
251
|
+
* `buildMemoTx` (portal/src/lib/wallet/memoTx.ts). It is never broadcast.
|
|
252
|
+
*/
|
|
253
|
+
export declare function buildSignInMemoTransaction(params: BuildSignInMemoTransactionParams): Uint8Array;
|
|
254
|
+
export interface SignInWithSolanaParams extends SiwsApiConfig {
|
|
255
|
+
/** A Wallet Standard wallet implementing `solana:signIn`. */
|
|
256
|
+
readonly wallet: SignInCapableWallet;
|
|
257
|
+
/** The account to sign in with. Defaults to the wallet's first account
|
|
258
|
+
* (connecting via `standard:connect` first if it has none). */
|
|
259
|
+
readonly account?: WalletStandardAccount;
|
|
260
|
+
/** Defaults to `globalThis.location.host`. */
|
|
261
|
+
readonly domain?: string;
|
|
262
|
+
readonly statement?: string;
|
|
263
|
+
readonly uri?: string;
|
|
264
|
+
/** How long to wait for the wallet, ms (default 60_000). */
|
|
265
|
+
readonly signTimeoutMs?: number;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Sign in with a Wallet Standard wallet via `solana:signIn` ONLY and return the
|
|
269
|
+
* X1ID Connect session. Throws {@link SiwsError} (`wallet-unsupported` when the
|
|
270
|
+
* wallet lacks `solana:signIn`). For automatic fallbacks use
|
|
271
|
+
* {@link signInWithWallet}.
|
|
272
|
+
*
|
|
273
|
+
* ```ts
|
|
274
|
+
* const session = await signInWithSolana({ wallet, apiKey: "x1id_..." });
|
|
275
|
+
* // session.token → `Authorization: Bearer …`; session.address; session.handle
|
|
276
|
+
* ```
|
|
277
|
+
*/
|
|
278
|
+
export declare function signInWithSolana(params: SignInWithSolanaParams): Promise<ConnectSession>;
|
|
279
|
+
export type SignInMethod = "siws" | "memo-tx" | "sign-message";
|
|
280
|
+
export interface SignInWithWalletParams extends SignInWithSolanaParams {
|
|
281
|
+
/** Paths to try, in order. Default: all three, SIWS → memo tx → signMessage
|
|
282
|
+
* (the developers.x1id.io order). */
|
|
283
|
+
readonly methods?: readonly SignInMethod[];
|
|
284
|
+
/** Memo path: display-only fee payer for dev-api's no-fee layout. Must equal
|
|
285
|
+
* the deployment's CONNECT_SIGNIN_FEE_PAYER (on api.x1id.io mainnet:
|
|
286
|
+
* {@link X1ID_MAINNET_SIGNIN_FEE_PAYER}). Default null: the user is the fee
|
|
287
|
+
* payer (accepted by every deployment; the tx is never sent either way). */
|
|
288
|
+
readonly signinFeePayer?: string | null;
|
|
289
|
+
/** Memo path: an RPC to fetch a recent blockhash from (wallets simulate the
|
|
290
|
+
* transaction before signing, and some refuse a stale one). */
|
|
291
|
+
readonly rpcUrl?: string;
|
|
292
|
+
/** Memo path: a blockhash to use as-is (overrides `rpcUrl`). Default: the
|
|
293
|
+
* all-ones placeholder. */
|
|
294
|
+
readonly blockhash?: string;
|
|
295
|
+
/** Called before each path is tried (for UI copy / telemetry). */
|
|
296
|
+
readonly onAttempt?: (method: SignInMethod) => void;
|
|
297
|
+
}
|
|
298
|
+
/** A session plus the path that produced it. */
|
|
299
|
+
export type WalletSignInResult = ConnectSession & {
|
|
300
|
+
readonly method: SignInMethod;
|
|
301
|
+
};
|
|
302
|
+
/**
|
|
303
|
+
* Sign in with any Wallet Standard wallet, falling back across the three
|
|
304
|
+
* wallet-control proofs the hosted API accepts: SIWS → never-sent memo
|
|
305
|
+
* transaction → signMessage (see the module docs). A user rejection or a
|
|
306
|
+
* wallet timeout stops immediately; otherwise the LAST path's error is thrown
|
|
307
|
+
* if every path fails. `wallet-unsupported` when the wallet implements none of
|
|
308
|
+
* `solana:signIn`, `solana:signTransaction`, `solana:signMessage`.
|
|
309
|
+
*/
|
|
310
|
+
export declare function signInWithWallet(params: SignInWithWalletParams): Promise<WalletSignInResult>;
|
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,8 +22,8 @@
|
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
28
|
* the escrow. `expiresAt` must be strictly in the future.
|
|
29
29
|
* - `claim_voucher(name)` registers the handle, drawing the fee from the
|
package/dist/voucher.js
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
28
|
* the escrow. `expiresAt` must be strictly in the future.
|
|
29
29
|
* - `claim_voucher(name)` registers the handle, drawing the fee from the
|
|
@@ -171,8 +171,19 @@ export function decodePrepaidVoucher(data) {
|
|
|
171
171
|
export function buildCreateVoucherIx(p) {
|
|
172
172
|
const name = encString(p.name);
|
|
173
173
|
const recipient = p.recipient ?? null;
|
|
174
|
+
// Audit S6: the program now REFUSES a recipient-less voucher (the claim tx
|
|
175
|
+
// reveals the secret in cleartext, so an open voucher is front-runnable —
|
|
176
|
+
// error 6106 VoucherMustBeBound). `claimSecretHash` is NO LONGER a second
|
|
177
|
+
// factor: with a recipient mandatory the program stores None and ignores any
|
|
178
|
+
// hash. Throw on both so no integrator believes either still works.
|
|
179
|
+
if (recipient === null) {
|
|
180
|
+
throw new Error("a voucher must bind its recipient (open secret-code vouchers are front-runnable and were disabled on-chain)");
|
|
181
|
+
}
|
|
182
|
+
if (p.claimSecretHash != null) {
|
|
183
|
+
throw new Error("claimSecretHash is removed — vouchers are recipient-bound and carry no claim secret (the program ignores it)");
|
|
184
|
+
}
|
|
174
185
|
const recBytes = recipient === null ? Uint8Array.from([0]) : Uint8Array.from([1, ...toBytes32(recipient, "recipient")]);
|
|
175
|
-
const hashBytes = encOption32(
|
|
186
|
+
const hashBytes = encOption32(null); // S6: always None (a supplied hash throws above)
|
|
176
187
|
const data = new Uint8Array(8 + name.length + 1 + recBytes.length + 8 + hashBytes.length);
|
|
177
188
|
let o = 0;
|
|
178
189
|
data.set(CREATE_VOUCHER_DISCRIMINATOR, o);
|