s402 0.2.1 → 0.2.2

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,7 +137,24 @@ 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) {
109
159
  if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `payment-required header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
110
160
  let parsed;
@@ -160,7 +210,23 @@ function pickPayloadFields(obj) {
160
210
  }
161
211
  return result;
162
212
  }
163
- /** Decode payment payload from the `x-payment` header */
213
+ /**
214
+ * Decode payment payload from the `x-payment` header.
215
+ * Validates shape, strips unknown keys, enforces size limit (64KB).
216
+ *
217
+ * @param header - Base64-encoded JSON string from the HTTP header
218
+ * @returns Validated s402 payment payload
219
+ * @throws {s402Error} `INVALID_PAYLOAD` on oversized header, invalid base64/JSON, or malformed shape
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * import { decodePaymentPayload, S402_HEADERS } from 's402/http';
224
+ *
225
+ * const header = request.headers.get(S402_HEADERS.PAYMENT)!;
226
+ * const payload = decodePaymentPayload(header);
227
+ * console.log(payload.scheme); // 'exact'
228
+ * ```
229
+ */
164
230
  function decodePaymentPayload(header) {
165
231
  if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `x-payment header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
166
232
  let parsed;
@@ -374,6 +440,19 @@ function validateRequirementsShape(obj) {
374
440
  if (record.facilitatorUrl !== void 0) {
375
441
  if (typeof record.facilitatorUrl !== "string") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must be a string, got ${typeof record.facilitatorUrl}`);
376
442
  if (/[\x00-\x1f\x7f]/.test(record.facilitatorUrl)) throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl contains control characters (potential header injection)");
443
+ try {
444
+ const url = new URL(record.facilitatorUrl);
445
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must use https:// or http://, got "${url.protocol}"`);
446
+ } catch (e) {
447
+ if (e instanceof s402Error) throw e;
448
+ throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
449
+ }
450
+ }
451
+ if (record.settlementMode !== void 0) {
452
+ if (record.settlementMode !== "facilitator" && record.settlementMode !== "direct") throw new s402Error("INVALID_PAYLOAD", `settlementMode must be "facilitator" or "direct", got ${JSON.stringify(record.settlementMode)}`);
453
+ }
454
+ if (record.receiptRequired !== void 0) {
455
+ if (typeof record.receiptRequired !== "boolean") throw new s402Error("INVALID_PAYLOAD", `receiptRequired must be a boolean, got ${typeof record.receiptRequired}`);
377
456
  }
378
457
  validateSubObjects(record);
379
458
  }
@@ -392,11 +471,21 @@ function validatePayloadShape(obj) {
392
471
  if (typeof inner.signature !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.signature must be a string, got ${typeof inner.signature}`);
393
472
  if (record.scheme === "unlock" && typeof inner.encryptionId !== "string") throw new s402Error("INVALID_PAYLOAD", `unlock payload requires encryptionId (string), got ${typeof inner.encryptionId}`);
394
473
  if (record.scheme === "prepaid" && typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
474
+ 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
475
  }
396
476
  /** Validate that a decoded settle response has the required shape. */
397
477
  function validateSettleShape(obj) {
398
478
  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)");
479
+ const record = obj;
480
+ if (typeof record.success !== "boolean") throw new s402Error("INVALID_PAYLOAD", "Malformed settle response: missing or invalid \"success\" (boolean)");
481
+ 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}`);
482
+ 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}`);
483
+ 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}`);
484
+ 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}`);
485
+ 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}`);
486
+ 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}`);
487
+ 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}`);
488
+ 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
489
  }
401
490
  /** Content type for s402 JSON body transport */
402
491
  const S402_CONTENT_TYPE = "application/s402+json";
@@ -481,6 +570,22 @@ function detectProtocol(headers) {
481
570
  * Extract s402 payment requirements from a 402 Response.
482
571
  * Returns null if the header is missing, malformed, or not s402 format.
483
572
  * For x402 responses, decode the header manually and use normalizeRequirements().
573
+ *
574
+ * @param response - Fetch API Response object (status should be 402)
575
+ * @returns Parsed requirements, or null if not an s402 response
576
+ *
577
+ * @example
578
+ * ```ts
579
+ * import { extractRequirementsFromResponse } from 's402/http';
580
+ *
581
+ * const res = await fetch(url);
582
+ * if (res.status === 402) {
583
+ * const requirements = extractRequirementsFromResponse(res);
584
+ * if (requirements) {
585
+ * // Build and send payment
586
+ * }
587
+ * }
588
+ * ```
484
589
  */
485
590
  function extractRequirementsFromResponse(response) {
486
591
  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.2",
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"