@x1id/resolve 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/x402.d.ts CHANGED
@@ -21,12 +21,21 @@
21
21
  * its own SVM chain, but `@x402/core`'s reference client/facilitator only route
22
22
  * the `solana:*` scheme family — so to let ANY standard, unmodified x402 client
23
23
  * or wallet pay an x1id agent out of the box, X1 testnet uses the CAIP-30 form
24
- * keyed on X1's genesis. (Fortiswap's self-hosted X1 facilitator uses the
25
- * identical id.) An earlier release used a principled `"x1:<full-genesis>"`
24
+ * keyed on X1's genesis. x1id's X1 rail is standalone and self-verified: the
25
+ * buyer self-sponsors and broadcasts, and the seller checks the finalized
26
+ * transaction on chain itself; no external facilitator is involved. An
27
+ * earlier release used a principled `"x1:<full-genesis>"`
26
28
  * extension; it was OSS-incompatible, so this migrated to CAIP-30 —
27
29
  * {@link X1_LEGACY_TESTNET_NETWORK} is kept only for reading older manifests.
28
30
  * Verified against `getGenesisHash` (full hash
29
31
  * `C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho`).
32
+ *
33
+ * # HTTP headers (x402 v2 transport)
34
+ *
35
+ * api.x1id.io sends the 402 body also as `PAYMENT-REQUIRED` (base64 JSON),
36
+ * accepts the payment in `PAYMENT-SIGNATURE` (base64 JSON; the v1 name
37
+ * `X-PAYMENT` still works), and returns `PAYMENT-RESPONSE` plus the v1
38
+ * `X-PAYMENT-RESPONSE` on success.
30
39
  */
31
40
  /** X1 testnet's x402 network id — CAIP-30 `"solana:" + first 32 chars of the
32
41
  * genesis hash — so unmodified OSS x402 clients route to it (#8325). */
@@ -72,9 +81,23 @@ export interface X402PaymentRequirements {
72
81
  * no sponsor (see docs/x402.md): this SDK always sets it to the
73
82
  * BUYER's own address (self-sponsored, no facilitator, no custody). */
74
83
  readonly feePayer: string;
75
- /** UTF-8, <=256 bytes. Lets a seller correlate the on-chain payment to
76
- * an invoice without a unique deposit address per sale. */
77
- readonly memo?: string;
84
+ /** Reference-key mode (api.x1id.io, R3-x402-1): a base58 pubkey derived
85
+ * from the request + challenge nonce + payer (see
86
+ * {@link deriveX402Reference}). The buyer appends it to the
87
+ * TransferChecked instruction as one extra READ-ONLY, NON-SIGNER account.
88
+ * Nobody holds a key for it. */
89
+ readonly reference?: string;
90
+ /** Hex SHA-256 of the canonical request the challenge was issued for
91
+ * ({@link x402RequestHash}). Informational: lets a client confirm it is
92
+ * about to pay for the request it will actually send. */
93
+ readonly requestHash?: string;
94
+ /** R4-D-1 payer-signed redemption (api.x1id.io): the exact UTF-8 message
95
+ * the bound payer signs (ed25519, `signMessage`) to redeem this challenge
96
+ * ({@link x402RedeemMessage}). The base58 signature goes in the payment
97
+ * payload as `payload.payerSig`. The on-chain payment is public once it
98
+ * lands; this signature is what proves the REDEEMER is the payer, so a
99
+ * front-runner who copies the transaction signature cannot redeem it. */
100
+ readonly redeemMessage?: string;
78
101
  readonly recentBlockhash?: string;
79
102
  readonly lastValidBlockHeight?: string;
80
103
  };
@@ -97,6 +120,10 @@ export interface X402PaymentPayload {
97
120
  };
98
121
  }
99
122
  export interface BuildX402RequirementsParams {
123
+ /** Reference-key mode: the derived reference pubkey + request hash
124
+ * ({@link deriveX402Reference}, {@link x402RequestHash}). */
125
+ readonly reference?: string;
126
+ readonly requestHash?: string;
100
127
  /** The recipient's base58 address. */
101
128
  readonly payTo: string;
102
129
  /** The SPL/Token-2022 mint's base58 address. */
@@ -114,7 +141,6 @@ export interface BuildX402RequirementsParams {
114
141
  * default. Pass {@link X1_TESTNET_NETWORK} for testnet. */
115
142
  readonly network?: string;
116
143
  readonly maxTimeoutSeconds?: number;
117
- readonly memo?: string;
118
144
  readonly recentBlockhash?: string;
119
145
  readonly lastValidBlockHeight?: string;
120
146
  }
@@ -128,3 +154,41 @@ export declare function buildX402Requirements(p: BuildX402RequirementsParams): X
128
154
  /** Parse a 402 response body, returning null if it doesn't look like one
129
155
  * (missing/wrong `x402Version`, `accepts` not an array). Never throws. */
130
156
  export declare function parseX402Response(body: unknown): X402PaymentRequiredResponse | null;
157
+ /** Domain-separation tag for the reference preimage. */
158
+ export declare const X402_REFERENCE_DOMAIN = "x1id-x402-ref:v1|api.x1id.io/dev-api";
159
+ /** SHA-256("") in hex — the body hash of a GET/HEAD or empty body. */
160
+ export declare const X402_EMPTY_BODY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
161
+ /** Canonicalise a URL path (see the section docs). */
162
+ export declare function canonicalizeX402Path(path: string): string;
163
+ /** Canonicalise a query string (with or without the leading "?"). */
164
+ export declare function canonicalizeX402Query(query: string): string;
165
+ export interface X402RequestInput {
166
+ /** HTTP method; uppercased. */
167
+ readonly method: string;
168
+ /** The request target: a path with optional `?query` and `#fragment`, or an
169
+ * absolute URL (scheme + host are dropped). */
170
+ readonly url: string;
171
+ /** The exact body bytes (a string is hashed as UTF-8). Ignored for GET/HEAD. */
172
+ readonly body?: Uint8Array | string | null;
173
+ }
174
+ /** The canonical request string (see the section docs). */
175
+ export declare function canonicalX402Request(req: X402RequestInput): string;
176
+ /** Hex SHA-256 of {@link canonicalX402Request} — `accepts[].extra.requestHash`. */
177
+ export declare function x402RequestHash(req: X402RequestInput): string;
178
+ /** Domain-separation tag for the payer-signed redemption message (R4-D-1). */
179
+ export declare const X402_REDEEM_DOMAIN = "x1id-x402-redeem:v1|api.x1id.io/dev-api";
180
+ /**
181
+ * R4-D-1: the message the bound payer signs to redeem a challenge — over its
182
+ * `accepts[].extra.reference`, which is single-use and already bound
183
+ * (server-side) to the endpoint, the exact request and the payer, so the
184
+ * signature cannot be replayed onto another challenge. Sent as base58 in
185
+ * `payload.payerSig`. The 402 also carries it verbatim as
186
+ * `accepts[].extra.redeemMessage`.
187
+ */
188
+ export declare function x402RedeemMessage(reference: string): string;
189
+ /** Derive the base58 reference pubkey for a request + challenge nonce + payer. */
190
+ export declare function deriveX402Reference(p: {
191
+ readonly canonicalRequest: string;
192
+ readonly nonce: string;
193
+ readonly payer: string;
194
+ }): string;
package/dist/x402.js CHANGED
@@ -21,13 +21,24 @@
21
21
  * its own SVM chain, but `@x402/core`'s reference client/facilitator only route
22
22
  * the `solana:*` scheme family — so to let ANY standard, unmodified x402 client
23
23
  * or wallet pay an x1id agent out of the box, X1 testnet uses the CAIP-30 form
24
- * keyed on X1's genesis. (Fortiswap's self-hosted X1 facilitator uses the
25
- * identical id.) An earlier release used a principled `"x1:<full-genesis>"`
24
+ * keyed on X1's genesis. x1id's X1 rail is standalone and self-verified: the
25
+ * buyer self-sponsors and broadcasts, and the seller checks the finalized
26
+ * transaction on chain itself; no external facilitator is involved. An
27
+ * earlier release used a principled `"x1:<full-genesis>"`
26
28
  * extension; it was OSS-incompatible, so this migrated to CAIP-30 —
27
29
  * {@link X1_LEGACY_TESTNET_NETWORK} is kept only for reading older manifests.
28
30
  * Verified against `getGenesisHash` (full hash
29
31
  * `C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWwT5iTdeaviDho`).
32
+ *
33
+ * # HTTP headers (x402 v2 transport)
34
+ *
35
+ * api.x1id.io sends the 402 body also as `PAYMENT-REQUIRED` (base64 JSON),
36
+ * accepts the payment in `PAYMENT-SIGNATURE` (base64 JSON; the v1 name
37
+ * `X-PAYMENT` still works), and returns `PAYMENT-RESPONSE` plus the v1
38
+ * `X-PAYMENT-RESPONSE` on success.
30
39
  */
40
+ import { encodeBase58 } from "./base58.js";
41
+ import { sha256, toHex } from "./sha256.js";
31
42
  /** X1 testnet's x402 network id — CAIP-30 `"solana:" + first 32 chars of the
32
43
  * genesis hash — so unmodified OSS x402 clients route to it (#8325). */
33
44
  export const X1_TESTNET_NETWORK = "solana:C7ucgdDEhxLTpXHhWSZxavSVmaNTUJWw";
@@ -71,12 +82,17 @@ export function buildX402Requirements(p) {
71
82
  if (!/^\d+$/.test(p.amount)) {
72
83
  throw new Error("amount must be a decimal string of smallest units (e.g. \"1000000\")");
73
84
  }
74
- if (p.memo !== undefined && new TextEncoder().encode(p.memo).length > 256) {
75
- throw new Error("memo must be <=256 UTF-8 bytes");
76
- }
77
85
  const extra = { feePayer: p.buyer };
78
- if (p.memo !== undefined)
79
- extra.memo = p.memo;
86
+ if (p.reference !== undefined) {
87
+ if (!PUBKEY_RE.test(p.reference))
88
+ throw new Error("reference must be a base58 address");
89
+ extra.reference = p.reference;
90
+ }
91
+ if (p.requestHash !== undefined) {
92
+ if (!/^[0-9a-f]{64}$/.test(p.requestHash))
93
+ throw new Error("requestHash must be 64 lowercase hex chars");
94
+ extra.requestHash = p.requestHash;
95
+ }
80
96
  if (p.recentBlockhash !== undefined) {
81
97
  extra.recentBlockhash = p.recentBlockhash;
82
98
  }
@@ -103,3 +119,138 @@ export function parseX402Response(body) {
103
119
  return null;
104
120
  return { x402Version: X402_VERSION, accepts: b.accepts };
105
121
  }
122
+ // ── Request-bound reference keys (api.x1id.io R3-x402-1) ───────────────────────
123
+ //
124
+ // A 402 challenge from api.x1id.io binds the payment to the EXACT request it
125
+ // was issued for. The server derives a `reference` pubkey from the canonical
126
+ // request, the challenge nonce and the payer; the buyer appends it to the
127
+ // TransferChecked instruction as an extra read-only, non-signer account (SPL
128
+ // Token ignores extra accounts for a single-owner transfer), ~6.6k CU per
129
+ // payment. Nobody signs with the reference; it is not a keypair or a PDA, just
130
+ // 32 hash bytes read as an address.
131
+ //
132
+ // Canonical request (one UTF-8 string, "\n"-separated, no trailing newline):
133
+ //
134
+ // METHOD uppercased ("GET", "POST")
135
+ // path split on "/", each segment percent-decoded then re-encoded
136
+ // per RFC 3986 (unreserved A-Z a-z 0-9 - . _ ~ literal, every
137
+ // other byte %XX uppercase). No trailing-slash folding, case
138
+ // PRESERVED: /v1/resolve/@Alice and /v1/resolve/@alice are
139
+ // DIFFERENT requests (both canonicalise "@" to %40).
140
+ // query split on "&" (empty parts dropped), each "k=v" split on the
141
+ // first "=" ("k" alone == "k="), "+" read as a space, both
142
+ // sides percent-decoded then re-encoded as above, sorted by
143
+ // (key, value) on the encoded strings, duplicates KEPT, joined
144
+ // with "&". "" when there is no query. No fragment.
145
+ // bodyHash lowercase hex SHA-256 of the exact body bytes; GET/HEAD or
146
+ // no body = SHA-256("") = e3b0c442…b855.
147
+ //
148
+ // Scheme and host are excluded (the domain is fixed by the tag below).
149
+ //
150
+ // requestHash = hex(SHA-256(canonical))
151
+ // reference = base58(SHA-256(utf8(X402_REFERENCE_DOMAIN + "\n" + canonical
152
+ // + "\n" + nonce + "\n" + payer)))
153
+ /** Domain-separation tag for the reference preimage. */
154
+ export const X402_REFERENCE_DOMAIN = "x1id-x402-ref:v1|api.x1id.io/dev-api";
155
+ /** SHA-256("") in hex — the body hash of a GET/HEAD or empty body. */
156
+ export const X402_EMPTY_BODY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
157
+ function isUnreserved(b) {
158
+ return ((b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a) || (b >= 0x30 && b <= 0x39) ||
159
+ b === 0x2d || b === 0x2e || b === 0x5f || b === 0x7e);
160
+ }
161
+ function hexVal(c) {
162
+ if (c >= 0x30 && c <= 0x39)
163
+ return c - 0x30;
164
+ if (c >= 0x41 && c <= 0x46)
165
+ return c - 0x37;
166
+ if (c >= 0x61 && c <= 0x66)
167
+ return c - 0x57;
168
+ return -1;
169
+ }
170
+ /** Percent-decode to raw bytes. A `%` not followed by two hex digits is kept
171
+ * literally (decoding never throws). */
172
+ function pctDecode(s, plusIsSpace) {
173
+ const raw = new TextEncoder().encode(s);
174
+ const out = [];
175
+ for (let i = 0; i < raw.length; i++) {
176
+ const c = raw[i];
177
+ if (c === 0x25 && i + 2 < raw.length) {
178
+ const hi = hexVal(raw[i + 1]);
179
+ const lo = hexVal(raw[i + 2]);
180
+ if (hi >= 0 && lo >= 0) {
181
+ out.push(hi * 16 + lo);
182
+ i += 2;
183
+ continue;
184
+ }
185
+ }
186
+ out.push(plusIsSpace && c === 0x2b ? 0x20 : c);
187
+ }
188
+ return new Uint8Array(out);
189
+ }
190
+ function pctEncode(bytes) {
191
+ let s = "";
192
+ for (const b of bytes)
193
+ s += isUnreserved(b) ? String.fromCharCode(b) : "%" + b.toString(16).toUpperCase().padStart(2, "0");
194
+ return s;
195
+ }
196
+ /** Canonicalise a URL path (see the section docs). */
197
+ export function canonicalizeX402Path(path) {
198
+ const p = path === "" ? "/" : path;
199
+ return p
200
+ .split("/")
201
+ .map((seg) => pctEncode(pctDecode(seg, false)))
202
+ .join("/");
203
+ }
204
+ /** Canonicalise a query string (with or without the leading "?"). */
205
+ export function canonicalizeX402Query(query) {
206
+ const q = query.startsWith("?") ? query.slice(1) : query;
207
+ const pairs = [];
208
+ for (const part of q.split("&")) {
209
+ if (part === "")
210
+ continue;
211
+ const eq = part.indexOf("=");
212
+ const k = eq === -1 ? part : part.slice(0, eq);
213
+ const v = eq === -1 ? "" : part.slice(eq + 1);
214
+ pairs.push([pctEncode(pctDecode(k, true)), pctEncode(pctDecode(v, true))]);
215
+ }
216
+ pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
217
+ return pairs.map(([k, v]) => `${k}=${v}`).join("&");
218
+ }
219
+ /** The canonical request string (see the section docs). */
220
+ export function canonicalX402Request(req) {
221
+ const method = req.method.toUpperCase();
222
+ let target = req.url;
223
+ const abs = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*/.exec(target);
224
+ if (abs)
225
+ target = target.slice(abs[0].length);
226
+ const hashAt = target.indexOf("#");
227
+ if (hashAt !== -1)
228
+ target = target.slice(0, hashAt);
229
+ const qAt = target.indexOf("?");
230
+ const path = qAt === -1 ? target : target.slice(0, qAt);
231
+ const query = qAt === -1 ? "" : target.slice(qAt + 1);
232
+ const noBody = method === "GET" || method === "HEAD" || req.body === undefined || req.body === null;
233
+ const bodyHash = noBody ? X402_EMPTY_BODY_HASH : toHex(sha256(req.body));
234
+ return `${method}\n${canonicalizeX402Path(path)}\n${canonicalizeX402Query(query)}\n${bodyHash}`;
235
+ }
236
+ /** Hex SHA-256 of {@link canonicalX402Request} — `accepts[].extra.requestHash`. */
237
+ export function x402RequestHash(req) {
238
+ return toHex(sha256(canonicalX402Request(req)));
239
+ }
240
+ /** Domain-separation tag for the payer-signed redemption message (R4-D-1). */
241
+ export const X402_REDEEM_DOMAIN = "x1id-x402-redeem:v1|api.x1id.io/dev-api";
242
+ /**
243
+ * R4-D-1: the message the bound payer signs to redeem a challenge — over its
244
+ * `accepts[].extra.reference`, which is single-use and already bound
245
+ * (server-side) to the endpoint, the exact request and the payer, so the
246
+ * signature cannot be replayed onto another challenge. Sent as base58 in
247
+ * `payload.payerSig`. The 402 also carries it verbatim as
248
+ * `accepts[].extra.redeemMessage`.
249
+ */
250
+ export function x402RedeemMessage(reference) {
251
+ return `${X402_REDEEM_DOMAIN}\nreference:${reference}`;
252
+ }
253
+ /** Derive the base58 reference pubkey for a request + challenge nonce + payer. */
254
+ export function deriveX402Reference(p) {
255
+ return encodeBase58(sha256(`${X402_REFERENCE_DOMAIN}\n${p.canonicalRequest}\n${p.nonce}\n${p.payer}`));
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x1id/resolve",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Resolve @handles and X1NS names on X1. Never silently picks between namespaces.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",