@x1id/resolve 0.19.1 → 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 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 rejects any
37
- * other data, which is what kills replay of an unrelated signed transaction. */
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 given challenge nonce. */
42
- export declare function verifyTxMemo(nonce: string): string;
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>`). Optional — derived
91
- * from `nonce` when omitted; when supplied it MUST equal that, or the build
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 rejects any
37
- * other data, which is what kills replay of an unrelated signed transaction. */
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
- /** The exact memo string for a given challenge nonce. */
42
- export function verifyTxMemo(nonce) {
43
- return VERIFY_TX_MEMO_PREFIX + nonce;
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 (memo !== verifyTxMemo(nonce)) {
67
- throw new Error(`memo must be exactly "${verifyTxMemo(nonce)}" for this nonce`);
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/gate.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * attestor-signed `Attestation` account.
16
16
  */
17
17
  import { fetchAttestations, isHandleVerified, ATTESTATION_KIND_DNS, ATTESTATION_KIND_SOCIAL, ATTESTATION_KIND_X, ATTESTATION_KIND_DISCORD, ATTESTATION_KIND_GITHUB, ATTESTATION_KIND_TELEGRAM, } from "./attestation.js";
18
+ import { ResolveError } from "./types.js";
18
19
  /** Named platform kinds, re-exported so callers gate without magic numbers. */
19
20
  export const GateKind = {
20
21
  dns: ATTESTATION_KIND_DNS,
@@ -29,7 +30,16 @@ export const GateKind = {
29
30
  export function attestationsPassGate(attestations, gate) {
30
31
  if (gate === "any-verified")
31
32
  return isHandleVerified(attestations);
32
- const kinds = "kind" in gate ? [gate.kind] : gate.anyOf;
33
+ // W171: a malformed gate (null, a string other than "any-verified", or an
34
+ // object with neither `kind` nor `anyOf`) otherwise threw a raw
35
+ // `TypeError: Cannot use 'in' operator…`. Surface it as a typed ResolveError.
36
+ if (gate === null || typeof gate !== "object") {
37
+ throw new ResolveError("invalid-input", 'gate must be "any-verified", { kind } or { anyOf: [...] }', String(gate));
38
+ }
39
+ const kinds = "kind" in gate ? [gate.kind] : "anyOf" in gate ? gate.anyOf : null;
40
+ if (kinds === null) {
41
+ throw new ResolveError("invalid-input", "gate object needs a `kind` or `anyOf`", JSON.stringify(gate));
42
+ }
33
43
  return attestations.some((a) => !a.stale && !a.holderMismatch && kinds.includes(a.kind));
34
44
  }
35
45
  /** Fetch a handle's attestations and test the gate in one call. `handleAccount`,
package/dist/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export * from "./integrator.js";
42
42
  export * from "./voucher.js";
43
43
  export * from "./agent.js";
44
44
  export * from "./register.js";
45
+ export * from "./transfer.js";
45
46
  export * from "./adminConfig.js";
46
47
  export * from "./lease.js";
47
48
  export * from "./accounts.js";
@@ -54,6 +55,7 @@ export * from "./pnftTransfer.js";
54
55
  export * from "./payment.js";
55
56
  export * from "./signin.js";
56
57
  export * from "./connect.js";
58
+ export * from "./siws.js";
57
59
  export * from "./domainProof.js";
58
60
  export { scopedTldCandidate, namespaceIsActive, makeScopedHandleDeriver, type ScopedCandidate, type ScopedHandleDeriver, } from "./scoped.js";
59
61
  import { type Chain, type Resolved } from "./types.js";
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ export * from "./integrator.js";
42
42
  export * from "./voucher.js";
43
43
  export * from "./agent.js";
44
44
  export * from "./register.js";
45
+ export * from "./transfer.js";
45
46
  export * from "./adminConfig.js";
46
47
  export * from "./lease.js";
47
48
  // Was previously imported here for internal use only, never re-exported —
@@ -61,6 +62,7 @@ export * from "./pnftTransfer.js";
61
62
  export * from "./payment.js";
62
63
  export * from "./signin.js";
63
64
  export * from "./connect.js";
65
+ export * from "./siws.js";
64
66
  export * from "./domainProof.js";
65
67
  // Named (not `export *`): `NAMESPACE_MAX_LEN` is already exported by
66
68
  // adminMarket.js, and two `export *` sharing a name would make it ambiguous
package/dist/register.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * field this builder needs (the treasury address every registration pays).
17
17
  */
18
18
  import { encodeBase58, decodeBase58_32 } from "./base58.js";
19
+ import { normalizeHandle } from "./parse.js";
19
20
  /** Anchor instruction discriminator: `sha256("global:register")[0..8]`. */
20
21
  export const REGISTER_DISCRIMINATOR = Uint8Array.from([
21
22
  211, 124, 67, 15, 211, 194, 178, 240,
@@ -73,6 +74,16 @@ export function buildRegisterIx(p) {
73
74
  if (!Number.isInteger(p.handleType) || p.handleType < 0 || p.handleType > 3) {
74
75
  throw new Error("handleType must be 0 (Human), 1 (Merchant), 2 (Org) or 3 (Agent)");
75
76
  }
77
+ // W171: the Handle PDA is derived from the CANONICAL name, so a non-canonical
78
+ // name (uppercase, surrounding space, …) builds an instruction the chain
79
+ // rejects with an opaque ConstraintSeeds (2006). normalizeHandle validates the
80
+ // grammar (ASCII, length, no leading/trailing/double hyphen, not all-digit)
81
+ // AND lowercases; require the caller to pass that canonical form so `handle`
82
+ // and the encoded name agree.
83
+ const canonical = normalizeHandle(p.name);
84
+ if (canonical !== p.name) {
85
+ throw new Error(`name must be the canonical handle "${canonical}" (got "${p.name}") — normalizeHandle() it and derive \`handle\` from the same value`);
86
+ }
76
87
  const nameBytes = new TextEncoder().encode(p.name);
77
88
  if (nameBytes.length < 1 || nameBytes.length > 32) {
78
89
  throw new Error("name must be 1..=32 bytes (UTF-8)");
@@ -123,6 +134,16 @@ export function buildRegisterReservedIx(p) {
123
134
  if (!Number.isInteger(p.handleType) || p.handleType < 0 || p.handleType > 3) {
124
135
  throw new Error("handleType must be 0 (Human), 1 (Merchant), 2 (Org) or 3 (Agent)");
125
136
  }
137
+ // W171: the Handle PDA is derived from the CANONICAL name, so a non-canonical
138
+ // name (uppercase, surrounding space, …) builds an instruction the chain
139
+ // rejects with an opaque ConstraintSeeds (2006). normalizeHandle validates the
140
+ // grammar (ASCII, length, no leading/trailing/double hyphen, not all-digit)
141
+ // AND lowercases; require the caller to pass that canonical form so `handle`
142
+ // and the encoded name agree.
143
+ const canonical = normalizeHandle(p.name);
144
+ if (canonical !== p.name) {
145
+ throw new Error(`name must be the canonical handle "${canonical}" (got "${p.name}") — normalizeHandle() it and derive \`handle\` from the same value`);
146
+ }
126
147
  const nameBytes = new TextEncoder().encode(p.name);
127
148
  if (nameBytes.length < 1 || nameBytes.length > 32) {
128
149
  throw new Error("name must be 1..=32 bytes (UTF-8)");
@@ -194,6 +215,16 @@ export function buildRegisterWithFortiIx(p) {
194
215
  if (!Number.isInteger(p.handleType) || p.handleType < 0 || p.handleType > 3) {
195
216
  throw new Error("handleType must be 0 (Human), 1 (Merchant), 2 (Org) or 3 (Agent)");
196
217
  }
218
+ // W171: the Handle PDA is derived from the CANONICAL name, so a non-canonical
219
+ // name (uppercase, surrounding space, …) builds an instruction the chain
220
+ // rejects with an opaque ConstraintSeeds (2006). normalizeHandle validates the
221
+ // grammar (ASCII, length, no leading/trailing/double hyphen, not all-digit)
222
+ // AND lowercases; require the caller to pass that canonical form so `handle`
223
+ // and the encoded name agree.
224
+ const canonical = normalizeHandle(p.name);
225
+ if (canonical !== p.name) {
226
+ throw new Error(`name must be the canonical handle "${canonical}" (got "${p.name}") — normalizeHandle() it and derive \`handle\` from the same value`);
227
+ }
197
228
  const nameBytes = new TextEncoder().encode(p.name);
198
229
  if (nameBytes.length < 1 || nameBytes.length > 32) {
199
230
  throw new Error("name must be 1..=32 bytes (UTF-8)");