s402 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +2 -2
- package/dist/compat.mjs +1 -0
- package/dist/errors.d.mts +2 -1
- package/dist/errors.mjs +8 -3
- package/dist/http.mjs +12 -5
- package/dist/index.d.mts +2 -94
- package/dist/scheme-D7qqwo3Q.d.mts +136 -0
- package/dist/test-utils.d.mts +74 -0
- package/dist/test-utils.mjs +132 -0
- package/dist/types.d.mts +13 -5
- package/package.json +17 -11
- package/test/conformance/vectors/requirements-decode.json +3 -3
- package/test/conformance/vectors/requirements-encode.json +3 -3
- package/test/conformance/vectors/validation-reject.json +0 -8
- package/test/conformance/README.md +0 -172
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-04-11
|
|
9
|
+
|
|
10
|
+
This release closes the facilitator causal-binding hole identified in the April 2026 scale-fragility review, and establishes s402 as a pure chain-agnostic protocol repo (no Sui code anywhere). Chain-specific implementations now live in downstream adapter repos — the canonical Sui reference is `@sweefi/sui` in the SweeFi monorepo.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`verifySettlement` — client-side causal-binding check (S8 Facilitator Accountability).** New optional method on `s402ClientScheme`. For all client-signed schemes (`exact`, `stream`, `escrow`, `unlock` TX1), this is a **local, offline comparison**: derive the expected transaction digest from the signed BCS bytes and compare to `SettleResponse.txDigest`. No RPC call required. Closes the causal-binding hole where a malicious facilitator could substitute an unrelated-but-real transaction digest — that digest would correspond to different signed bytes the client never produced, and the check would reject it. Interface-only in this release; concrete implementations land in `@sweefi/sui` per ADR-002. See `typescript/src/scheme.ts` and `INVARIANTS.md` § S8 for the full contract and copy-paste implementation template.
|
|
15
|
+
- **`s402SettlementVerification` type** — return shape for `verifySettlement`: `{ verified, expectedDigest, actualDigest, reason? }`.
|
|
16
|
+
- **`DIGEST_MISMATCH` error code** — `retryable: false`, with a `suggestedAction` warning callers NOT to retry on mismatch. Retrying is dangerous: the signed bytes may have already landed on-chain under the *expected* digest (the facilitator may simply be lying about what it broadcast), and a fresh retry would double-pay. The correct failure mode is to mark the payment as non-settled and stop trusting the facilitator. See `typescript/src/errors.ts`.
|
|
17
|
+
- **S8. Facilitator Accountability** — first-class safety invariant alongside S1–S7. Full statement, formal proof for the `exact` scheme on Sui (by blake2b-256 collision resistance), per-scheme scope table, and a copy-paste implementation template for downstream Sui adapters now live in `INVARIANTS.md` § S8. The Allium behavioral spec is at `spec/allium/s8-facilitator-accountability.allium`.
|
|
18
|
+
- **ADR-001 — Protocol Boundaries.** Documents four decisions from the scale-fragility council: (1) facilitator trust boundary sealed by client-side digest verification, (2) receipt cardinality is a non-guarantee at the protocol layer, (3) scheme cap at five with burden-of-proof for any new scheme, (4) extension hygiene rules. See `docs/adr/001-protocol-boundaries.md`.
|
|
19
|
+
- **ADR-002 — s402 is a pure protocol repo.** Decides that this repo contains NO chain-specific code at any path — not in `typescript/src/`, not in a sibling package, not anywhere. All Sui-specific implementation moves to SweeFi. Corollary: the S7 chain-agnostic boundary is now enforced repo-wide, not just inside `src/`. See `docs/adr/002-s402-is-pure-protocol.md`.
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- **`mcp-server/` directory deleted.** The Sui-specific MCP server that previously shipped in this repo has been relocated to `@sweefi/mcp` (canonical implementation in the SweeFi repo) per ADR-002. **This does not affect the npm `s402` package** — `mcp-server` was a separate consumer of this package, not part of it. Users who were installing from the repo directly should migrate to `npx @sweefi/mcp`; a forthcoming `npm deprecate s402-mcp` will redirect the legacy standalone package to the new name.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **S7 scope strengthened to repo-level.** `INVARIANTS.md` § S7 scope note now reads: "chain-specific code lives in downstream implementation repos (e.g. `@sweefi/sui` for the Sui implementation) which consume this package from npm and add chain validation on top. Per ADR-002, the s402 repo itself contains NO chain-specific imports at the repo level — the protocol-pure boundary is enforced repo-wide, not just inside `src/`."
|
|
28
|
+
- **`INVARIANTS.md` Sui references rewritten as downstream pointers.** Prior revisions of the S8 proof block referenced a reference implementation at `mcp-server/src/sui-exact.ts`. That path no longer exists; the proof block now points at `sweefi/packages/sui/src/s402/exact/client.ts` as the canonical Sui adapter per ADR-002.
|
|
29
|
+
- **Demo API distribution surfaces** (`demo-api/public/index.html` served at `demo.s402-protocol.org`, and `demo-api/src/server.ts`) now reference `@sweefi/mcp` with the correct `SUI_PRIVATE_KEY` / `SUI_NETWORK` environment variables, matching SweeFi's documented `mcpServers` config shape. Outside the npm package scope but noted here for consumers browsing the monorepo.
|
|
30
|
+
|
|
31
|
+
### Compatibility
|
|
32
|
+
|
|
33
|
+
- **TypeScript type compatibility**: `verifySettlement` is optional on `s402ClientScheme`, and `DIGEST_MISMATCH` is a purely additive enum member. Existing adapter implementations compile unchanged against `^0.3.0`.
|
|
34
|
+
- **Wire format**: unchanged from v0.2.3. The 132 conformance test vectors in `test/conformance/vectors/` still pass byte-for-byte against v0.3.0.
|
|
35
|
+
- **Minor-bump rationale**: the 0.2.3 → 0.3.0 jump reflects the semantic significance of adding a new safety invariant (S8) and the repo-level architectural decisions (ADR-001/002), not a breaking wire-format change. Under semver 0.x, minor bumps are treated as breaking by `^0.x.y` ranges — consumers should expect to opt-in explicitly.
|
|
36
|
+
|
|
8
37
|
## [0.2.1] - 2026-03-02
|
|
9
38
|
|
|
10
39
|
### Added
|
|
@@ -129,6 +158,7 @@ _Version bump for npm publish after license change._
|
|
|
129
158
|
- Property-based fuzz testing via fast-check
|
|
130
159
|
- 207 tests, zero runtime dependencies
|
|
131
160
|
|
|
161
|
+
[0.3.0]: https://github.com/s402-protocol/core/compare/v0.2.3...v0.3.0
|
|
132
162
|
[0.2.1]: https://github.com/s402-protocol/core/compare/v0.2.0...v0.2.1
|
|
133
163
|
[0.2.0]: https://github.com/s402-protocol/core/compare/v0.1.8...v0.2.0
|
|
134
164
|
[0.1.8]: https://github.com/s402-protocol/core/compare/v0.1.7...v0.1.8
|
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://github.com/s402-protocol/core/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/s402)
|
|
5
5
|
|
|
6
|
-
**
|
|
6
|
+
**Chain-agnostic HTTP 402 protocol.** Five payment schemes for AI agent commerce. Wire-compatible with x402. Zero runtime dependencies. Includes an optional compat layer (`s402/compat`) for normalizing x402 input.
|
|
7
7
|
|
|
8
|
-
s402 is
|
|
8
|
+
s402 is a chain-agnostic HTTP 402 wire format — types, HTTP encoding, scheme registry, and error handling for five payment schemes. The protocol layer contains no chain-specific logic (see [S7 invariant](./AGENTS.md)). The reference implementation on Sui uses Programmable Transaction Blocks to reduce 1,000 payments to just 2 on-chain transactions via the Prepaid scheme, cutting per-call effective gas from $0.007 to $0.000014 and making micropayments economically viable for AI agents for the first time.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npm install s402
|
package/dist/compat.mjs
CHANGED
|
@@ -15,6 +15,7 @@ function fromX402Requirements(x402) {
|
|
|
15
15
|
if (x402.facilitatorUrl !== void 0) try {
|
|
16
16
|
const url = new URL(x402.facilitatorUrl);
|
|
17
17
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must use https:// or http://, got "${url.protocol}"`);
|
|
18
|
+
if (url.username || url.password) throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl must not contain embedded credentials (user:password@)");
|
|
18
19
|
} catch (e) {
|
|
19
20
|
if (e instanceof s402Error) throw e;
|
|
20
21
|
throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
|
package/dist/errors.d.mts
CHANGED
|
@@ -23,6 +23,7 @@ declare const s402ErrorCode: {
|
|
|
23
23
|
readonly REQUIREMENTS_EXPIRED: "REQUIREMENTS_EXPIRED";
|
|
24
24
|
readonly VERIFICATION_FAILED: "VERIFICATION_FAILED";
|
|
25
25
|
readonly SETTLEMENT_FAILED: "SETTLEMENT_FAILED";
|
|
26
|
+
readonly DIGEST_MISMATCH: "DIGEST_MISMATCH";
|
|
26
27
|
};
|
|
27
28
|
type s402ErrorCodeType = (typeof s402ErrorCode)[keyof typeof s402ErrorCode];
|
|
28
29
|
interface s402ErrorInfo {
|
|
@@ -34,7 +35,7 @@ interface s402ErrorInfo {
|
|
|
34
35
|
/**
|
|
35
36
|
* Create a typed s402 error with recovery hints.
|
|
36
37
|
*
|
|
37
|
-
* @param code - One of the
|
|
38
|
+
* @param code - One of the 16 s402 error codes (e.g. 'SETTLEMENT_FAILED')
|
|
38
39
|
* @param message - Optional human-readable message (defaults to the code)
|
|
39
40
|
* @returns Error info object with code, message, retryable flag, and suggestedAction
|
|
40
41
|
*
|
package/dist/errors.mjs
CHANGED
|
@@ -22,7 +22,8 @@ const s402ErrorCode = {
|
|
|
22
22
|
SIGNATURE_INVALID: "SIGNATURE_INVALID",
|
|
23
23
|
REQUIREMENTS_EXPIRED: "REQUIREMENTS_EXPIRED",
|
|
24
24
|
VERIFICATION_FAILED: "VERIFICATION_FAILED",
|
|
25
|
-
SETTLEMENT_FAILED: "SETTLEMENT_FAILED"
|
|
25
|
+
SETTLEMENT_FAILED: "SETTLEMENT_FAILED",
|
|
26
|
+
DIGEST_MISMATCH: "DIGEST_MISMATCH"
|
|
26
27
|
};
|
|
27
28
|
/** Error recovery hints for each error code */
|
|
28
29
|
const ERROR_HINTS = {
|
|
@@ -68,7 +69,7 @@ const ERROR_HINTS = {
|
|
|
68
69
|
},
|
|
69
70
|
NETWORK_MISMATCH: {
|
|
70
71
|
retryable: false,
|
|
71
|
-
suggestedAction: "Ensure client and server are on the same
|
|
72
|
+
suggestedAction: "Ensure client and server are on the same network"
|
|
72
73
|
},
|
|
73
74
|
SIGNATURE_INVALID: {
|
|
74
75
|
retryable: false,
|
|
@@ -85,12 +86,16 @@ const ERROR_HINTS = {
|
|
|
85
86
|
SETTLEMENT_FAILED: {
|
|
86
87
|
retryable: true,
|
|
87
88
|
suggestedAction: "Transient RPC failure during settlement — retry in a few seconds"
|
|
89
|
+
},
|
|
90
|
+
DIGEST_MISMATCH: {
|
|
91
|
+
retryable: false,
|
|
92
|
+
suggestedAction: "Facilitator returned a transaction digest that does not match the signed payload. Do NOT retry — treat payment as non-settled and investigate the facilitator."
|
|
88
93
|
}
|
|
89
94
|
};
|
|
90
95
|
/**
|
|
91
96
|
* Create a typed s402 error with recovery hints.
|
|
92
97
|
*
|
|
93
|
-
* @param code - One of the
|
|
98
|
+
* @param code - One of the 16 s402 error codes (e.g. 'SETTLEMENT_FAILED')
|
|
94
99
|
* @param message - Optional human-readable message (defaults to the code)
|
|
95
100
|
* @returns Error info object with code, message, retryable flag, and suggestedAction
|
|
96
101
|
*
|
package/dist/http.mjs
CHANGED
|
@@ -109,8 +109,8 @@ const S402_SUB_OBJECT_KEYS = {
|
|
|
109
109
|
]),
|
|
110
110
|
unlock: new Set([
|
|
111
111
|
"encryptionId",
|
|
112
|
-
"
|
|
113
|
-
"
|
|
112
|
+
"encryptedContentId",
|
|
113
|
+
"encryptionServiceId"
|
|
114
114
|
]),
|
|
115
115
|
prepaid: new Set([
|
|
116
116
|
"ratePerCall",
|
|
@@ -156,6 +156,7 @@ function pickRequirementsFields(obj) {
|
|
|
156
156
|
* ```
|
|
157
157
|
*/
|
|
158
158
|
function decodePaymentRequired(header) {
|
|
159
|
+
if (typeof header !== "string") throw new s402Error("INVALID_PAYLOAD", `payment-required header must be a string, got ${typeof header}`);
|
|
159
160
|
if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `payment-required header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
|
|
160
161
|
let parsed;
|
|
161
162
|
try {
|
|
@@ -228,6 +229,7 @@ function pickPayloadFields(obj) {
|
|
|
228
229
|
* ```
|
|
229
230
|
*/
|
|
230
231
|
function decodePaymentPayload(header) {
|
|
232
|
+
if (typeof header !== "string") throw new s402Error("INVALID_PAYLOAD", `x-payment header must be a string, got ${typeof header}`);
|
|
231
233
|
if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `x-payment header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
|
|
232
234
|
let parsed;
|
|
233
235
|
try {
|
|
@@ -261,6 +263,7 @@ function pickSettleResponseFields(obj) {
|
|
|
261
263
|
}
|
|
262
264
|
/** Decode settlement response from the `payment-response` header */
|
|
263
265
|
function decodeSettleResponse(header) {
|
|
266
|
+
if (typeof header !== "string") throw new s402Error("INVALID_PAYLOAD", `payment-response header must be a string, got ${typeof header}`);
|
|
264
267
|
if (header.length > MAX_HEADER_BYTES) throw new s402Error("INVALID_PAYLOAD", `payment-response header exceeds maximum size (${header.length} > ${MAX_HEADER_BYTES})`);
|
|
265
268
|
let parsed;
|
|
266
269
|
try {
|
|
@@ -370,8 +373,8 @@ function validateUnlockShape(value) {
|
|
|
370
373
|
assertPlainObject(value, "unlock");
|
|
371
374
|
const obj = value;
|
|
372
375
|
assertString(obj, "encryptionId", "unlock");
|
|
373
|
-
assertString(obj, "
|
|
374
|
-
assertString(obj, "
|
|
376
|
+
assertString(obj, "encryptedContentId", "unlock");
|
|
377
|
+
assertString(obj, "encryptionServiceId", "unlock");
|
|
375
378
|
}
|
|
376
379
|
/**
|
|
377
380
|
* Validate prepaid sub-object.
|
|
@@ -417,7 +420,7 @@ function validateRequirementsShape(obj) {
|
|
|
417
420
|
if (typeof record.network !== "string") missing.push("network (string)");
|
|
418
421
|
if (typeof record.asset !== "string") missing.push("asset (string)");
|
|
419
422
|
if (typeof record.amount !== "string") missing.push("amount (string)");
|
|
420
|
-
else if (!
|
|
423
|
+
else if (!isValidAmount(record.amount)) throw new s402Error("INVALID_PAYLOAD", `Invalid amount "${record.amount}": must be a non-negative integer string`);
|
|
421
424
|
if (typeof record.payTo !== "string") missing.push("payTo (string)");
|
|
422
425
|
else if (record.payTo.length === 0) throw new s402Error("INVALID_PAYLOAD", "payTo must be a non-empty string");
|
|
423
426
|
if (missing.length > 0) throw new s402Error("INVALID_PAYLOAD", `Malformed payment requirements: missing ${missing.join(", ")}`);
|
|
@@ -443,6 +446,7 @@ function validateRequirementsShape(obj) {
|
|
|
443
446
|
try {
|
|
444
447
|
const url = new URL(record.facilitatorUrl);
|
|
445
448
|
if (url.protocol !== "https:" && url.protocol !== "http:") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must use https:// or http://, got "${url.protocol}"`);
|
|
449
|
+
if (url.username || url.password) throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl must not contain embedded credentials (user:password@)");
|
|
446
450
|
} catch (e) {
|
|
447
451
|
if (e instanceof s402Error) throw e;
|
|
448
452
|
throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
|
|
@@ -495,6 +499,7 @@ function encodeRequirementsBody(requirements) {
|
|
|
495
499
|
}
|
|
496
500
|
/** Decode payment requirements from JSON string (from response body) */
|
|
497
501
|
function decodeRequirementsBody(body) {
|
|
502
|
+
if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 requirements body must be a string, got ${typeof body}`);
|
|
498
503
|
let parsed;
|
|
499
504
|
try {
|
|
500
505
|
parsed = JSON.parse(body);
|
|
@@ -510,6 +515,7 @@ function encodePayloadBody(payload) {
|
|
|
510
515
|
}
|
|
511
516
|
/** Decode payment payload from JSON string (from request body) */
|
|
512
517
|
function decodePayloadBody(body) {
|
|
518
|
+
if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 payload body must be a string, got ${typeof body}`);
|
|
513
519
|
let parsed;
|
|
514
520
|
try {
|
|
515
521
|
parsed = JSON.parse(body);
|
|
@@ -525,6 +531,7 @@ function encodeSettleBody(response) {
|
|
|
525
531
|
}
|
|
526
532
|
/** Decode settlement response from JSON string (from response body) */
|
|
527
533
|
function decodeSettleBody(body) {
|
|
534
|
+
if (typeof body !== "string") throw new s402Error("INVALID_PAYLOAD", `s402 settle body must be a string, got ${typeof body}`);
|
|
528
535
|
let parsed;
|
|
529
536
|
try {
|
|
530
537
|
parsed = JSON.parse(body);
|
package/dist/index.d.mts
CHANGED
|
@@ -1,101 +1,9 @@
|
|
|
1
1
|
import { createS402Error, s402Error, s402ErrorCode, s402ErrorCodeType, s402ErrorInfo } from "./errors.mjs";
|
|
2
2
|
import { S402_HEADERS, S402_VERSION, s402Discovery, s402EscrowExtra, s402EscrowPayload, s402ExactPayload, s402Mandate, s402MandateRequirements, s402PaymentPayload, s402PaymentPayloadBase, s402PaymentRequirements, s402PaymentSession, s402PrepaidExtra, s402PrepaidPayload, s402RegistryQuery, s402Scheme, s402ServiceEntry, s402SettleResponse, s402SettlementMode, s402StreamExtra, s402StreamPayload, s402UnlockExtra, s402UnlockPayload, s402VerifyResponse } from "./types.mjs";
|
|
3
3
|
import { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, validateRequirementsShape } from "./http.mjs";
|
|
4
|
+
import { a as s402ServerScheme, i as s402RouteConfig, n as s402DirectScheme, o as s402SettlementVerification, r as s402FacilitatorScheme, t as s402ClientScheme } from "./scheme-D7qqwo3Q.mjs";
|
|
4
5
|
import { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader, s402Receipt, s402ReceiptSigner, s402ReceiptVerifier } from "./receipts.mjs";
|
|
5
6
|
|
|
6
|
-
//#region src/scheme.d.ts
|
|
7
|
-
/** Implemented by each scheme on the client side */
|
|
8
|
-
interface s402ClientScheme {
|
|
9
|
-
/** Which scheme this implements */
|
|
10
|
-
readonly scheme: s402Scheme;
|
|
11
|
-
/** Create a signed payment payload from server requirements */
|
|
12
|
-
createPayment(requirements: s402PaymentRequirements): Promise<s402PaymentPayload>;
|
|
13
|
-
}
|
|
14
|
-
/** Implemented by each scheme on the server side */
|
|
15
|
-
interface s402ServerScheme {
|
|
16
|
-
readonly scheme: s402Scheme;
|
|
17
|
-
/** Build payment requirements from route config */
|
|
18
|
-
buildRequirements(config: s402RouteConfig): s402PaymentRequirements;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Implemented by each scheme in the facilitator.
|
|
22
|
-
*
|
|
23
|
-
* Critical: each scheme has its OWN verify logic.
|
|
24
|
-
* - Exact: signature recovery + dry-run simulation + balance check
|
|
25
|
-
* - Stream: stream creation PTB validation + deposit check
|
|
26
|
-
* - Escrow: escrow creation PTB validation + arbiter/deadline check
|
|
27
|
-
* - Unlock: escrow validation (key release is separate PTB)
|
|
28
|
-
*/
|
|
29
|
-
interface s402FacilitatorScheme {
|
|
30
|
-
readonly scheme: s402Scheme;
|
|
31
|
-
/** Verify a payment payload without broadcasting */
|
|
32
|
-
verify(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402VerifyResponse>;
|
|
33
|
-
/** Verify and broadcast the transaction */
|
|
34
|
-
settle(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* For self-sovereign agents that hold their own keys.
|
|
38
|
-
* Builds, signs, and broadcasts in one step — no facilitator needed.
|
|
39
|
-
*
|
|
40
|
-
* MUST call waitForTransaction() before returning success.
|
|
41
|
-
* Without finality confirmation, server could grant access for a
|
|
42
|
-
* transaction that gets reverted.
|
|
43
|
-
*/
|
|
44
|
-
interface s402DirectScheme {
|
|
45
|
-
readonly scheme: s402Scheme;
|
|
46
|
-
/** Build, sign, broadcast, and wait for finality */
|
|
47
|
-
settleDirectly(requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
48
|
-
}
|
|
49
|
-
/** Per-route payment configuration for the server middleware */
|
|
50
|
-
interface s402RouteConfig {
|
|
51
|
-
/** Which payment scheme(s) to accept. Always includes "exact" for x402 compat. */
|
|
52
|
-
schemes: s402Scheme[];
|
|
53
|
-
/** Amount in base units, same as wire format (e.g., "1000000") */
|
|
54
|
-
price: string;
|
|
55
|
-
/** Network identifier (e.g., "sui:mainnet", "solana:mainnet-beta") */
|
|
56
|
-
network: string;
|
|
57
|
-
/** Recipient address (chain-specific format, validated by chain adapter) */
|
|
58
|
-
payTo: string;
|
|
59
|
-
/** Asset/coin type identifier (chain-specific, e.g., Sui Move type or Solana mint address) */
|
|
60
|
-
asset: string;
|
|
61
|
-
/** Facilitator URL (optional for direct settlement) */
|
|
62
|
-
facilitatorUrl?: string;
|
|
63
|
-
/** Settlement mode preference */
|
|
64
|
-
settlementMode?: s402SettlementMode;
|
|
65
|
-
/** AP2 mandate requirements */
|
|
66
|
-
mandate?: {
|
|
67
|
-
required: boolean;
|
|
68
|
-
minPerTx?: string;
|
|
69
|
-
};
|
|
70
|
-
/** Protocol fee in basis points */
|
|
71
|
-
protocolFeeBps?: number;
|
|
72
|
-
/** Require on-chain receipt */
|
|
73
|
-
receiptRequired?: boolean;
|
|
74
|
-
stream?: {
|
|
75
|
-
ratePerSecond: string;
|
|
76
|
-
budgetCap: string;
|
|
77
|
-
minDeposit: string;
|
|
78
|
-
};
|
|
79
|
-
escrow?: {
|
|
80
|
-
seller: string;
|
|
81
|
-
arbiter?: string;
|
|
82
|
-
deadlineMs: string;
|
|
83
|
-
};
|
|
84
|
-
unlock?: {
|
|
85
|
-
encryptionId: string;
|
|
86
|
-
walrusBlobId: string;
|
|
87
|
-
encryptionPackageId: string;
|
|
88
|
-
};
|
|
89
|
-
prepaid?: {
|
|
90
|
-
ratePerCall: string;
|
|
91
|
-
maxCalls?: string;
|
|
92
|
-
minDeposit: string;
|
|
93
|
-
withdrawalDelayMs: string; /** Provider Ed25519 pubkey (hex). Enables v0.2 signed receipt mode. @since v0.2 */
|
|
94
|
-
providerPubkey?: string; /** Dispute window in ms. Required when providerPubkey is set. @since v0.2 */
|
|
95
|
-
disputeWindowMs?: string;
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
//#endregion
|
|
99
7
|
//#region src/client.d.ts
|
|
100
8
|
declare class s402Client {
|
|
101
9
|
private schemes;
|
|
@@ -277,4 +185,4 @@ declare class s402ResourceServer {
|
|
|
277
185
|
process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
278
186
|
}
|
|
279
187
|
//#endregion
|
|
280
|
-
export { S402_CONTENT_TYPE, S402_HEADERS, S402_RECEIPT_HEADER, S402_VERSION, createS402Error, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, formatReceiptHeader, isValidAmount, isValidU64Amount, parseReceiptHeader, s402Client, type s402ClientScheme, type s402DirectScheme, type s402Discovery, s402Error, s402ErrorCode, type s402ErrorCodeType, type s402ErrorInfo, type s402EscrowExtra, type s402EscrowPayload, type s402ExactPayload, s402Facilitator, type s402FacilitatorScheme, type s402Mandate, type s402MandateRequirements, type s402PaymentPayload, type s402PaymentPayloadBase, type s402PaymentRequirements, type s402PaymentSession, type s402PrepaidExtra, type s402PrepaidPayload, type s402Receipt, type s402ReceiptSigner, type s402ReceiptVerifier, type s402RegistryQuery, s402ResourceServer, type s402RouteConfig, type s402Scheme, type s402ServerScheme, type s402ServiceEntry, type s402SettleResponse, type s402SettlementMode, type s402StreamExtra, type s402StreamPayload, type s402UnlockExtra, type s402UnlockPayload, type s402VerifyResponse, validateRequirementsShape };
|
|
188
|
+
export { S402_CONTENT_TYPE, S402_HEADERS, S402_RECEIPT_HEADER, S402_VERSION, createS402Error, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, formatReceiptHeader, isValidAmount, isValidU64Amount, parseReceiptHeader, s402Client, type s402ClientScheme, type s402DirectScheme, type s402Discovery, s402Error, s402ErrorCode, type s402ErrorCodeType, type s402ErrorInfo, type s402EscrowExtra, type s402EscrowPayload, type s402ExactPayload, s402Facilitator, type s402FacilitatorScheme, type s402Mandate, type s402MandateRequirements, type s402PaymentPayload, type s402PaymentPayloadBase, type s402PaymentRequirements, type s402PaymentSession, type s402PrepaidExtra, type s402PrepaidPayload, type s402Receipt, type s402ReceiptSigner, type s402ReceiptVerifier, type s402RegistryQuery, s402ResourceServer, type s402RouteConfig, type s402Scheme, type s402ServerScheme, type s402ServiceEntry, type s402SettleResponse, type s402SettlementMode, type s402SettlementVerification, type s402StreamExtra, type s402StreamPayload, type s402UnlockExtra, type s402UnlockPayload, type s402VerifyResponse, validateRequirementsShape };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { s402PaymentPayload, s402PaymentRequirements, s402Scheme, s402SettleResponse, s402SettlementMode, s402VerifyResponse } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/scheme.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Result of client-side settlement verification.
|
|
6
|
+
*
|
|
7
|
+
* See `s402ClientScheme.verifySettlement` — this is the outcome of the
|
|
8
|
+
* client independently checking that the facilitator's returned digest
|
|
9
|
+
* is causally bound to the signed payload the client actually sent.
|
|
10
|
+
*/
|
|
11
|
+
interface s402SettlementVerification {
|
|
12
|
+
/** True iff the facilitator's digest matches the one derived from the signed payload bytes. */
|
|
13
|
+
verified: boolean;
|
|
14
|
+
/** The digest the client computed locally from its own signed bytes. */
|
|
15
|
+
expectedDigest: string;
|
|
16
|
+
/** The digest the facilitator returned (copied from SettleResponse). */
|
|
17
|
+
actualDigest: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Human-readable reason when `verified` is false. Present only on mismatch,
|
|
20
|
+
* unknown-digest, or when the scheme cannot verify the settlement locally.
|
|
21
|
+
*/
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Implemented by each scheme on the client side */
|
|
25
|
+
interface s402ClientScheme {
|
|
26
|
+
/** Which scheme this implements */
|
|
27
|
+
readonly scheme: s402Scheme;
|
|
28
|
+
/** Create a signed payment payload from server requirements */
|
|
29
|
+
createPayment(requirements: s402PaymentRequirements): Promise<s402PaymentPayload>;
|
|
30
|
+
/**
|
|
31
|
+
* Verify that the facilitator's `SettleResponse` is causally bound to the
|
|
32
|
+
* signed payload the client actually sent.
|
|
33
|
+
*
|
|
34
|
+
* For schemes where the client signs the full transaction before sending
|
|
35
|
+
* (exact, stream, escrow, unlock-TX1), this is a **local, offline check**:
|
|
36
|
+
* derive the expected tx digest from the signed bytes and compare it to the
|
|
37
|
+
* digest the facilitator returned. No RPC call required. This closes the
|
|
38
|
+
* causal-binding hole identified in the April 2026 scale-fragility review
|
|
39
|
+
* (see `knowledge/scale-fragility-council-v03.md`): a malicious facilitator
|
|
40
|
+
* cannot substitute an unrelated-but-real tx digest, because that other
|
|
41
|
+
* digest would correspond to different signed bytes the client never
|
|
42
|
+
* produced.
|
|
43
|
+
*
|
|
44
|
+
* Optional for backward-compatibility. Schemes that cannot verify locally
|
|
45
|
+
* (e.g. unlock-TX2, which is facilitator-constructed) should return
|
|
46
|
+
* `{ verified: false, reason: 'scheme does not support local verification' }`
|
|
47
|
+
* and rely on other attestation mechanisms.
|
|
48
|
+
*/
|
|
49
|
+
verifySettlement?(payload: s402PaymentPayload, settleResponse: s402SettleResponse): s402SettlementVerification;
|
|
50
|
+
}
|
|
51
|
+
/** Implemented by each scheme on the server side */
|
|
52
|
+
interface s402ServerScheme {
|
|
53
|
+
readonly scheme: s402Scheme;
|
|
54
|
+
/** Build payment requirements from route config */
|
|
55
|
+
buildRequirements(config: s402RouteConfig): s402PaymentRequirements;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Implemented by each scheme in the facilitator.
|
|
59
|
+
*
|
|
60
|
+
* Critical: each scheme has its OWN verify logic.
|
|
61
|
+
* - Exact: signature recovery + dry-run simulation + balance check
|
|
62
|
+
* - Stream: stream creation PTB validation + deposit check
|
|
63
|
+
* - Escrow: escrow creation PTB validation + arbiter/deadline check
|
|
64
|
+
* - Unlock: escrow validation (key release is separate PTB)
|
|
65
|
+
*/
|
|
66
|
+
interface s402FacilitatorScheme {
|
|
67
|
+
readonly scheme: s402Scheme;
|
|
68
|
+
/** Verify a payment payload without broadcasting */
|
|
69
|
+
verify(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402VerifyResponse>;
|
|
70
|
+
/** Verify and broadcast the transaction */
|
|
71
|
+
settle(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* For self-sovereign agents that hold their own keys.
|
|
75
|
+
* Builds, signs, and broadcasts in one step — no facilitator needed.
|
|
76
|
+
*
|
|
77
|
+
* MUST call waitForTransaction() before returning success.
|
|
78
|
+
* Without finality confirmation, server could grant access for a
|
|
79
|
+
* transaction that gets reverted.
|
|
80
|
+
*/
|
|
81
|
+
interface s402DirectScheme {
|
|
82
|
+
readonly scheme: s402Scheme;
|
|
83
|
+
/** Build, sign, broadcast, and wait for finality */
|
|
84
|
+
settleDirectly(requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
|
|
85
|
+
}
|
|
86
|
+
/** Per-route payment configuration for the server middleware */
|
|
87
|
+
interface s402RouteConfig {
|
|
88
|
+
/** Which payment scheme(s) to accept. Always includes "exact" for x402 compat. */
|
|
89
|
+
schemes: s402Scheme[];
|
|
90
|
+
/** Amount in base units, same as wire format (e.g., "1000000") */
|
|
91
|
+
price: string;
|
|
92
|
+
/** Network identifier (e.g., "sui:mainnet", "solana:mainnet-beta") */
|
|
93
|
+
network: string;
|
|
94
|
+
/** Recipient address (chain-specific format, validated by chain adapter) */
|
|
95
|
+
payTo: string;
|
|
96
|
+
/** Asset/coin type identifier (chain-specific, e.g., Sui Move type or Solana mint address) */
|
|
97
|
+
asset: string;
|
|
98
|
+
/** Facilitator URL (optional for direct settlement) */
|
|
99
|
+
facilitatorUrl?: string;
|
|
100
|
+
/** Settlement mode preference */
|
|
101
|
+
settlementMode?: s402SettlementMode;
|
|
102
|
+
/** AP2 mandate requirements */
|
|
103
|
+
mandate?: {
|
|
104
|
+
required: boolean;
|
|
105
|
+
minPerTx?: string;
|
|
106
|
+
};
|
|
107
|
+
/** Protocol fee in basis points */
|
|
108
|
+
protocolFeeBps?: number;
|
|
109
|
+
/** Require on-chain receipt */
|
|
110
|
+
receiptRequired?: boolean;
|
|
111
|
+
stream?: {
|
|
112
|
+
ratePerSecond: string;
|
|
113
|
+
budgetCap: string;
|
|
114
|
+
minDeposit: string;
|
|
115
|
+
};
|
|
116
|
+
escrow?: {
|
|
117
|
+
seller: string;
|
|
118
|
+
arbiter?: string;
|
|
119
|
+
deadlineMs: string;
|
|
120
|
+
};
|
|
121
|
+
unlock?: {
|
|
122
|
+
encryptionId: string;
|
|
123
|
+
encryptedContentId: string;
|
|
124
|
+
encryptionServiceId: string;
|
|
125
|
+
};
|
|
126
|
+
prepaid?: {
|
|
127
|
+
ratePerCall: string;
|
|
128
|
+
maxCalls?: string;
|
|
129
|
+
minDeposit: string;
|
|
130
|
+
withdrawalDelayMs: string; /** Provider Ed25519 pubkey (hex). Enables v0.2 signed receipt mode. @since v0.2 */
|
|
131
|
+
providerPubkey?: string; /** Dispute window in ms. Required when providerPubkey is set. @since v0.2 */
|
|
132
|
+
disputeWindowMs?: string;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
export { s402ServerScheme as a, s402RouteConfig as i, s402DirectScheme as n, s402SettlementVerification as o, s402FacilitatorScheme as r, s402ClientScheme as t };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { s402PaymentPayload, s402PaymentRequirements, s402SettleResponse, s402VerifyResponse } from "./types.mjs";
|
|
2
|
+
import { a as s402ServerScheme, r as s402FacilitatorScheme, t as s402ClientScheme } from "./scheme-D7qqwo3Q.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/test-utils.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Create a mock client scheme for the `exact` payment type.
|
|
7
|
+
*
|
|
8
|
+
* Produces payloads with deterministic transaction/signature strings
|
|
9
|
+
* derived from the requirements (amount + payTo). Useful for testing
|
|
10
|
+
* the client→server→facilitator flow without real keys.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const client = new s402Client();
|
|
15
|
+
* client.register('sui:testnet', mockExactClientScheme());
|
|
16
|
+
*
|
|
17
|
+
* const payload = await client.createPayment(requirements);
|
|
18
|
+
* // payload.payload.transaction === 'mock-pay-1000000-to-0xabc...'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
declare function mockExactClientScheme(): s402ClientScheme;
|
|
22
|
+
/**
|
|
23
|
+
* Create a mock facilitator scheme for the `exact` payment type.
|
|
24
|
+
*
|
|
25
|
+
* Verifies that the payload's transaction string matches the expected
|
|
26
|
+
* format from `mockExactClientScheme()`. Settle always succeeds with
|
|
27
|
+
* a deterministic digest.
|
|
28
|
+
*
|
|
29
|
+
* Pair this with `mockExactClientScheme()` for end-to-end testing.
|
|
30
|
+
*
|
|
31
|
+
* @param options.txDigest - Custom transaction digest (default: 'mock-tx-digest')
|
|
32
|
+
* @param options.finalityMs - Custom finality time (default: 400)
|
|
33
|
+
* @param options.verifyFn - Override the verify function for custom behavior
|
|
34
|
+
* @param options.settleFn - Override the settle function for custom behavior
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const facilitator = new s402Facilitator();
|
|
39
|
+
* facilitator.register('sui:testnet', mockExactFacilitatorScheme());
|
|
40
|
+
*
|
|
41
|
+
* const result = await facilitator.process(payload, requirements);
|
|
42
|
+
* // result.success === true
|
|
43
|
+
* // result.txDigest === 'mock-tx-digest'
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
declare function mockExactFacilitatorScheme(options?: {
|
|
47
|
+
txDigest?: string;
|
|
48
|
+
finalityMs?: number;
|
|
49
|
+
verifyFn?: (payload: s402PaymentPayload, requirements: s402PaymentRequirements) => Promise<s402VerifyResponse>;
|
|
50
|
+
settleFn?: (payload: s402PaymentPayload, requirements: s402PaymentRequirements) => Promise<s402SettleResponse>;
|
|
51
|
+
}): s402FacilitatorScheme;
|
|
52
|
+
/**
|
|
53
|
+
* Create a mock server scheme for the `exact` payment type.
|
|
54
|
+
*
|
|
55
|
+
* Builds payment requirements from a route config. Useful for testing
|
|
56
|
+
* server middleware without chain-specific adapters.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const server = new s402ResourceServer();
|
|
61
|
+
* server.register('sui:testnet', mockExactServerScheme());
|
|
62
|
+
*
|
|
63
|
+
* const requirements = server.buildRequirements({
|
|
64
|
+
* schemes: ['exact'],
|
|
65
|
+
* price: '1000000',
|
|
66
|
+
* network: 'sui:testnet',
|
|
67
|
+
* payTo: '0xabc...',
|
|
68
|
+
* asset: 'SUI',
|
|
69
|
+
* });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
declare function mockExactServerScheme(): s402ServerScheme;
|
|
73
|
+
//#endregion
|
|
74
|
+
export { mockExactClientScheme, mockExactFacilitatorScheme, mockExactServerScheme };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { S402_VERSION } from "./types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/test-utils.ts
|
|
4
|
+
/**
|
|
5
|
+
* Create a mock client scheme for the `exact` payment type.
|
|
6
|
+
*
|
|
7
|
+
* Produces payloads with deterministic transaction/signature strings
|
|
8
|
+
* derived from the requirements (amount + payTo). Useful for testing
|
|
9
|
+
* the client→server→facilitator flow without real keys.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const client = new s402Client();
|
|
14
|
+
* client.register('sui:testnet', mockExactClientScheme());
|
|
15
|
+
*
|
|
16
|
+
* const payload = await client.createPayment(requirements);
|
|
17
|
+
* // payload.payload.transaction === 'mock-pay-1000000-to-0xabc...'
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
function mockExactClientScheme() {
|
|
21
|
+
return {
|
|
22
|
+
scheme: "exact",
|
|
23
|
+
async createPayment(requirements) {
|
|
24
|
+
return {
|
|
25
|
+
s402Version: S402_VERSION,
|
|
26
|
+
scheme: "exact",
|
|
27
|
+
payload: {
|
|
28
|
+
transaction: `mock-pay-${requirements.amount}-to-${requirements.payTo}`,
|
|
29
|
+
signature: "mock-signature"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a mock facilitator scheme for the `exact` payment type.
|
|
37
|
+
*
|
|
38
|
+
* Verifies that the payload's transaction string matches the expected
|
|
39
|
+
* format from `mockExactClientScheme()`. Settle always succeeds with
|
|
40
|
+
* a deterministic digest.
|
|
41
|
+
*
|
|
42
|
+
* Pair this with `mockExactClientScheme()` for end-to-end testing.
|
|
43
|
+
*
|
|
44
|
+
* @param options.txDigest - Custom transaction digest (default: 'mock-tx-digest')
|
|
45
|
+
* @param options.finalityMs - Custom finality time (default: 400)
|
|
46
|
+
* @param options.verifyFn - Override the verify function for custom behavior
|
|
47
|
+
* @param options.settleFn - Override the settle function for custom behavior
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* const facilitator = new s402Facilitator();
|
|
52
|
+
* facilitator.register('sui:testnet', mockExactFacilitatorScheme());
|
|
53
|
+
*
|
|
54
|
+
* const result = await facilitator.process(payload, requirements);
|
|
55
|
+
* // result.success === true
|
|
56
|
+
* // result.txDigest === 'mock-tx-digest'
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
function mockExactFacilitatorScheme(options) {
|
|
60
|
+
const txDigest = options?.txDigest ?? "mock-tx-digest";
|
|
61
|
+
const finalityMs = options?.finalityMs ?? 400;
|
|
62
|
+
return {
|
|
63
|
+
scheme: "exact",
|
|
64
|
+
async verify(payload, requirements) {
|
|
65
|
+
if (options?.verifyFn) return options.verifyFn(payload, requirements);
|
|
66
|
+
if (payload.scheme !== "exact") return {
|
|
67
|
+
valid: false,
|
|
68
|
+
invalidReason: `Expected exact scheme, got ${payload.scheme}`
|
|
69
|
+
};
|
|
70
|
+
const exact = payload;
|
|
71
|
+
const expectedTx = `mock-pay-${requirements.amount}-to-${requirements.payTo}`;
|
|
72
|
+
if (exact.payload.transaction !== expectedTx) return {
|
|
73
|
+
valid: false,
|
|
74
|
+
invalidReason: "Transaction does not match expected mock format"
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
valid: true,
|
|
78
|
+
payerAddress: "0xmock-payer"
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
async settle(payload, requirements) {
|
|
82
|
+
if (options?.settleFn) return options.settleFn(payload, requirements);
|
|
83
|
+
return {
|
|
84
|
+
success: true,
|
|
85
|
+
txDigest,
|
|
86
|
+
finalityMs
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create a mock server scheme for the `exact` payment type.
|
|
93
|
+
*
|
|
94
|
+
* Builds payment requirements from a route config. Useful for testing
|
|
95
|
+
* server middleware without chain-specific adapters.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const server = new s402ResourceServer();
|
|
100
|
+
* server.register('sui:testnet', mockExactServerScheme());
|
|
101
|
+
*
|
|
102
|
+
* const requirements = server.buildRequirements({
|
|
103
|
+
* schemes: ['exact'],
|
|
104
|
+
* price: '1000000',
|
|
105
|
+
* network: 'sui:testnet',
|
|
106
|
+
* payTo: '0xabc...',
|
|
107
|
+
* asset: 'SUI',
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
function mockExactServerScheme() {
|
|
112
|
+
return {
|
|
113
|
+
scheme: "exact",
|
|
114
|
+
buildRequirements(config) {
|
|
115
|
+
return {
|
|
116
|
+
s402Version: S402_VERSION,
|
|
117
|
+
accepts: [...new Set([...config.schemes, "exact"])],
|
|
118
|
+
network: config.network,
|
|
119
|
+
asset: config.asset,
|
|
120
|
+
amount: config.price,
|
|
121
|
+
payTo: config.payTo,
|
|
122
|
+
facilitatorUrl: config.facilitatorUrl,
|
|
123
|
+
protocolFeeBps: config.protocolFeeBps,
|
|
124
|
+
receiptRequired: config.receiptRequired,
|
|
125
|
+
settlementMode: config.settlementMode
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
//#endregion
|
|
132
|
+
export { mockExactClientScheme, mockExactFacilitatorScheme, mockExactServerScheme };
|
package/dist/types.d.mts
CHANGED
|
@@ -24,7 +24,15 @@ interface s402PaymentRequirements {
|
|
|
24
24
|
amount: string;
|
|
25
25
|
/** Recipient address */
|
|
26
26
|
payTo: string;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Facilitator URL (optional for direct settlement).
|
|
29
|
+
*
|
|
30
|
+
* Validated at the wire layer for protocol (`https:` or `http:` only) and
|
|
31
|
+
* embedded credentials (rejected). Consumers that fetch this URL MUST apply
|
|
32
|
+
* their own hostname/IP restrictions to prevent SSRF (block RFC 1918 private
|
|
33
|
+
* addresses, link-local 169.254.x.x, loopback, cloud metadata endpoints).
|
|
34
|
+
* DNS-based SSRF cannot be caught at URL parse time.
|
|
35
|
+
*/
|
|
28
36
|
facilitatorUrl?: string;
|
|
29
37
|
/** AP2 mandate requirements (if agent spending authorization is needed) */
|
|
30
38
|
mandate?: s402MandateRequirements;
|
|
@@ -99,10 +107,10 @@ interface s402EscrowExtra {
|
|
|
99
107
|
interface s402UnlockExtra {
|
|
100
108
|
/** Encryption ID for key servers */
|
|
101
109
|
encryptionId: string;
|
|
102
|
-
/** Walrus blob ID
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
|
|
110
|
+
/** Content identifier for the encrypted blob (e.g., Walrus blob ID, IPFS CID) */
|
|
111
|
+
encryptedContentId: string;
|
|
112
|
+
/** Identifier for the encryption service or module (e.g., Sui package ID, EVM contract address) */
|
|
113
|
+
encryptionServiceId: string;
|
|
106
114
|
}
|
|
107
115
|
/**
|
|
108
116
|
* Prepaid-specific requirements.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s402",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -88,24 +88,30 @@
|
|
|
88
88
|
"default": "./dist/receipts.mjs"
|
|
89
89
|
},
|
|
90
90
|
"default": "./dist/receipts.mjs"
|
|
91
|
+
},
|
|
92
|
+
"./test-utils": {
|
|
93
|
+
"import": {
|
|
94
|
+
"types": "./dist/test-utils.d.mts",
|
|
95
|
+
"default": "./dist/test-utils.mjs"
|
|
96
|
+
},
|
|
97
|
+
"default": "./dist/test-utils.mjs"
|
|
91
98
|
}
|
|
92
99
|
},
|
|
100
|
+
"devDependencies": {
|
|
101
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
102
|
+
"fast-check": "^4.5.3",
|
|
103
|
+
"tsdown": "^0.20.3",
|
|
104
|
+
"typescript": "^5.7.0",
|
|
105
|
+
"vitepress": "^1.6.4",
|
|
106
|
+
"vitest": "^3.0.5"
|
|
107
|
+
},
|
|
93
108
|
"scripts": {
|
|
94
109
|
"build": "tsdown",
|
|
95
110
|
"typecheck": "tsc --noEmit",
|
|
96
111
|
"test": "vitest run",
|
|
97
112
|
"test:watch": "vitest",
|
|
98
|
-
"prepublishOnly": "npm run build && npm run typecheck && npm run test",
|
|
99
113
|
"docs:dev": "vitepress dev docs",
|
|
100
114
|
"docs:build": "vitepress build docs",
|
|
101
115
|
"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"
|
|
110
116
|
}
|
|
111
|
-
}
|
|
117
|
+
}
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
{
|
|
63
63
|
"description": "Decode unlock scheme",
|
|
64
64
|
"input": {
|
|
65
|
-
"header": "
|
|
65
|
+
"header": "eyJzNDAyVmVyc2lvbiI6IjEiLCJhY2NlcHRzIjpbInVubG9jayJdLCJuZXR3b3JrIjoic3VpOm1haW5uZXQiLCJhc3NldCI6IjB4Mjo6c3VpOjpTVUkiLCJhbW91bnQiOiIxMDAwMDAwIiwicGF5VG8iOiIweGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTAiLCJ1bmxvY2siOnsiZW5jcnlwdGlvbklkIjoiZW5jLWFiYy0xMjMiLCJlbmNyeXB0ZWRDb250ZW50SWQiOiJibG9iLXh5ei03ODkiLCJlbmNyeXB0aW9uU2VydmljZUlkIjoiMHhwa2cxMjM0NTY3ODkwIn19"
|
|
66
66
|
},
|
|
67
67
|
"expected": {
|
|
68
68
|
"s402Version": "1",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"payTo": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
76
76
|
"unlock": {
|
|
77
77
|
"encryptionId": "enc-abc-123",
|
|
78
|
-
"
|
|
79
|
-
"
|
|
78
|
+
"encryptedContentId": "blob-xyz-789",
|
|
79
|
+
"encryptionServiceId": "0xpkg1234567890"
|
|
80
80
|
}
|
|
81
81
|
},
|
|
82
82
|
"shouldReject": false
|
|
@@ -72,12 +72,12 @@
|
|
|
72
72
|
"payTo": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
|
73
73
|
"unlock": {
|
|
74
74
|
"encryptionId": "enc-abc-123",
|
|
75
|
-
"
|
|
76
|
-
"
|
|
75
|
+
"encryptedContentId": "blob-xyz-789",
|
|
76
|
+
"encryptionServiceId": "0xpkg1234567890"
|
|
77
77
|
}
|
|
78
78
|
},
|
|
79
79
|
"expected": {
|
|
80
|
-
"header": "
|
|
80
|
+
"header": "eyJzNDAyVmVyc2lvbiI6IjEiLCJhY2NlcHRzIjpbInVubG9jayJdLCJuZXR3b3JrIjoic3VpOm1haW5uZXQiLCJhc3NldCI6IjB4Mjo6c3VpOjpTVUkiLCJhbW91bnQiOiIxMDAwMDAwIiwicGF5VG8iOiIweGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTAiLCJ1bmxvY2siOnsiZW5jcnlwdGlvbklkIjoiZW5jLWFiYy0xMjMiLCJlbmNyeXB0ZWRDb250ZW50SWQiOiJibG9iLXh5ei03ODkiLCJlbmNyeXB0aW9uU2VydmljZUlkIjoiMHhwa2cxMjM0NTY3ODkwIn19"
|
|
81
81
|
},
|
|
82
82
|
"shouldReject": false
|
|
83
83
|
},
|
|
@@ -175,14 +175,6 @@
|
|
|
175
175
|
"shouldReject": true,
|
|
176
176
|
"expectedErrorCode": "INVALID_PAYLOAD"
|
|
177
177
|
},
|
|
178
|
-
{
|
|
179
|
-
"description": "Rejects amount exceeding u64 max",
|
|
180
|
-
"input": {
|
|
181
|
-
"header": "eyJzNDAyVmVyc2lvbiI6IjEiLCJhY2NlcHRzIjpbImV4YWN0Il0sIm5ldHdvcmsiOiJzdWk6bWFpbm5ldCIsImFzc2V0IjoiU1VJIiwiYW1vdW50IjoiMTg0NDY3NDQwNzM3MDk1NTE2MTYiLCJwYXlUbyI6IjB4YWJjIn0="
|
|
182
|
-
},
|
|
183
|
-
"shouldReject": true,
|
|
184
|
-
"expectedErrorCode": "INVALID_PAYLOAD"
|
|
185
|
-
},
|
|
186
178
|
{
|
|
187
179
|
"description": "Rejects invalid base64 header",
|
|
188
180
|
"input": {
|
|
@@ -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
|
-
```
|