s402 0.2.1 → 0.2.3

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
@@ -36,6 +36,18 @@ HTTP 402 ("Payment Required") has been reserved since 1999 — waiting for a pay
36
36
 
37
37
  **s402 is Sui-native by design.** These advantages come from Sui's object model, PTBs, and sub-second finality. They can't be replicated on EVM — and they don't need to be. x402 already handles EVM well. s402 handles Sui better.
38
38
 
39
+ ## Which Scheme Should I Use?
40
+
41
+ | Your situation | Scheme | Gas per 1K calls | Latency |
42
+ |---|---|---|---|
43
+ | One-time API call, simplest path | **Exact** | $7.00 | ~400ms |
44
+ | High-frequency API (10+ calls) | **Prepaid** | $0.014 | ~0ms per call |
45
+ | Buyer needs dispute protection | **Escrow** | $7.00 | ~400ms |
46
+ | Selling encrypted content | **Unlock** | $7.00 | ~400ms |
47
+ | Real-time billing (per-second) | **Stream** | variable | ~400ms setup |
48
+
49
+ **Quick decision:** Use **Prepaid** for AI agents making repeated API calls. Use **Exact** for everything else (it's the x402-compatible default). See the [full guide](https://s402-protocol.org/guide/which-scheme) for details.
50
+
39
51
  ## Architecture
40
52
 
41
53
  ```
package/dist/compat.d.mts CHANGED
@@ -108,6 +108,20 @@ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope): s402Pa
108
108
  * Validates required fields to catch malformed/malicious payloads at the trust boundary.
109
109
  *
110
110
  * Returns a clean object with only known s402 fields — unknown top-level keys are stripped.
111
+ *
112
+ * @param obj - Raw decoded JSON (could be s402, x402 V1, or x402 V2 envelope)
113
+ * @returns Validated s402PaymentRequirements
114
+ * @throws {s402Error} `INVALID_PAYLOAD` if the format is unrecognized or malformed
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * import { normalizeRequirements } from 's402/compat';
119
+ *
120
+ * // Works with any format — auto-detects s402 vs x402
121
+ * const rawJson = JSON.parse(atob(header));
122
+ * const requirements = normalizeRequirements(rawJson);
123
+ * // Always returns s402PaymentRequirements regardless of input format
124
+ * ```
111
125
  */
112
126
  declare function normalizeRequirements(obj: Record<string, unknown>): s402PaymentRequirements;
113
127
  //#endregion
package/dist/compat.mjs CHANGED
@@ -127,8 +127,23 @@ function fromX402Envelope(envelope) {
127
127
  * Validates required fields to catch malformed/malicious payloads at the trust boundary.
128
128
  *
129
129
  * Returns a clean object with only known s402 fields — unknown top-level keys are stripped.
130
+ *
131
+ * @param obj - Raw decoded JSON (could be s402, x402 V1, or x402 V2 envelope)
132
+ * @returns Validated s402PaymentRequirements
133
+ * @throws {s402Error} `INVALID_PAYLOAD` if the format is unrecognized or malformed
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * import { normalizeRequirements } from 's402/compat';
138
+ *
139
+ * // Works with any format — auto-detects s402 vs x402
140
+ * const rawJson = JSON.parse(atob(header));
141
+ * const requirements = normalizeRequirements(rawJson);
142
+ * // Always returns s402PaymentRequirements regardless of input format
143
+ * ```
130
144
  */
131
145
  function normalizeRequirements(obj) {
146
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) throw new s402Error("INVALID_PAYLOAD", `Payment requirements must be a plain object, got ${obj === null ? "null" : Array.isArray(obj) ? "array" : typeof obj}`);
132
147
  if (isS402(obj)) {
133
148
  validateRequirementsShape(obj);
134
149
  return pickRequirementsFields(obj);
package/dist/errors.d.mts CHANGED
@@ -33,10 +33,36 @@ interface s402ErrorInfo {
33
33
  }
34
34
  /**
35
35
  * Create a typed s402 error with recovery hints.
36
+ *
37
+ * @param code - One of the 15 s402 error codes (e.g. 'SETTLEMENT_FAILED')
38
+ * @param message - Optional human-readable message (defaults to the code)
39
+ * @returns Error info object with code, message, retryable flag, and suggestedAction
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const info = createS402Error('INSUFFICIENT_BALANCE');
44
+ * // { code: 'INSUFFICIENT_BALANCE', message: 'INSUFFICIENT_BALANCE',
45
+ * // retryable: false, suggestedAction: 'Top up wallet balance...' }
46
+ * ```
36
47
  */
37
48
  declare function createS402Error(code: s402ErrorCodeType, message?: string): s402ErrorInfo;
38
49
  /**
39
50
  * s402 error class for throwing in code.
51
+ * Every instance includes a machine-readable `code`, `retryable` flag, and `suggestedAction`.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * try {
56
+ * await facilitator.process(payload, requirements);
57
+ * } catch (e) {
58
+ * if (e instanceof s402Error) {
59
+ * console.log(e.code); // 'SETTLEMENT_FAILED'
60
+ * console.log(e.retryable); // true
61
+ * console.log(e.suggestedAction); // 'Transient RPC failure...'
62
+ * if (e.retryable) { } // retry with exponential backoff
63
+ * }
64
+ * }
65
+ * ```
40
66
  */
41
67
  declare class s402Error extends Error {
42
68
  readonly code: s402ErrorCodeType;
package/dist/errors.mjs CHANGED
@@ -89,6 +89,17 @@ const ERROR_HINTS = {
89
89
  };
90
90
  /**
91
91
  * Create a typed s402 error with recovery hints.
92
+ *
93
+ * @param code - One of the 15 s402 error codes (e.g. 'SETTLEMENT_FAILED')
94
+ * @param message - Optional human-readable message (defaults to the code)
95
+ * @returns Error info object with code, message, retryable flag, and suggestedAction
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const info = createS402Error('INSUFFICIENT_BALANCE');
100
+ * // { code: 'INSUFFICIENT_BALANCE', message: 'INSUFFICIENT_BALANCE',
101
+ * // retryable: false, suggestedAction: 'Top up wallet balance...' }
102
+ * ```
92
103
  */
93
104
  function createS402Error(code, message) {
94
105
  const hints = ERROR_HINTS[code];
@@ -101,6 +112,21 @@ function createS402Error(code, message) {
101
112
  }
102
113
  /**
103
114
  * s402 error class for throwing in code.
115
+ * Every instance includes a machine-readable `code`, `retryable` flag, and `suggestedAction`.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * try {
120
+ * await facilitator.process(payload, requirements);
121
+ * } catch (e) {
122
+ * if (e instanceof s402Error) {
123
+ * console.log(e.code); // 'SETTLEMENT_FAILED'
124
+ * console.log(e.retryable); // true
125
+ * console.log(e.suggestedAction); // 'Transient RPC failure...'
126
+ * if (e.retryable) { } // retry with exponential backoff
127
+ * }
128
+ * }
129
+ * ```
104
130
  */
105
131
  var s402Error = class extends Error {
106
132
  code;
package/dist/http.d.mts CHANGED
@@ -1,19 +1,85 @@
1
1
  import { s402PaymentPayload, s402PaymentRequirements, s402SettleResponse } from "./types.mjs";
2
2
 
3
3
  //#region src/http.d.ts
4
- /** Encode payment requirements for the `payment-required` header */
4
+ /**
5
+ * Encode payment requirements for the `payment-required` header.
6
+ *
7
+ * @param requirements - Typed s402 payment requirements
8
+ * @returns Base64-encoded JSON string for the HTTP header
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { encodePaymentRequired } from 's402/http';
13
+ *
14
+ * const header = encodePaymentRequired({
15
+ * s402Version: '1',
16
+ * accepts: ['exact'],
17
+ * network: 'sui:mainnet',
18
+ * asset: '0x2::sui::SUI',
19
+ * amount: '1000000',
20
+ * payTo: '0xYOUR_ADDRESS',
21
+ * });
22
+ * response.headers.set('payment-required', header);
23
+ * ```
24
+ */
5
25
  declare function encodePaymentRequired(requirements: s402PaymentRequirements): string;
6
- /** Encode payment payload for the `x-payment` header */
26
+ /**
27
+ * Encode payment payload for the `x-payment` header.
28
+ *
29
+ * @param payload - Typed s402 payment payload (any scheme)
30
+ * @returns Base64-encoded JSON string for the HTTP header
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * import { encodePaymentPayload, S402_HEADERS } from 's402/http';
35
+ *
36
+ * const header = encodePaymentPayload(payload);
37
+ * fetch(url, { headers: { [S402_HEADERS.PAYMENT]: header } });
38
+ * ```
39
+ */
7
40
  declare function encodePaymentPayload(payload: s402PaymentPayload): string;
8
41
  /** Encode settlement response for the `payment-response` header */
9
42
  declare function encodeSettleResponse(response: s402SettleResponse): string;
10
43
  /** Return a clean object with only known s402 requirement fields. */
11
44
  declare function pickRequirementsFields(obj: Record<string, unknown>): s402PaymentRequirements;
12
- /** Decode payment requirements from the `payment-required` header */
45
+ /**
46
+ * Decode payment requirements from the `payment-required` header.
47
+ * Validates shape, strips unknown keys, enforces size limit (64KB).
48
+ *
49
+ * @param header - Base64-encoded JSON string from the HTTP header
50
+ * @returns Validated s402 payment requirements
51
+ * @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import { decodePaymentRequired } from 's402/http';
56
+ *
57
+ * const header = response.headers.get('payment-required')!;
58
+ * const requirements = decodePaymentRequired(header);
59
+ * console.log(requirements.accepts); // ['exact', 'prepaid']
60
+ * console.log(requirements.amount); // '1000000'
61
+ * ```
62
+ */
13
63
  declare function decodePaymentRequired(header: string): s402PaymentRequirements;
14
64
  /** Return a clean payload object with only known s402 payload fields. */
15
65
  declare function pickPayloadFields(obj: Record<string, unknown>): s402PaymentPayload;
16
- /** Decode payment payload from the `x-payment` header */
66
+ /**
67
+ * Decode payment payload from the `x-payment` header.
68
+ * Validates shape, strips unknown keys, enforces size limit (64KB).
69
+ *
70
+ * @param header - Base64-encoded JSON string from the HTTP header
71
+ * @returns Validated s402 payment payload
72
+ * @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * import { decodePaymentPayload, S402_HEADERS } from 's402/http';
77
+ *
78
+ * const header = request.headers.get(S402_HEADERS.PAYMENT)!;
79
+ * const payload = decodePaymentPayload(header);
80
+ * console.log(payload.scheme); // 'exact'
81
+ * ```
82
+ */
17
83
  declare function decodePaymentPayload(header: string): s402PaymentPayload;
18
84
  /** Return a clean settle response with only known s402 fields. */
19
85
  declare function pickSettleResponseFields(obj: Record<string, unknown>): s402SettleResponse;
@@ -110,6 +176,22 @@ declare function detectProtocol(headers: Headers): 's402' | 'x402' | 'unknown';
110
176
  * Extract s402 payment requirements from a 402 Response.
111
177
  * Returns null if the header is missing, malformed, or not s402 format.
112
178
  * For x402 responses, decode the header manually and use normalizeRequirements().
179
+ *
180
+ * @param response - Fetch API Response object (status should be 402)
181
+ * @returns Parsed requirements, or null if not an s402 response
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * import { extractRequirementsFromResponse } from 's402/http';
186
+ *
187
+ * const res = await fetch(url);
188
+ * if (res.status === 402) {
189
+ * const requirements = extractRequirementsFromResponse(res);
190
+ * if (requirements) {
191
+ * // Build and send payment
192
+ * }
193
+ * }
194
+ * ```
113
195
  */
114
196
  declare function extractRequirementsFromResponse(response: Response): s402PaymentRequirements | null;
115
197
  //#endregion
package/dist/http.mjs CHANGED
@@ -13,11 +13,44 @@ function fromBase64(b64) {
13
13
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
14
14
  return new TextDecoder().decode(bytes);
15
15
  }
16
- /** Encode payment requirements for the `payment-required` header */
16
+ /**
17
+ * Encode payment requirements for the `payment-required` header.
18
+ *
19
+ * @param requirements - Typed s402 payment requirements
20
+ * @returns Base64-encoded JSON string for the HTTP header
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { encodePaymentRequired } from 's402/http';
25
+ *
26
+ * const header = encodePaymentRequired({
27
+ * s402Version: '1',
28
+ * accepts: ['exact'],
29
+ * network: 'sui:mainnet',
30
+ * asset: '0x2::sui::SUI',
31
+ * amount: '1000000',
32
+ * payTo: '0xYOUR_ADDRESS',
33
+ * });
34
+ * response.headers.set('payment-required', header);
35
+ * ```
36
+ */
17
37
  function encodePaymentRequired(requirements) {
18
38
  return toBase64(JSON.stringify(requirements));
19
39
  }
20
- /** Encode payment payload for the `x-payment` header */
40
+ /**
41
+ * Encode payment payload for the `x-payment` header.
42
+ *
43
+ * @param payload - Typed s402 payment payload (any scheme)
44
+ * @returns Base64-encoded JSON string for the HTTP header
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { encodePaymentPayload, S402_HEADERS } from 's402/http';
49
+ *
50
+ * const header = encodePaymentPayload(payload);
51
+ * fetch(url, { headers: { [S402_HEADERS.PAYMENT]: header } });
52
+ * ```
53
+ */
21
54
  function encodePaymentPayload(payload) {
22
55
  return toBase64(JSON.stringify(payload));
23
56
  }
@@ -104,8 +137,26 @@ function pickRequirementsFields(obj) {
104
137
  else result[key] = obj[key];
105
138
  return result;
106
139
  }
107
- /** Decode payment requirements from the `payment-required` header */
140
+ /**
141
+ * Decode payment requirements from the `payment-required` header.
142
+ * Validates shape, strips unknown keys, enforces size limit (64KB).
143
+ *
144
+ * @param header - Base64-encoded JSON string from the HTTP header
145
+ * @returns Validated s402 payment requirements
146
+ * @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * import { decodePaymentRequired } from 's402/http';
151
+ *
152
+ * const header = response.headers.get('payment-required')!;
153
+ * const requirements = decodePaymentRequired(header);
154
+ * console.log(requirements.accepts); // ['exact', 'prepaid']
155
+ * console.log(requirements.amount); // '1000000'
156
+ * ```
157
+ */
108
158
  function decodePaymentRequired(header) {
159
+ if (typeof header !== "string") throw new s402Error("INVALID_PAYLOAD", `payment-required header must be a string, got ${typeof header}`);
109
160
  if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `payment-required header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
110
161
  let parsed;
111
162
  try {
@@ -160,8 +211,25 @@ function pickPayloadFields(obj) {
160
211
  }
161
212
  return result;
162
213
  }
163
- /** Decode payment payload from the `x-payment` header */
214
+ /**
215
+ * Decode payment payload from the `x-payment` header.
216
+ * Validates shape, strips unknown keys, enforces size limit (64KB).
217
+ *
218
+ * @param header - Base64-encoded JSON string from the HTTP header
219
+ * @returns Validated s402 payment payload
220
+ * @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * import { decodePaymentPayload, S402_HEADERS } from 's402/http';
225
+ *
226
+ * const header = request.headers.get(S402_HEADERS.PAYMENT)!;
227
+ * const payload = decodePaymentPayload(header);
228
+ * console.log(payload.scheme); // 'exact'
229
+ * ```
230
+ */
164
231
  function decodePaymentPayload(header) {
232
+ if (typeof header !== "string") throw new s402Error("INVALID_PAYLOAD", `x-payment header must be a string, got ${typeof header}`);
165
233
  if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `x-payment header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
166
234
  let parsed;
167
235
  try {
@@ -195,6 +263,7 @@ function pickSettleResponseFields(obj) {
195
263
  }
196
264
  /** Decode settlement response from the `payment-response` header */
197
265
  function decodeSettleResponse(header) {
266
+ if (typeof header !== "string") throw new s402Error("INVALID_PAYLOAD", `payment-response header must be a string, got ${typeof header}`);
198
267
  if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `payment-response header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
199
268
  let parsed;
200
269
  try {
@@ -374,6 +443,19 @@ function validateRequirementsShape(obj) {
374
443
  if (record.facilitatorUrl !== void 0) {
375
444
  if (typeof record.facilitatorUrl !== "string") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must be a string, got ${typeof record.facilitatorUrl}`);
376
445
  if (/[\x00-\x1f\x7f]/.test(record.facilitatorUrl)) throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl contains control characters (potential header injection)");
446
+ try {
447
+ const url = new URL(record.facilitatorUrl);
448
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must use https:// or http://, got "${url.protocol}"`);
449
+ } catch (e) {
450
+ if (e instanceof s402Error) throw e;
451
+ throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
452
+ }
453
+ }
454
+ if (record.settlementMode !== void 0) {
455
+ if (record.settlementMode !== "facilitator" && record.settlementMode !== "direct") throw new s402Error("INVALID_PAYLOAD", `settlementMode must be "facilitator" or "direct", got ${JSON.stringify(record.settlementMode)}`);
456
+ }
457
+ if (record.receiptRequired !== void 0) {
458
+ if (typeof record.receiptRequired !== "boolean") throw new s402Error("INVALID_PAYLOAD", `receiptRequired must be a boolean, got ${typeof record.receiptRequired}`);
377
459
  }
378
460
  validateSubObjects(record);
379
461
  }
@@ -392,11 +474,21 @@ function validatePayloadShape(obj) {
392
474
  if (typeof inner.signature !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.signature must be a string, got ${typeof inner.signature}`);
393
475
  if (record.scheme === "unlock" && typeof inner.encryptionId !== "string") throw new s402Error("INVALID_PAYLOAD", `unlock payload requires encryptionId (string), got ${typeof inner.encryptionId}`);
394
476
  if (record.scheme === "prepaid" && typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
477
+ if (record.scheme === "prepaid" && inner.maxCalls !== void 0 && typeof inner.maxCalls !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a string if provided, got ${typeof inner.maxCalls}`);
395
478
  }
396
479
  /** Validate that a decoded settle response has the required shape. */
397
480
  function validateSettleShape(obj) {
398
481
  if (obj == null || typeof obj !== "object") throw new s402Error("INVALID_PAYLOAD", "Settle response is not an object");
399
- if (typeof obj.success !== "boolean") throw new s402Error("INVALID_PAYLOAD", "Malformed settle response: missing or invalid \"success\" (boolean)");
482
+ const record = obj;
483
+ if (typeof record.success !== "boolean") throw new s402Error("INVALID_PAYLOAD", "Malformed settle response: missing or invalid \"success\" (boolean)");
484
+ if (record.txDigest !== void 0 && typeof record.txDigest !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: txDigest must be a string, got ${typeof record.txDigest}`);
485
+ if (record.receiptId !== void 0 && typeof record.receiptId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: receiptId must be a string, got ${typeof record.receiptId}`);
486
+ if (record.finalityMs !== void 0 && (typeof record.finalityMs !== "number" || !Number.isFinite(record.finalityMs))) throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: finalityMs must be a finite number, got ${typeof record.finalityMs}`);
487
+ if (record.streamId !== void 0 && typeof record.streamId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: streamId must be a string, got ${typeof record.streamId}`);
488
+ if (record.escrowId !== void 0 && typeof record.escrowId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: escrowId must be a string, got ${typeof record.escrowId}`);
489
+ if (record.balanceId !== void 0 && typeof record.balanceId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: balanceId must be a string, got ${typeof record.balanceId}`);
490
+ if (record.error !== void 0 && typeof record.error !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: error must be a string, got ${typeof record.error}`);
491
+ if (record.errorCode !== void 0 && typeof record.errorCode !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: errorCode must be a string, got ${typeof record.errorCode}`);
400
492
  }
401
493
  /** Content type for s402 JSON body transport */
402
494
  const S402_CONTENT_TYPE = "application/s402+json";
@@ -406,6 +498,7 @@ function encodeRequirementsBody(requirements) {
406
498
  }
407
499
  /** Decode payment requirements from JSON string (from response body) */
408
500
  function decodeRequirementsBody(body) {
501
+ if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 requirements body must be a string, got ${typeof body}`);
409
502
  let parsed;
410
503
  try {
411
504
  parsed = JSON.parse(body);
@@ -421,6 +514,7 @@ function encodePayloadBody(payload) {
421
514
  }
422
515
  /** Decode payment payload from JSON string (from request body) */
423
516
  function decodePayloadBody(body) {
517
+ if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 payload body must be a string, got ${typeof body}`);
424
518
  let parsed;
425
519
  try {
426
520
  parsed = JSON.parse(body);
@@ -436,6 +530,7 @@ function encodeSettleBody(response) {
436
530
  }
437
531
  /** Decode settlement response from JSON string (from response body) */
438
532
  function decodeSettleBody(body) {
533
+ if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 settle body must be a string, got ${typeof body}`);
439
534
  let parsed;
440
535
  try {
441
536
  parsed = JSON.parse(body);
@@ -481,6 +576,22 @@ function detectProtocol(headers) {
481
576
  * Extract s402 payment requirements from a 402 Response.
482
577
  * Returns null if the header is missing, malformed, or not s402 format.
483
578
  * For x402 responses, decode the header manually and use normalizeRequirements().
579
+ *
580
+ * @param response - Fetch API Response object (status should be 402)
581
+ * @returns Parsed requirements, or null if not an s402 response
582
+ *
583
+ * @example
584
+ * ```ts
585
+ * import { extractRequirementsFromResponse } from 's402/http';
586
+ *
587
+ * const res = await fetch(url);
588
+ * if (res.status === 402) {
589
+ * const requirements = extractRequirementsFromResponse(res);
590
+ * if (requirements) {
591
+ * // Build and send payment
592
+ * }
593
+ * }
594
+ * ```
484
595
  */
485
596
  function extractRequirementsFromResponse(response) {
486
597
  const header = response.headers.get(S402_HEADERS.PAYMENT_REQUIRED);
package/dist/index.d.mts CHANGED
@@ -102,8 +102,19 @@ declare class s402Client {
102
102
  /**
103
103
  * Register a scheme implementation for a network.
104
104
  *
105
- * @param network - Sui network (e.g., "sui:testnet")
106
- * @param scheme - Scheme implementation
105
+ * @param network - Network identifier (e.g., "sui:testnet", "sui:mainnet")
106
+ * @param scheme - Scheme implementation (from @sweefi/sui or your own)
107
+ * @returns `this` for chaining
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * import { s402Client } from 's402';
112
+ *
113
+ * const client = new s402Client();
114
+ * client
115
+ * .register('sui:mainnet', exactScheme)
116
+ * .register('sui:mainnet', prepaidScheme);
117
+ * ```
107
118
  */
108
119
  register(network: string, scheme: s402ClientScheme): this;
109
120
  /**
@@ -114,6 +125,23 @@ declare class s402Client {
114
125
  *
115
126
  * Accepts typed s402PaymentRequirements only. For x402 input, normalize
116
127
  * first via `normalizeRequirements()` from 's402/compat'.
128
+ *
129
+ * @param requirements - Server's payment requirements (from a 402 response)
130
+ * @returns Payment payload ready to send in the `x-payment` header
131
+ * @throws {s402Error} `NETWORK_MISMATCH` if no schemes registered for the network
132
+ * @throws {s402Error} `SCHEME_NOT_SUPPORTED` if no registered scheme matches server's accepts
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * import { s402Client, decodePaymentRequired, encodePaymentPayload, S402_HEADERS } from 's402';
137
+ *
138
+ * const client = new s402Client();
139
+ * client.register('sui:mainnet', exactScheme);
140
+ *
141
+ * const requirements = decodePaymentRequired(res.headers.get('payment-required')!);
142
+ * const payload = await client.createPayment(requirements);
143
+ * fetch(url, { headers: { [S402_HEADERS.PAYMENT]: encodePaymentPayload(payload) } });
144
+ * ```
117
145
  */
118
146
  createPayment(requirements: s402PaymentRequirements): Promise<s402PaymentPayload>;
119
147
  /**
@@ -141,12 +169,33 @@ declare class s402Facilitator {
141
169
  */
142
170
  settle(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
143
171
  /**
144
- * Expiration-guarded verify + settle in one call.
172
+ * Expiration-guarded verify + settle in one call. **This is the recommended path.**
145
173
  * Rejects expired requirements, verifies the payload, then settles.
174
+ * Includes deduplication (prevents concurrent identical requests) and timeouts
175
+ * (5s verify, 15s settle).
146
176
  *
147
177
  * Note: True atomicity comes from Sui's PTBs in the scheme implementation,
148
178
  * not from this method. This method provides the expiration guard and
149
- * sequential verifysettle orchestration.
179
+ * sequential verify-then-settle orchestration.
180
+ *
181
+ * @param payload - Client's payment payload
182
+ * @param requirements - Server's payment requirements
183
+ * @returns Settlement result (check `result.success` and `result.errorCode`)
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * import { s402Facilitator } from 's402';
188
+ *
189
+ * const facilitator = new s402Facilitator();
190
+ * facilitator.register('sui:mainnet', exactFacilitatorScheme);
191
+ *
192
+ * const result = await facilitator.process(payload, requirements);
193
+ * if (result.success) {
194
+ * console.log(result.txDigest); // Sui transaction digest
195
+ * } else {
196
+ * console.log(result.errorCode); // e.g. 'VERIFICATION_FAILED'
197
+ * }
198
+ * ```
150
199
  */
151
200
  process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
152
201
  /**
@@ -176,6 +225,25 @@ declare class s402ResourceServer {
176
225
  * Build payment requirements for a route.
177
226
  * Uses the first scheme in the config's schemes array.
178
227
  * Always includes "exact" in accepts for x402 compat.
228
+ *
229
+ * @param config - Per-route payment configuration (schemes, price, network, payTo, asset)
230
+ * @returns Payment requirements ready to encode for the 402 response
231
+ * @throws {s402Error} `INVALID_PAYLOAD` if price is not a valid non-negative integer string
232
+ *
233
+ * @example
234
+ * ```ts
235
+ * import { s402ResourceServer, encodePaymentRequired } from 's402';
236
+ *
237
+ * const server = new s402ResourceServer();
238
+ * const requirements = server.buildRequirements({
239
+ * schemes: ['exact'],
240
+ * price: '1000000',
241
+ * network: 'sui:mainnet',
242
+ * payTo: '0xYOUR_ADDRESS',
243
+ * asset: '0x2::sui::SUI',
244
+ * });
245
+ * res.status(402).setHeader('payment-required', encodePaymentRequired(requirements));
246
+ * ```
179
247
  */
180
248
  buildRequirements(config: s402RouteConfig): s402PaymentRequirements;
181
249
  /**
@@ -191,6 +259,20 @@ declare class s402ResourceServer {
191
259
  * Expiration-guarded verify + settle. This is the recommended path.
192
260
  * Rejects expired requirements, verifies the payload, then settles.
193
261
  * True atomicity comes from Sui PTBs in the scheme implementation.
262
+ *
263
+ * @param payload - Client's payment payload (from the `x-payment` header)
264
+ * @param requirements - The requirements this server originally sent
265
+ * @returns Settlement result with txDigest on success
266
+ * @throws {s402Error} `FACILITATOR_UNAVAILABLE` if no facilitator is configured
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const result = await server.process(payload, requirements);
271
+ * if (result.success) {
272
+ * // Serve the protected resource
273
+ * res.status(200).json({ data: 'paid content' });
274
+ * }
275
+ * ```
194
276
  */
195
277
  process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
196
278
  }
package/dist/index.mjs CHANGED
@@ -9,8 +9,19 @@ var s402Client = class {
9
9
  /**
10
10
  * Register a scheme implementation for a network.
11
11
  *
12
- * @param network - Sui network (e.g., "sui:testnet")
13
- * @param scheme - Scheme implementation
12
+ * @param network - Network identifier (e.g., "sui:testnet", "sui:mainnet")
13
+ * @param scheme - Scheme implementation (from @sweefi/sui or your own)
14
+ * @returns `this` for chaining
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { s402Client } from 's402';
19
+ *
20
+ * const client = new s402Client();
21
+ * client
22
+ * .register('sui:mainnet', exactScheme)
23
+ * .register('sui:mainnet', prepaidScheme);
24
+ * ```
14
25
  */
15
26
  register(network, scheme) {
16
27
  if (!this.schemes.has(network)) this.schemes.set(network, /* @__PURE__ */ new Map());
@@ -25,6 +36,23 @@ var s402Client = class {
25
36
  *
26
37
  * Accepts typed s402PaymentRequirements only. For x402 input, normalize
27
38
  * first via `normalizeRequirements()` from 's402/compat'.
39
+ *
40
+ * @param requirements - Server's payment requirements (from a 402 response)
41
+ * @returns Payment payload ready to send in the `x-payment` header
42
+ * @throws {s402Error} `NETWORK_MISMATCH` if no schemes registered for the network
43
+ * @throws {s402Error} `SCHEME_NOT_SUPPORTED` if no registered scheme matches server's accepts
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * import { s402Client, decodePaymentRequired, encodePaymentPayload, S402_HEADERS } from 's402';
48
+ *
49
+ * const client = new s402Client();
50
+ * client.register('sui:mainnet', exactScheme);
51
+ *
52
+ * const requirements = decodePaymentRequired(res.headers.get('payment-required')!);
53
+ * const payload = await client.createPayment(requirements);
54
+ * fetch(url, { headers: { [S402_HEADERS.PAYMENT]: encodePaymentPayload(payload) } });
55
+ * ```
28
56
  */
29
57
  async createPayment(requirements) {
30
58
  const networkSchemes = this.schemes.get(requirements.network);
@@ -67,6 +95,25 @@ var s402ResourceServer = class {
67
95
  * Build payment requirements for a route.
68
96
  * Uses the first scheme in the config's schemes array.
69
97
  * Always includes "exact" in accepts for x402 compat.
98
+ *
99
+ * @param config - Per-route payment configuration (schemes, price, network, payTo, asset)
100
+ * @returns Payment requirements ready to encode for the 402 response
101
+ * @throws {s402Error} `INVALID_PAYLOAD` if price is not a valid non-negative integer string
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * import { s402ResourceServer, encodePaymentRequired } from 's402';
106
+ *
107
+ * const server = new s402ResourceServer();
108
+ * const requirements = server.buildRequirements({
109
+ * schemes: ['exact'],
110
+ * price: '1000000',
111
+ * network: 'sui:mainnet',
112
+ * payTo: '0xYOUR_ADDRESS',
113
+ * asset: '0x2::sui::SUI',
114
+ * });
115
+ * res.status(402).setHeader('payment-required', encodePaymentRequired(requirements));
116
+ * ```
70
117
  */
71
118
  buildRequirements(config) {
72
119
  if (!isValidAmount(config.price)) throw new s402Error("INVALID_PAYLOAD", `Invalid price "${config.price}": must be a non-negative integer string`);
@@ -118,6 +165,20 @@ var s402ResourceServer = class {
118
165
  * Expiration-guarded verify + settle. This is the recommended path.
119
166
  * Rejects expired requirements, verifies the payload, then settles.
120
167
  * True atomicity comes from Sui PTBs in the scheme implementation.
168
+ *
169
+ * @param payload - Client's payment payload (from the `x-payment` header)
170
+ * @param requirements - The requirements this server originally sent
171
+ * @returns Settlement result with txDigest on success
172
+ * @throws {s402Error} `FACILITATOR_UNAVAILABLE` if no facilitator is configured
173
+ *
174
+ * @example
175
+ * ```ts
176
+ * const result = await server.process(payload, requirements);
177
+ * if (result.success) {
178
+ * // Serve the protected resource
179
+ * res.status(200).json({ data: 'paid content' });
180
+ * }
181
+ * ```
121
182
  */
122
183
  async process(payload, requirements) {
123
184
  if (!this.facilitator) throw new s402Error("FACILITATOR_UNAVAILABLE", "No facilitator configured on this server");
@@ -212,12 +273,33 @@ var s402Facilitator = class {
212
273
  }
213
274
  }
214
275
  /**
215
- * Expiration-guarded verify + settle in one call.
276
+ * Expiration-guarded verify + settle in one call. **This is the recommended path.**
216
277
  * Rejects expired requirements, verifies the payload, then settles.
278
+ * Includes deduplication (prevents concurrent identical requests) and timeouts
279
+ * (5s verify, 15s settle).
217
280
  *
218
281
  * Note: True atomicity comes from Sui's PTBs in the scheme implementation,
219
282
  * not from this method. This method provides the expiration guard and
220
- * sequential verifysettle orchestration.
283
+ * sequential verify-then-settle orchestration.
284
+ *
285
+ * @param payload - Client's payment payload
286
+ * @param requirements - Server's payment requirements
287
+ * @returns Settlement result (check `result.success` and `result.errorCode`)
288
+ *
289
+ * @example
290
+ * ```ts
291
+ * import { s402Facilitator } from 's402';
292
+ *
293
+ * const facilitator = new s402Facilitator();
294
+ * facilitator.register('sui:mainnet', exactFacilitatorScheme);
295
+ *
296
+ * const result = await facilitator.process(payload, requirements);
297
+ * if (result.success) {
298
+ * console.log(result.txDigest); // Sui transaction digest
299
+ * } else {
300
+ * console.log(result.errorCode); // e.g. 'VERIFICATION_FAILED'
301
+ * }
302
+ * ```
221
303
  */
222
304
  async process(payload, requirements) {
223
305
  if (requirements.expiresAt != null) {
@@ -264,7 +346,10 @@ var s402Facilitator = class {
264
346
  try {
265
347
  let verifyResult;
266
348
  try {
267
- verifyResult = await Promise.race([scheme.verify(payload, requirements), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Verification timed out after 5s")), 5e3))]);
349
+ let verifyTimer;
350
+ verifyResult = await Promise.race([scheme.verify(payload, requirements), new Promise((_, reject) => {
351
+ verifyTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Verification timed out after 5s")), 5e3);
352
+ })]).finally(() => clearTimeout(verifyTimer));
268
353
  } catch (e) {
269
354
  return {
270
355
  success: false,
@@ -283,7 +368,10 @@ var s402Facilitator = class {
283
368
  errorCode: "REQUIREMENTS_EXPIRED"
284
369
  };
285
370
  try {
286
- return await Promise.race([scheme.settle(payload, requirements), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("Settlement timed out after 15s")), 15e3))]);
371
+ let settleTimer;
372
+ return await Promise.race([scheme.settle(payload, requirements), new Promise((_, reject) => {
373
+ settleTimer = setTimeout(() => reject(/* @__PURE__ */ new Error("Settlement timed out after 15s")), 15e3);
374
+ })]).finally(() => clearTimeout(settleTimer));
287
375
  } catch (e) {
288
376
  return {
289
377
  success: false,
package/dist/receipts.mjs CHANGED
@@ -1,4 +1,23 @@
1
+ import { s402Error } from "./errors.mjs";
2
+
1
3
  //#region src/receipts.ts
4
+ /**
5
+ * s402 Receipt HTTP Helpers — chain-agnostic receipt header format/parse.
6
+ *
7
+ * Providers sign each API response with Ed25519. The signature, call number,
8
+ * timestamp, and response hash are transported as a single HTTP header:
9
+ *
10
+ * X-S402-Receipt: v2:base64(signature):callNumber:timestampMs:base64(responseHash)
11
+ *
12
+ * This module handles the header wire format ONLY. It does not:
13
+ * - Construct BCS messages (chain-specific — see @sweefi/sui receipts.ts)
14
+ * - Generate Ed25519 keys (implementations provide their own)
15
+ * - Accumulate receipts (client-side concern — see @sweefi/sui ReceiptAccumulator)
16
+ *
17
+ * Zero runtime dependencies. Uses built-in btoa/atob.
18
+ *
19
+ * @see docs/schemes/prepaid.md — v0.2 spec
20
+ */
2
21
  /** HTTP header name for s402 signed usage receipts. */
3
22
  const S402_RECEIPT_HEADER = "X-S402-Receipt";
4
23
  const HEADER_VERSION = "v2";
@@ -8,9 +27,14 @@ function uint8ToBase64(bytes) {
8
27
  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
9
28
  return btoa(binary);
10
29
  }
11
- /** Decode a base64 string to Uint8Array using built-in atob. */
30
+ /** Decode a base64 string to Uint8Array using built-in atob. Throws Error on invalid base64. */
12
31
  function base64ToUint8(b64) {
13
- const binary = atob(b64);
32
+ let binary;
33
+ try {
34
+ binary = atob(b64);
35
+ } catch {
36
+ throw new s402Error("INVALID_PAYLOAD", `Invalid base64: "${b64.slice(0, 20)}${b64.length > 20 ? "..." : ""}"`);
37
+ }
14
38
  const bytes = new Uint8Array(binary.length);
15
39
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
16
40
  return bytes;
@@ -40,21 +64,35 @@ function formatReceiptHeader(receipt) {
40
64
  * @throws On empty string, wrong number of parts, or unknown version
41
65
  */
42
66
  function parseReceiptHeader(header) {
43
- if (!header) throw new Error("Empty receipt header");
67
+ if (!header) throw new s402Error("INVALID_PAYLOAD", "Empty receipt header");
44
68
  const parts = header.split(":");
45
- if (parts.length !== 5) throw new Error(`Malformed receipt header: expected 5 colon-separated parts, got ${parts.length}`);
69
+ if (parts.length !== 5) throw new s402Error("INVALID_PAYLOAD", `Malformed receipt header: expected 5 colon-separated parts, got ${parts.length}`);
46
70
  const [version, sigB64, callNumberStr, timestampMsStr, hashB64] = parts;
47
- if (version !== HEADER_VERSION) throw new Error(`Unknown receipt header version: "${version}" (expected "${HEADER_VERSION}")`);
48
- const callNumber = BigInt(callNumberStr);
49
- const timestampMs = BigInt(timestampMsStr);
50
- if (callNumber <= 0n) throw new Error(`Invalid receipt callNumber: must be positive, got ${callNumber}`);
51
- if (timestampMs <= 0n) throw new Error(`Invalid receipt timestampMs: must be positive, got ${timestampMs}`);
71
+ if (version !== HEADER_VERSION) throw new s402Error("INVALID_PAYLOAD", `Unknown receipt header version: "${version}" (expected "${HEADER_VERSION}")`);
72
+ let callNumber;
73
+ let timestampMs;
74
+ try {
75
+ callNumber = BigInt(callNumberStr);
76
+ } catch {
77
+ throw new s402Error("INVALID_PAYLOAD", `Invalid receipt callNumber: not a valid integer "${callNumberStr}"`);
78
+ }
79
+ try {
80
+ timestampMs = BigInt(timestampMsStr);
81
+ } catch {
82
+ throw new s402Error("INVALID_PAYLOAD", `Invalid receipt timestampMs: not a valid integer "${timestampMsStr}"`);
83
+ }
84
+ if (callNumber <= 0n) throw new s402Error("INVALID_PAYLOAD", `Invalid receipt callNumber: must be positive, got ${callNumber}`);
85
+ if (timestampMs <= 0n) throw new s402Error("INVALID_PAYLOAD", `Invalid receipt timestampMs: must be positive, got ${timestampMs}`);
86
+ const signature = base64ToUint8(sigB64);
87
+ if (signature.length !== 64) throw new s402Error("INVALID_PAYLOAD", `Receipt signature must be 64 bytes (Ed25519), got ${signature.length}`);
88
+ const responseHash = base64ToUint8(hashB64);
89
+ if (responseHash.length !== 32) throw new s402Error("INVALID_PAYLOAD", `Receipt responseHash must be 32 bytes (SHA-256), got ${responseHash.length}`);
52
90
  return {
53
91
  version: "v2",
54
- signature: base64ToUint8(sigB64),
92
+ signature,
55
93
  callNumber,
56
94
  timestampMs,
57
- responseHash: base64ToUint8(hashB64)
95
+ responseHash
58
96
  };
59
97
  }
60
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s402",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "s402 — Chain-agnostic HTTP 402 wire format. Types, HTTP encoding, and scheme registry for five payment schemes. Wire-compatible with x402. Zero runtime dependencies.",
6
6
  "license": "Apache-2.0",
@@ -25,7 +25,11 @@
25
25
  "streaming",
26
26
  "escrow",
27
27
  "web3",
28
- "protocol"
28
+ "protocol",
29
+ "ai-agent",
30
+ "machine-to-machine",
31
+ "m2m",
32
+ "agent-payments"
29
33
  ],
30
34
  "engines": {
31
35
  "node": ">=18"
@@ -86,22 +90,21 @@
86
90
  "default": "./dist/receipts.mjs"
87
91
  }
88
92
  },
93
+ "devDependencies": {
94
+ "@vitest/coverage-v8": "^3.2.4",
95
+ "fast-check": "^4.5.3",
96
+ "tsdown": "^0.20.3",
97
+ "typescript": "^5.7.0",
98
+ "vitepress": "^1.6.4",
99
+ "vitest": "^3.0.5"
100
+ },
89
101
  "scripts": {
90
102
  "build": "tsdown",
91
103
  "typecheck": "tsc --noEmit",
92
104
  "test": "vitest run",
93
105
  "test:watch": "vitest",
94
- "prepublishOnly": "npm run build && npm run typecheck && npm run test",
95
106
  "docs:dev": "vitepress dev docs",
96
107
  "docs:build": "vitepress build docs",
97
108
  "docs:preview": "vitepress preview docs"
98
- },
99
- "devDependencies": {
100
- "@vitest/coverage-v8": "^3.2.4",
101
- "fast-check": "^4.5.3",
102
- "tsdown": "^0.20.3",
103
- "typescript": "^5.7.0",
104
- "vitepress": "^1.6.4",
105
- "vitest": "^3.0.5"
106
109
  }
107
- }
110
+ }
@@ -1,172 +0,0 @@
1
- # s402 Conformance Test Vectors
2
-
3
- Machine-readable test vectors that define s402-compliant behavior. Use these to verify any s402 implementation — TypeScript, Go, Python, Rust, etc.
4
-
5
- ## Getting the Vectors
6
-
7
- The vectors ship with the `s402` npm package:
8
-
9
- ```bash
10
- npm pack s402
11
- tar xzf s402-*.tgz
12
- ls package/test/conformance/vectors/
13
- ```
14
-
15
- Or clone the repo directly:
16
-
17
- ```bash
18
- git clone https://github.com/s402-protocol/core.git
19
- ls core/test/conformance/vectors/
20
- ```
21
-
22
- ## Vector Format
23
-
24
- Each JSON file contains an array of test cases:
25
-
26
- ```json
27
- [
28
- {
29
- "description": "Human-readable test name",
30
- "input": { ... },
31
- "expected": { ... },
32
- "shouldReject": false
33
- }
34
- ]
35
- ```
36
-
37
- ### Accept vectors (`shouldReject: false`)
38
-
39
- The implementation MUST produce `expected` when given `input`.
40
-
41
- ### Reject vectors (`shouldReject: true`)
42
-
43
- The implementation MUST reject the input with an error. The `expectedErrorCode` field indicates which error:
44
-
45
- ```json
46
- {
47
- "description": "Rejects negative amount",
48
- "input": { "header": "base64-encoded-malformed-json" },
49
- "shouldReject": true,
50
- "expectedErrorCode": "INVALID_PAYLOAD"
51
- }
52
- ```
53
-
54
- ## Vector Files
55
-
56
- | File | Tests | Description |
57
- |------|-------|-------------|
58
- | `requirements-encode.json` | Encode | `s402PaymentRequirements` object → base64 header string |
59
- | `requirements-decode.json` | Decode | base64 header string → `s402PaymentRequirements` object |
60
- | `payload-encode.json` | Encode | `s402PaymentPayload` object → base64 header string |
61
- | `payload-decode.json` | Decode | base64 header string → `s402PaymentPayload` object |
62
- | `settle-encode.json` | Encode | `s402SettleResponse` object → base64 header string |
63
- | `settle-decode.json` | Decode | base64 header string → `s402SettleResponse` object |
64
- | `body-transport.json` | Both | Requirements/payload/settle via JSON body (no base64) |
65
- | `compat-normalize.json` | Normalize | x402 V1/V2 input → normalized s402 output |
66
- | `receipt-format.json` | Format | Receipt fields → `X-S402-Receipt` header string |
67
- | `receipt-parse.json` | Parse | `X-S402-Receipt` header string → parsed receipt fields |
68
- | `validation-reject.json` | Reject | Malformed inputs that MUST be rejected |
69
- | `roundtrip.json` | Roundtrip | encode → decode → re-encode = identical output |
70
-
71
- ## Encoding Scheme
72
-
73
- s402 uses **Unicode-safe base64** for HTTP header transport:
74
-
75
- 1. JSON-serialize the object: `JSON.stringify(obj)`
76
- 2. UTF-8 encode the string: `TextEncoder.encode(json)`
77
- 3. Base64 encode the bytes: `btoa(bytes.map(b => String.fromCharCode(b)).join(''))`
78
-
79
- For ASCII-only content (the common case), this produces the same output as plain `btoa(json)`.
80
-
81
- Body transport uses raw JSON (no base64).
82
-
83
- ## Receipt Header Format
84
-
85
- The `X-S402-Receipt` header uses colon-separated fields:
86
-
87
- ```
88
- v2:base64(signature):callNumber:timestampMs:base64(responseHash)
89
- ```
90
-
91
- - `v2` — version prefix (always "v2" for signed receipts)
92
- - `signature` — 64-byte Ed25519 signature, base64-encoded
93
- - `callNumber` — sequential call number (1-indexed, bigint string)
94
- - `timestampMs` — Unix timestamp in milliseconds (bigint string)
95
- - `responseHash` — response body hash (typically SHA-256, 32 bytes), base64-encoded
96
-
97
- In the vector JSON files, `signature` and `responseHash` are represented as arrays of byte values (0-255) since JSON has no binary type.
98
-
99
- ## Implementing in Another Language
100
-
101
- 1. Load the JSON vector files
102
- 2. For each vector where `shouldReject` is `false`:
103
- - Feed `input` to your encode/decode function
104
- - Compare the result to `expected`
105
- 3. For each vector where `shouldReject` is `true`:
106
- - Feed `input` to your decode function
107
- - Verify it throws/returns an error with the matching error code
108
- 4. For roundtrip vectors:
109
- - Verify `expected.identical` is `true`
110
- - Verify `expected.firstEncode === expected.reEncode`
111
-
112
- ### Key stripping on decode
113
-
114
- All three decode functions (`decodePaymentRequired`, `decodePaymentPayload`, `decodeSettleResponse`) MUST strip unknown top-level keys. Only known fields should survive the decode. This is a defense-in-depth measure at the HTTP trust boundary.
115
-
116
- **Requirements known top-level keys:** `s402Version`, `accepts`, `network`, `asset`, `amount`, `payTo`, `facilitatorUrl`, `mandate`, `protocolFeeBps`, `protocolFeeAddress`, `receiptRequired`, `settlementMode`, `expiresAt`, `stream`, `escrow`, `unlock`, `prepaid`, `extensions`.
117
-
118
- **Requirements sub-object known keys** (also stripped of unknowns):
119
- - `mandate`: `required`, `minPerTx`, `coinType`
120
- - `stream`: `ratePerSecond`, `budgetCap`, `minDeposit`, `streamSetupUrl`
121
- - `escrow`: `seller`, `arbiter`, `deadlineMs`
122
- - `unlock`: `encryptionId`, `walrusBlobId`, `encryptionPackageId`
123
- - `prepaid`: `ratePerCall`, `maxCalls`, `minDeposit`, `withdrawalDelayMs`, `providerPubkey`, `disputeWindowMs`
124
-
125
- **Payload known top-level keys:** `s402Version`, `scheme`, `payload`.
126
-
127
- **Payload inner keys** (per scheme):
128
- - `exact`, `stream`, `escrow`: `transaction`, `signature`
129
- - `unlock`: `transaction`, `signature`, `encryptionId`
130
- - `prepaid`: `transaction`, `signature`, `ratePerCall`, `maxCalls`
131
-
132
- **Settle response known keys:** `success`, `txDigest`, `receiptId`, `finalityMs`, `streamId`, `escrowId`, `balanceId`, `error`, `errorCode`.
133
-
134
- ### JSON key ordering
135
-
136
- s402 vectors use JavaScript's `JSON.stringify()` key ordering (insertion order). Cross-language implementations MUST serialize keys in the same order as the vector files to produce identical base64 output. If your language's JSON serializer uses alphabetical ordering by default, you'll need ordered serialization (e.g., Go's `json.Marshal` on a struct with ordered fields, Python's `json.dumps` with `sort_keys=False`).
137
-
138
- Note: **decode also reorders keys**. The decode functions iterate their allowlist in the order listed above, not the input order. If the input JSON has keys in a different order, the decoded output follows the allowlist order. This affects roundtrip tests — the re-encoded output uses the allowlist order, which may differ from the original input order.
139
-
140
- ### Header size limits
141
-
142
- Implementations SHOULD enforce a maximum header size of **64 KiB** (`MAX_HEADER_BYTES = 65536`). Headers exceeding this limit indicate abuse or misconfiguration and SHOULD be rejected before base64 decoding.
143
-
144
- ### Rejection vector dispatch
145
-
146
- Rejection vectors are dispatched in this precedence order:
147
-
148
- 1. If `expectedErrorCode` is `"RECEIPT_PARSE_ERROR"` → test `parseReceiptHeader`. Note: receipts throw a plain `Error`, not an `s402Error`, since receipts are a separate subsystem.
149
- 2. If `input.decodeAs` is `"payload"` → test `decodePaymentPayload`
150
- 3. If `input.decodeAs` is `"compat"` → test `normalizeRequirements` with the raw JSON (not base64)
151
- 4. Otherwise → test `decodePaymentRequired` (the default)
152
-
153
- ### Error codes
154
-
155
- Rejection vectors use `expectedErrorCode` values from the s402 error code enum:
156
-
157
- - `INVALID_PAYLOAD` — malformed or invalid input
158
- - `RECEIPT_PARSE_ERROR` — malformed receipt header (vector convention for receipt-specific parsing errors)
159
-
160
- ## Regenerating Vectors
161
-
162
- If you modify the s402 encoding logic, regenerate vectors:
163
-
164
- ```bash
165
- npx tsx test/conformance/generate-vectors.ts
166
- ```
167
-
168
- Then run the conformance tests to verify:
169
-
170
- ```bash
171
- pnpm run test
172
- ```