s402 0.2.3 → 0.4.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,49 @@ 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.4.0] - 2026-04-11
9
+
10
+ ### Changed
11
+ - **BREAKING: `verifySettlement` is now required on `s402ClientScheme` (DAN-280).** The `?` was removed — every scheme implementation MUST provide `verifySettlement()`. Schemes that cannot verify locally (e.g. unlock-TX2) should return `{ verified: false, reason: '...' }`. All 5 SweeFi adapters already implement this method; only custom third-party implementations that relied on the optional marker will need updating.
12
+ - Updated JSDoc: `@since 0.4.0 — required (was optional in 0.3.0)`
13
+ - `mockExactClientScheme()` in `test-utils.ts` now includes `verifySettlement()` returning `{ verified: false }` with reason `'mock scheme'`
14
+
15
+ ### Added
16
+ - **S8 conformance test vectors (DAN-282).** `spec/vectors/settlement-verification.json` — 7 chain-agnostic test vectors covering the `verifySettlement` interface contract: matching digest, mismatched digest (malicious facilitator), settle failed, missing txDigest, invalid base64, stream scheme, and non-verifiable scheme. Each vector includes `expectedShape`, `invariants`, and implementation `notes`.
17
+
18
+ ### Compatibility
19
+ - **BREAKING for 0.3.x consumers**: implementations that omitted `verifySettlement` will now fail type-checking. Add a stub returning `{ verified: false, expectedDigest: '', actualDigest: null, reason: 'not implemented' }` to restore compilation.
20
+ - Wire format: unchanged from v0.3.0.
21
+
22
+ ## [0.3.0] - 2026-04-11
23
+
24
+ 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.
25
+
26
+ ### Added
27
+
28
+ - **`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.
29
+ - **`s402SettlementVerification` type** — return shape for `verifySettlement`: `{ verified, expectedDigest, actualDigest, reason? }`.
30
+ - **`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`.
31
+ - **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`.
32
+ - **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`.
33
+ - **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`.
34
+
35
+ ### Removed
36
+
37
+ - **`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.
38
+
39
+ ### Changed
40
+
41
+ - **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/`."
42
+ - **`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.
43
+ - **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.
44
+
45
+ ### Compatibility
46
+
47
+ - **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`.
48
+ - **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.
49
+ - **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.
50
+
8
51
  ## [0.2.1] - 2026-03-02
9
52
 
10
53
  ### Added
@@ -129,6 +172,8 @@ _Version bump for npm publish after license change._
129
172
  - Property-based fuzz testing via fast-check
130
173
  - 207 tests, zero runtime dependencies
131
174
 
175
+ [0.4.0]: https://github.com/s402-protocol/core/compare/v0.3.0...v0.4.0
176
+ [0.3.0]: https://github.com/s402-protocol/core/compare/v0.2.3...v0.3.0
132
177
  [0.2.1]: https://github.com/s402-protocol/core/compare/v0.2.0...v0.2.1
133
178
  [0.2.0]: https://github.com/s402-protocol/core/compare/v0.1.8...v0.2.0
134
179
  [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",
@@ -373,8 +373,8 @@ function validateUnlockShape(value) {
373
373
  assertPlainObject(value, "unlock");
374
374
  const obj = value;
375
375
  assertString(obj, "encryptionId", "unlock");
376
- assertString(obj, "walrusBlobId", "unlock");
377
- assertString(obj, "encryptionPackageId", "unlock");
376
+ assertString(obj, "encryptedContentId", "unlock");
377
+ assertString(obj, "encryptionServiceId", "unlock");
378
378
  }
379
379
  /**
380
380
  * Validate prepaid sub-object.
@@ -420,7 +420,7 @@ function validateRequirementsShape(obj) {
420
420
  if (typeof record.network !== "string") missing.push("network (string)");
421
421
  if (typeof record.asset !== "string") missing.push("asset (string)");
422
422
  if (typeof record.amount !== "string") missing.push("amount (string)");
423
- 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`);
424
424
  if (typeof record.payTo !== "string") missing.push("payTo (string)");
425
425
  else if (record.payTo.length === 0) throw new s402Error("INVALID_PAYLOAD", "payTo must be a non-empty string");
426
426
  if (missing.length > 0) throw new s402Error("INVALID_PAYLOAD", `Malformed payment requirements: missing ${missing.join(", ")}`);
@@ -446,6 +446,7 @@ function validateRequirementsShape(obj) {
446
446
  try {
447
447
  const url = new URL(record.facilitatorUrl);
448
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@)");
449
450
  } catch (e) {
450
451
  if (e instanceof s402Error) throw e;
451
452
  throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
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-tVj4sOr-.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,137 @@
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, prepaid, unlock-TX1), this is a **local, offline
36
+ * check**: derive the expected tx digest from the signed bytes and compare it
37
+ * to the digest the facilitator returned. No RPC call required. This closes
38
+ * the causal-binding hole identified in the April 2026 S8 review: a malicious
39
+ * facilitator cannot substitute an unrelated-but-real tx digest, because that
40
+ * other digest would correspond to different signed bytes the client never
41
+ * produced.
42
+ *
43
+ * Every scheme MUST implement this method. Schemes that cannot verify locally
44
+ * (e.g. unlock-TX2, which is facilitator-constructed) should return
45
+ * `{ verified: false, reason: 'scheme does not support local verification' }`
46
+ * and rely on other attestation mechanisms.
47
+ *
48
+ * @since 0.4.0 — required (was optional in 0.3.0)
49
+ */
50
+ verifySettlement(payload: s402PaymentPayload, settleResponse: s402SettleResponse): s402SettlementVerification;
51
+ }
52
+ /** Implemented by each scheme on the server side */
53
+ interface s402ServerScheme {
54
+ readonly scheme: s402Scheme;
55
+ /** Build payment requirements from route config */
56
+ buildRequirements(config: s402RouteConfig): s402PaymentRequirements;
57
+ }
58
+ /**
59
+ * Implemented by each scheme in the facilitator.
60
+ *
61
+ * Critical: each scheme has its OWN verify logic.
62
+ * - Exact: signature recovery + dry-run simulation + balance check
63
+ * - Stream: stream creation PTB validation + deposit check
64
+ * - Escrow: escrow creation PTB validation + arbiter/deadline check
65
+ * - Unlock: escrow validation (key release is separate PTB)
66
+ */
67
+ interface s402FacilitatorScheme {
68
+ readonly scheme: s402Scheme;
69
+ /** Verify a payment payload without broadcasting */
70
+ verify(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402VerifyResponse>;
71
+ /** Verify and broadcast the transaction */
72
+ settle(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
73
+ }
74
+ /**
75
+ * For self-sovereign agents that hold their own keys.
76
+ * Builds, signs, and broadcasts in one step — no facilitator needed.
77
+ *
78
+ * MUST call waitForTransaction() before returning success.
79
+ * Without finality confirmation, server could grant access for a
80
+ * transaction that gets reverted.
81
+ */
82
+ interface s402DirectScheme {
83
+ readonly scheme: s402Scheme;
84
+ /** Build, sign, broadcast, and wait for finality */
85
+ settleDirectly(requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
86
+ }
87
+ /** Per-route payment configuration for the server middleware */
88
+ interface s402RouteConfig {
89
+ /** Which payment scheme(s) to accept. Always includes "exact" for x402 compat. */
90
+ schemes: s402Scheme[];
91
+ /** Amount in base units, same as wire format (e.g., "1000000") */
92
+ price: string;
93
+ /** Network identifier (e.g., "sui:mainnet", "solana:mainnet-beta") */
94
+ network: string;
95
+ /** Recipient address (chain-specific format, validated by chain adapter) */
96
+ payTo: string;
97
+ /** Asset/coin type identifier (chain-specific, e.g., Sui Move type or Solana mint address) */
98
+ asset: string;
99
+ /** Facilitator URL (optional for direct settlement) */
100
+ facilitatorUrl?: string;
101
+ /** Settlement mode preference */
102
+ settlementMode?: s402SettlementMode;
103
+ /** AP2 mandate requirements */
104
+ mandate?: {
105
+ required: boolean;
106
+ minPerTx?: string;
107
+ };
108
+ /** Protocol fee in basis points */
109
+ protocolFeeBps?: number;
110
+ /** Require on-chain receipt */
111
+ receiptRequired?: boolean;
112
+ stream?: {
113
+ ratePerSecond: string;
114
+ budgetCap: string;
115
+ minDeposit: string;
116
+ };
117
+ escrow?: {
118
+ seller: string;
119
+ arbiter?: string;
120
+ deadlineMs: string;
121
+ };
122
+ unlock?: {
123
+ encryptionId: string;
124
+ encryptedContentId: string;
125
+ encryptionServiceId: string;
126
+ };
127
+ prepaid?: {
128
+ ratePerCall: string;
129
+ maxCalls?: string;
130
+ minDeposit: string;
131
+ withdrawalDelayMs: string; /** Provider Ed25519 pubkey (hex). Enables v0.2 signed receipt mode. @since v0.2 */
132
+ providerPubkey?: string; /** Dispute window in ms. Required when providerPubkey is set. @since v0.2 */
133
+ disputeWindowMs?: string;
134
+ };
135
+ }
136
+ //#endregion
137
+ 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-tVj4sOr-.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,140 @@
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
+ verifySettlement() {
34
+ return {
35
+ verified: false,
36
+ expectedDigest: "",
37
+ actualDigest: null,
38
+ reason: "mock scheme"
39
+ };
40
+ }
41
+ };
42
+ }
43
+ /**
44
+ * Create a mock facilitator scheme for the `exact` payment type.
45
+ *
46
+ * Verifies that the payload's transaction string matches the expected
47
+ * format from `mockExactClientScheme()`. Settle always succeeds with
48
+ * a deterministic digest.
49
+ *
50
+ * Pair this with `mockExactClientScheme()` for end-to-end testing.
51
+ *
52
+ * @param options.txDigest - Custom transaction digest (default: 'mock-tx-digest')
53
+ * @param options.finalityMs - Custom finality time (default: 400)
54
+ * @param options.verifyFn - Override the verify function for custom behavior
55
+ * @param options.settleFn - Override the settle function for custom behavior
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const facilitator = new s402Facilitator();
60
+ * facilitator.register('sui:testnet', mockExactFacilitatorScheme());
61
+ *
62
+ * const result = await facilitator.process(payload, requirements);
63
+ * // result.success === true
64
+ * // result.txDigest === 'mock-tx-digest'
65
+ * ```
66
+ */
67
+ function mockExactFacilitatorScheme(options) {
68
+ const txDigest = options?.txDigest ?? "mock-tx-digest";
69
+ const finalityMs = options?.finalityMs ?? 400;
70
+ return {
71
+ scheme: "exact",
72
+ async verify(payload, requirements) {
73
+ if (options?.verifyFn) return options.verifyFn(payload, requirements);
74
+ if (payload.scheme !== "exact") return {
75
+ valid: false,
76
+ invalidReason: `Expected exact scheme, got ${payload.scheme}`
77
+ };
78
+ const exact = payload;
79
+ const expectedTx = `mock-pay-${requirements.amount}-to-${requirements.payTo}`;
80
+ if (exact.payload.transaction !== expectedTx) return {
81
+ valid: false,
82
+ invalidReason: "Transaction does not match expected mock format"
83
+ };
84
+ return {
85
+ valid: true,
86
+ payerAddress: "0xmock-payer"
87
+ };
88
+ },
89
+ async settle(payload, requirements) {
90
+ if (options?.settleFn) return options.settleFn(payload, requirements);
91
+ return {
92
+ success: true,
93
+ txDigest,
94
+ finalityMs
95
+ };
96
+ }
97
+ };
98
+ }
99
+ /**
100
+ * Create a mock server scheme for the `exact` payment type.
101
+ *
102
+ * Builds payment requirements from a route config. Useful for testing
103
+ * server middleware without chain-specific adapters.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const server = new s402ResourceServer();
108
+ * server.register('sui:testnet', mockExactServerScheme());
109
+ *
110
+ * const requirements = server.buildRequirements({
111
+ * schemes: ['exact'],
112
+ * price: '1000000',
113
+ * network: 'sui:testnet',
114
+ * payTo: '0xabc...',
115
+ * asset: 'SUI',
116
+ * });
117
+ * ```
118
+ */
119
+ function mockExactServerScheme() {
120
+ return {
121
+ scheme: "exact",
122
+ buildRequirements(config) {
123
+ return {
124
+ s402Version: S402_VERSION,
125
+ accepts: [...new Set([...config.schemes, "exact"])],
126
+ network: config.network,
127
+ asset: config.asset,
128
+ amount: config.price,
129
+ payTo: config.payTo,
130
+ facilitatorUrl: config.facilitatorUrl,
131
+ protocolFeeBps: config.protocolFeeBps,
132
+ receiptRequired: config.receiptRequired,
133
+ settlementMode: config.settlementMode
134
+ };
135
+ }
136
+ };
137
+ }
138
+
139
+ //#endregion
140
+ 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.3",
3
+ "version": "0.4.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,6 +88,13 @@
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
  },
93
100
  "devDependencies": {
@@ -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
  },
@@ -0,0 +1,180 @@
1
+ [
2
+ {
3
+ "description": "Matching digest — verified is true, digests equal",
4
+ "payload": {
5
+ "s402Version": "1",
6
+ "scheme": "exact",
7
+ "payload": {
8
+ "transaction": "dHgtYnl0ZXMtaGVyZQ==",
9
+ "signature": "c2lnLWJ5dGVzLWhlcmU="
10
+ }
11
+ },
12
+ "settleResponse": {
13
+ "success": true,
14
+ "txDigest": "KNOWN_DIGEST_ABC123"
15
+ },
16
+ "expectedShape": {
17
+ "verified": "boolean",
18
+ "expectedDigest": "string",
19
+ "actualDigest": "string|null"
20
+ },
21
+ "invariants": [
22
+ "IF verified === true THEN expectedDigest === actualDigest",
23
+ "actualDigest MUST equal settleResponse.txDigest"
24
+ ],
25
+ "notes": "The implementation derives expectedDigest from payload.payload.transaction using a chain-specific algorithm. If the facilitator returned the correct digest, verified MUST be true."
26
+ },
27
+ {
28
+ "description": "Mismatched digest — verified is false with DIGEST_MISMATCH reason",
29
+ "payload": {
30
+ "s402Version": "1",
31
+ "scheme": "exact",
32
+ "payload": {
33
+ "transaction": "dHgtYnl0ZXMtaGVyZQ==",
34
+ "signature": "c2lnLWJ5dGVzLWhlcmU="
35
+ }
36
+ },
37
+ "settleResponse": {
38
+ "success": true,
39
+ "txDigest": "WRONG_DIGEST_XYZ789"
40
+ },
41
+ "expectedShape": {
42
+ "verified": "boolean",
43
+ "expectedDigest": "string",
44
+ "actualDigest": "string|null"
45
+ },
46
+ "invariants": [
47
+ "verified MUST be false",
48
+ "expectedDigest MUST NOT equal actualDigest",
49
+ "actualDigest MUST equal settleResponse.txDigest",
50
+ "reason SHOULD contain 'DIGEST_MISMATCH' or indicate the mismatch"
51
+ ],
52
+ "notes": "A malicious facilitator returns a real but unrelated digest. The client detects the mismatch locally without any RPC call."
53
+ },
54
+ {
55
+ "description": "Settle failed — no txDigest available",
56
+ "payload": {
57
+ "s402Version": "1",
58
+ "scheme": "exact",
59
+ "payload": {
60
+ "transaction": "dHgtYnl0ZXMtaGVyZQ==",
61
+ "signature": "c2lnLWJ5dGVzLWhlcmU="
62
+ }
63
+ },
64
+ "settleResponse": {
65
+ "success": false,
66
+ "error": "Insufficient gas"
67
+ },
68
+ "expectedShape": {
69
+ "verified": "boolean",
70
+ "expectedDigest": "string",
71
+ "actualDigest": "string|null"
72
+ },
73
+ "invariants": [
74
+ "verified MUST be false",
75
+ "actualDigest MUST be null (no digest was returned)",
76
+ "reason SHOULD indicate no digest was available"
77
+ ],
78
+ "notes": "When settlement fails, there is no digest to verify. The implementation should handle this gracefully rather than throwing."
79
+ },
80
+ {
81
+ "description": "Settle succeeded but txDigest is undefined",
82
+ "payload": {
83
+ "s402Version": "1",
84
+ "scheme": "exact",
85
+ "payload": {
86
+ "transaction": "dHgtYnl0ZXMtaGVyZQ==",
87
+ "signature": "c2lnLWJ5dGVzLWhlcmU="
88
+ }
89
+ },
90
+ "settleResponse": {
91
+ "success": true
92
+ },
93
+ "expectedShape": {
94
+ "verified": "boolean",
95
+ "expectedDigest": "string",
96
+ "actualDigest": "string|null"
97
+ },
98
+ "invariants": [
99
+ "verified MUST be false",
100
+ "actualDigest MUST be null",
101
+ "reason SHOULD indicate missing digest"
102
+ ],
103
+ "notes": "Edge case: facilitator reports success but omits the digest. This should be treated as unverifiable — the client cannot confirm settlement."
104
+ },
105
+ {
106
+ "description": "Invalid base64 in payload transaction bytes",
107
+ "payload": {
108
+ "s402Version": "1",
109
+ "scheme": "exact",
110
+ "payload": {
111
+ "transaction": "!!!NOT-BASE64!!!",
112
+ "signature": "c2lnLWJ5dGVzLWhlcmU="
113
+ }
114
+ },
115
+ "settleResponse": {
116
+ "success": true,
117
+ "txDigest": "SOME_DIGEST"
118
+ },
119
+ "expectedShape": {
120
+ "verified": "boolean",
121
+ "expectedDigest": "string",
122
+ "actualDigest": "string|null"
123
+ },
124
+ "invariants": [
125
+ "verified MUST be false",
126
+ "MUST NOT throw — return a verification result with reason"
127
+ ],
128
+ "notes": "Malformed input must fail gracefully. Implementations MUST catch decode errors and return { verified: false } rather than propagating exceptions."
129
+ },
130
+ {
131
+ "description": "Stream scheme — same verification contract applies",
132
+ "payload": {
133
+ "s402Version": "1",
134
+ "scheme": "stream",
135
+ "payload": {
136
+ "transaction": "c3RyZWFtLXR4LWJ5dGVz",
137
+ "signature": "c3RyZWFtLXNpZw=="
138
+ }
139
+ },
140
+ "settleResponse": {
141
+ "success": true,
142
+ "txDigest": "STREAM_DIGEST_123"
143
+ },
144
+ "expectedShape": {
145
+ "verified": "boolean",
146
+ "expectedDigest": "string",
147
+ "actualDigest": "string|null"
148
+ },
149
+ "invariants": [
150
+ "IF verified === true THEN expectedDigest === actualDigest",
151
+ "actualDigest MUST equal settleResponse.txDigest"
152
+ ],
153
+ "notes": "The S8 invariant applies to ALL client-signed schemes, not just exact. Stream, escrow, prepaid, and unlock-TX1 all sign full transactions before sending to the facilitator."
154
+ },
155
+ {
156
+ "description": "Scheme that cannot verify locally (e.g. unlock-TX2)",
157
+ "payload": {
158
+ "s402Version": "1",
159
+ "scheme": "unlock",
160
+ "payload": {
161
+ "transaction": "",
162
+ "signature": ""
163
+ }
164
+ },
165
+ "settleResponse": {
166
+ "success": true,
167
+ "txDigest": "FACILITATOR_BUILT_TX"
168
+ },
169
+ "expectedShape": {
170
+ "verified": "boolean",
171
+ "expectedDigest": "string",
172
+ "actualDigest": "string|null"
173
+ },
174
+ "invariants": [
175
+ "verified MUST be false",
176
+ "reason MUST be present and explain why verification is not possible"
177
+ ],
178
+ "notes": "For schemes where the facilitator constructs the transaction (e.g. unlock phase 2), the client has no signed bytes to derive a digest from. The implementation MUST return { verified: false } with a reason, not throw."
179
+ }
180
+ ]
@@ -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": {