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 +12 -0
- package/dist/compat.d.mts +14 -0
- package/dist/compat.mjs +15 -0
- package/dist/errors.d.mts +26 -0
- package/dist/errors.mjs +26 -0
- package/dist/http.d.mts +86 -4
- package/dist/http.mjs +116 -5
- package/dist/index.d.mts +86 -4
- package/dist/index.mjs +94 -6
- package/dist/receipts.mjs +49 -11
- package/package.json +15 -12
- package/test/conformance/README.md +0 -172
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 -
|
|
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 verify
|
|
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 -
|
|
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 verify
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
67
|
+
if (!header) throw new s402Error("INVALID_PAYLOAD", "Empty receipt header");
|
|
44
68
|
const parts = header.split(":");
|
|
45
|
-
if (parts.length !== 5) throw new
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
92
|
+
signature,
|
|
55
93
|
callNumber,
|
|
56
94
|
timestampMs,
|
|
57
|
-
responseHash
|
|
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.
|
|
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
|
-
```
|