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 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
  [![CI](https://github.com/s402-protocol/core/actions/workflows/ci.yml/badge.svg)](https://github.com/s402-protocol/core/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/s402.svg)](https://www.npmjs.com/package/s402)
5
5
 
6
- **Sui-native HTTP 402 protocol.** Atomic settlement via Sui's Programmable Transaction Blocks (PTBs). Includes an optional compat layer (`s402/compat`) for normalizing x402 input.
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 the Sui-native implementation of HTTP 402 (Payment Required)an open protocol that lets AI agents pay for API calls in a single HTTP request with no per-call on-chain transaction. Unlike Coinbase's x402 on Base (EVM L2), s402 uses Sui's 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.
8
+ s402 is a chain-agnostic HTTP 402 wire formattypes, 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 15 s402 error codes (e.g. 'SETTLEMENT_FAILED')
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 Sui network"
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 15 s402 error codes (e.g. 'SETTLEMENT_FAILED')
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
- "walrusBlobId",
113
- "encryptionPackageId"
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, "walrusBlobId", "unlock");
374
- assertString(obj, "encryptionPackageId", "unlock");
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 (!isValidU64Amount(record.amount)) throw new s402Error("INVALID_PAYLOAD", `Invalid amount "${record.amount}": must be a non-negative integer string within u64 range`);
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
- /** Facilitator URL (optional for direct settlement) */
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 containing the encrypted content */
103
- walrusBlobId: string;
104
- /** Encryption package ID on Sui */
105
- encryptionPackageId: string;
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.2.2",
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": "eyJzNDAyVmVyc2lvbiI6IjEiLCJhY2NlcHRzIjpbInVubG9jayJdLCJuZXR3b3JrIjoic3VpOm1haW5uZXQiLCJhc3NldCI6IjB4Mjo6c3VpOjpTVUkiLCJhbW91bnQiOiIxMDAwMDAwIiwicGF5VG8iOiIweGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTAiLCJ1bmxvY2siOnsiZW5jcnlwdGlvbklkIjoiZW5jLWFiYy0xMjMiLCJ3YWxydXNCbG9iSWQiOiJibG9iLXh5ei03ODkiLCJlbmNyeXB0aW9uUGFja2FnZUlkIjoiMHhwa2cxMjM0NTY3ODkwIn19"
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
- "walrusBlobId": "blob-xyz-789",
79
- "encryptionPackageId": "0xpkg1234567890"
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
- "walrusBlobId": "blob-xyz-789",
76
- "encryptionPackageId": "0xpkg1234567890"
75
+ "encryptedContentId": "blob-xyz-789",
76
+ "encryptionServiceId": "0xpkg1234567890"
77
77
  }
78
78
  },
79
79
  "expected": {
80
- "header": "eyJzNDAyVmVyc2lvbiI6IjEiLCJhY2NlcHRzIjpbInVubG9jayJdLCJuZXR3b3JrIjoic3VpOm1haW5uZXQiLCJhc3NldCI6IjB4Mjo6c3VpOjpTVUkiLCJhbW91bnQiOiIxMDAwMDAwIiwicGF5VG8iOiIweGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTAiLCJ1bmxvY2siOnsiZW5jcnlwdGlvbklkIjoiZW5jLWFiYy0xMjMiLCJ3YWxydXNCbG9iSWQiOiJibG9iLXh5ei03ODkiLCJlbmNyeXB0aW9uUGFja2FnZUlkIjoiMHhwa2cxMjM0NTY3ODkwIn19"
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
- ```