s402 0.2.0 → 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/CHANGELOG.md +15 -1
- package/README.md +26 -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 +110 -5
- package/dist/index.d.mts +86 -4
- package/dist/index.mjs +94 -6
- package/dist/receipts.mjs +49 -11
- package/package.json +18 -12
- package/test/conformance/README.md +172 -0
- package/test/conformance/vectors/body-transport.json +142 -0
- package/test/conformance/vectors/compat-normalize.json +259 -0
- package/test/conformance/vectors/payload-decode.json +110 -0
- package/test/conformance/vectors/payload-encode.json +127 -0
- package/test/conformance/vectors/receipt-format.json +668 -0
- package/test/conformance/vectors/receipt-parse.json +450 -0
- package/test/conformance/vectors/requirements-decode.json +377 -0
- package/test/conformance/vectors/requirements-encode.json +338 -0
- package/test/conformance/vectors/roundtrip.json +156 -0
- package/test/conformance/vectors/settle-decode.json +76 -0
- package/test/conformance/vectors/settle-encode.json +65 -0
- package/test/conformance/vectors/validation-reject.json +317 -0
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.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"
|
|
@@ -36,7 +40,8 @@
|
|
|
36
40
|
"README.md",
|
|
37
41
|
"LICENSE",
|
|
38
42
|
"CHANGELOG.md",
|
|
39
|
-
"SECURITY.md"
|
|
43
|
+
"SECURITY.md",
|
|
44
|
+
"test/conformance/vectors"
|
|
40
45
|
],
|
|
41
46
|
"main": "./dist/index.mjs",
|
|
42
47
|
"module": "./dist/index.mjs",
|
|
@@ -85,21 +90,22 @@
|
|
|
85
90
|
"default": "./dist/receipts.mjs"
|
|
86
91
|
}
|
|
87
92
|
},
|
|
88
|
-
"devDependencies": {
|
|
89
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
90
|
-
"fast-check": "^4.5.3",
|
|
91
|
-
"tsdown": "^0.20.3",
|
|
92
|
-
"typescript": "^5.7.0",
|
|
93
|
-
"vitepress": "^1.6.4",
|
|
94
|
-
"vitest": "^3.0.5"
|
|
95
|
-
},
|
|
96
93
|
"scripts": {
|
|
97
94
|
"build": "tsdown",
|
|
98
95
|
"typecheck": "tsc --noEmit",
|
|
99
96
|
"test": "vitest run",
|
|
100
97
|
"test:watch": "vitest",
|
|
98
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm run test",
|
|
101
99
|
"docs:dev": "vitepress dev docs",
|
|
102
100
|
"docs:build": "vitepress build docs",
|
|
103
101
|
"docs:preview": "vitepress preview docs"
|
|
102
|
+
},
|
|
103
|
+
"devDependencies": {
|
|
104
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
105
|
+
"fast-check": "^4.5.3",
|
|
106
|
+
"tsdown": "^0.20.3",
|
|
107
|
+
"typescript": "^5.7.0",
|
|
108
|
+
"vitepress": "^1.6.4",
|
|
109
|
+
"vitest": "^3.0.5"
|
|
104
110
|
}
|
|
105
|
-
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
```
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"description": "Requirements body encode/decode",
|
|
4
|
+
"input": {
|
|
5
|
+
"type": "requirements",
|
|
6
|
+
"value": {
|
|
7
|
+
"s402Version": "1",
|
|
8
|
+
"accepts": [
|
|
9
|
+
"exact"
|
|
10
|
+
],
|
|
11
|
+
"network": "sui:mainnet",
|
|
12
|
+
"asset": "0x2::sui::SUI",
|
|
13
|
+
"amount": "1000000",
|
|
14
|
+
"payTo": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"expected": {
|
|
18
|
+
"body": "{\"s402Version\":\"1\",\"accepts\":[\"exact\"],\"network\":\"sui:mainnet\",\"asset\":\"0x2::sui::SUI\",\"amount\":\"1000000\",\"payTo\":\"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\"}",
|
|
19
|
+
"decoded": {
|
|
20
|
+
"s402Version": "1",
|
|
21
|
+
"accepts": [
|
|
22
|
+
"exact"
|
|
23
|
+
],
|
|
24
|
+
"network": "sui:mainnet",
|
|
25
|
+
"asset": "0x2::sui::SUI",
|
|
26
|
+
"amount": "1000000",
|
|
27
|
+
"payTo": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"shouldReject": false
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"description": "Requirements body with extensions",
|
|
34
|
+
"input": {
|
|
35
|
+
"type": "requirements",
|
|
36
|
+
"value": {
|
|
37
|
+
"s402Version": "1",
|
|
38
|
+
"accepts": [
|
|
39
|
+
"exact"
|
|
40
|
+
],
|
|
41
|
+
"network": "sui:mainnet",
|
|
42
|
+
"asset": "0x2::sui::SUI",
|
|
43
|
+
"amount": "1000000",
|
|
44
|
+
"payTo": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
45
|
+
"extensions": {
|
|
46
|
+
"auction": {
|
|
47
|
+
"type": "sealed-bid",
|
|
48
|
+
"deadline": 1708000000000
|
|
49
|
+
},
|
|
50
|
+
"customField": "hello world"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"expected": {
|
|
55
|
+
"body": "{\"s402Version\":\"1\",\"accepts\":[\"exact\"],\"network\":\"sui:mainnet\",\"asset\":\"0x2::sui::SUI\",\"amount\":\"1000000\",\"payTo\":\"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\",\"extensions\":{\"auction\":{\"type\":\"sealed-bid\",\"deadline\":1708000000000},\"customField\":\"hello world\"}}",
|
|
56
|
+
"decoded": {
|
|
57
|
+
"s402Version": "1",
|
|
58
|
+
"accepts": [
|
|
59
|
+
"exact"
|
|
60
|
+
],
|
|
61
|
+
"network": "sui:mainnet",
|
|
62
|
+
"asset": "0x2::sui::SUI",
|
|
63
|
+
"amount": "1000000",
|
|
64
|
+
"payTo": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
65
|
+
"extensions": {
|
|
66
|
+
"auction": {
|
|
67
|
+
"type": "sealed-bid",
|
|
68
|
+
"deadline": 1708000000000
|
|
69
|
+
},
|
|
70
|
+
"customField": "hello world"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"shouldReject": false
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"description": "Payload body encode/decode",
|
|
78
|
+
"input": {
|
|
79
|
+
"type": "payload",
|
|
80
|
+
"value": {
|
|
81
|
+
"s402Version": "1",
|
|
82
|
+
"scheme": "exact",
|
|
83
|
+
"payload": {
|
|
84
|
+
"transaction": "dHhfYnl0ZXM=",
|
|
85
|
+
"signature": "c2lnX2J5dGVz"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"expected": {
|
|
90
|
+
"body": "{\"s402Version\":\"1\",\"scheme\":\"exact\",\"payload\":{\"transaction\":\"dHhfYnl0ZXM=\",\"signature\":\"c2lnX2J5dGVz\"}}",
|
|
91
|
+
"decoded": {
|
|
92
|
+
"s402Version": "1",
|
|
93
|
+
"scheme": "exact",
|
|
94
|
+
"payload": {
|
|
95
|
+
"transaction": "dHhfYnl0ZXM=",
|
|
96
|
+
"signature": "c2lnX2J5dGVz"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"shouldReject": false
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"description": "Settle body encode/decode",
|
|
104
|
+
"input": {
|
|
105
|
+
"type": "settle",
|
|
106
|
+
"value": {
|
|
107
|
+
"success": true,
|
|
108
|
+
"txDigest": "ABC123",
|
|
109
|
+
"finalityMs": 300
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"expected": {
|
|
113
|
+
"body": "{\"success\":true,\"txDigest\":\"ABC123\",\"finalityMs\":300}",
|
|
114
|
+
"decoded": {
|
|
115
|
+
"success": true,
|
|
116
|
+
"txDigest": "ABC123",
|
|
117
|
+
"finalityMs": 300
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"shouldReject": false
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"description": "Settle failure body encode/decode",
|
|
124
|
+
"input": {
|
|
125
|
+
"type": "settle",
|
|
126
|
+
"value": {
|
|
127
|
+
"success": false,
|
|
128
|
+
"error": "Gas object not found",
|
|
129
|
+
"errorCode": "SETTLEMENT_FAILED"
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
"expected": {
|
|
133
|
+
"body": "{\"success\":false,\"error\":\"Gas object not found\",\"errorCode\":\"SETTLEMENT_FAILED\"}",
|
|
134
|
+
"decoded": {
|
|
135
|
+
"success": false,
|
|
136
|
+
"error": "Gas object not found",
|
|
137
|
+
"errorCode": "SETTLEMENT_FAILED"
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"shouldReject": false
|
|
141
|
+
}
|
|
142
|
+
]
|