s402 0.1.8 → 0.2.1

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,58 @@ 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.2.1] - 2026-03-02
9
+
10
+ ### Added
11
+
12
+ - **Conformance test vectors ship in npm package** — 133 machine-readable JSON test vectors across 12 files now included via `test/conformance/vectors`. Cross-language implementors (Go, Python, Rust) can `npm pack s402` to get the vectors without cloning the repo.
13
+ - **API stability declaration** — `API-STABILITY.md` classifies all 83 exports as stable, experimental, or internal.
14
+
15
+ ### Fixed
16
+
17
+ - Barrel export JSDoc updated to chain-agnostic wording (was "Sui-native").
18
+
19
+ ## [0.2.0] - 2026-03-01
20
+
21
+ ### Added
22
+
23
+ - **Receipt HTTP helpers** — `s402/receipts` sub-path export with `formatReceiptHeader()`, `parseReceiptHeader()`, `S402_RECEIPT_HEADER`. Chain-agnostic receipt wire format (`v2:base64(sig):callNumber:timestampMs:base64(hash)`) for v0.2 signed usage receipts.
24
+ - **S7 chain-agnostic boundary invariant** — formal safety invariant enforced by `test/boundary.test.ts`. Greps `src/` for chain-specific patterns (Sui address regex, Solana base58, Ethereum imports) and fails the build if any are found.
25
+ - **v0.2 prepaid type extensions** — `providerPubkey` and `disputeWindowMs` fields on `s402PrepaidExtra` for signed receipt mode.
26
+ - **Body transport** — `application/s402+json` content type for large payloads that don't fit in HTTP headers.
27
+ - **Formal safety invariants** (S1-S7) documented in AGENTS.md.
28
+
29
+ ### Fixed
30
+
31
+ - **Chain-agnostic payTo/protocolFeeAddress validation** — removed Sui-specific address regex (`/^0x[0-9a-fA-F]{64}$/`) from `http.ts`. Replaced with chain-agnostic checks (non-empty string, no control characters). Chain-specific validation belongs in `@sweefi/sui`.
32
+ - **x402 compat validation parity** — `normalizeRequirements()` now runs `validateRequirementsShape()` on x402 conversion output, ensuring identical validation regardless of input format.
33
+ - **Prepaid pairing invariant enforcement** — `providerPubkey` and `disputeWindowMs` must both be present (v0.2) or both absent (v0.1). Was documented in JSDoc but not enforced at wire decode.
34
+ - **Receipt BigInt coercion** — `parseReceiptHeader()` rejects empty strings and whitespace-only strings that JavaScript's `BigInt()` would silently coerce to `0n`.
35
+ - **Removed Sui default for `asset`** — `s402RouteConfig.asset` is now required (was optional with `'0x2::sui::SUI'` default). Chain-specific defaults don't belong in the protocol layer.
36
+
37
+ ### Changed
38
+
39
+ - **BREAKING**: `s402RouteConfig.asset` is now required (was optional).
40
+ - JSDoc on `s402PaymentRequirements` updated to chain-agnostic wording (network, asset, amount fields).
41
+ - **Conformance test suite** — 133 machine-readable JSON test vectors across 12 files for cross-language implementation verification. Covers encode/decode, body transport, compat normalization, receipt format/parse, validation rejection, key-stripping, and roundtrip identity. Vectors ship in the npm package.
42
+ - **API stability declaration** — `API-STABILITY.md` classifies all 83 exports as stable/experimental/internal.
43
+ - 405 tests across 12 suites (was 207 at v0.1.0).
44
+
45
+ ## [0.1.8] - 2026-02-27
46
+
47
+ ### Added
48
+
49
+ - Body transport (`application/s402+json`) for large payloads
50
+ - v0.2 prepaid type extensions (`providerPubkey`, `disputeWindowMs`)
51
+ - `FUNDING.yml` and cross-linked SweeFi in README
52
+
53
+ ## [0.1.7] - 2026-02-25
54
+
55
+ ### Added
56
+
57
+ - Formal safety invariants (Lamport-style proofs)
58
+ - `isValidU64Amount()` magnitude checks
59
+
8
60
  ## [0.1.6] - 2026-02-19
9
61
 
10
62
  ### Fixed
@@ -77,6 +129,10 @@ _Version bump for npm publish after license change._
77
129
  - Property-based fuzz testing via fast-check
78
130
  - 207 tests, zero runtime dependencies
79
131
 
132
+ [0.2.1]: https://github.com/s402-protocol/core/compare/v0.2.0...v0.2.1
133
+ [0.2.0]: https://github.com/s402-protocol/core/compare/v0.1.8...v0.2.0
134
+ [0.1.8]: https://github.com/s402-protocol/core/compare/v0.1.7...v0.1.8
135
+ [0.1.7]: https://github.com/s402-protocol/core/compare/v0.1.6...v0.1.7
80
136
  [0.1.6]: https://github.com/s402-protocol/core/compare/v0.1.5...v0.1.6
81
137
  [0.1.5]: https://github.com/s402-protocol/core/compare/v0.1.4...v0.1.5
82
138
  [0.1.4]: https://github.com/s402-protocol/core/compare/v0.1.3...v0.1.4
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
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.
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 Ethereum, 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 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.
9
9
 
10
10
  ```bash
11
11
  npm install s402
@@ -27,7 +27,7 @@ HTTP 402 ("Payment Required") has been reserved since 1999 — waiting for a pay
27
27
  | **Settlement** | Two-step: verify then settle (temporal gap) | Atomic: verify + settle in one PTB |
28
28
  | **Finality** | 12+ second blocks (EVM L1) | ~400ms (Sui) |
29
29
  | **Payment models** | Exact (one-shot) only | Five schemes: Exact, Prepaid, Escrow, Unlock, Stream |
30
- | **Micro-payments** | $7.00 gas per 1K calls (broken) | $0.014 gas per 1K calls (prepaid) |
30
+ | **Micro-payments** | ~$1.60 gas per 1K calls on Base (broken) | $0.014 gas per 1K calls (prepaid) |
31
31
  | **Coin handling** | approve + transferFrom | Native `coinWithBalance` + `splitCoins` |
32
32
  | **Agent auth** | None | AP2 mandate delegation |
33
33
  | **Direct mode** | No | Yes (no facilitator needed) |
@@ -325,6 +325,25 @@ const requirements: s402PaymentRequirements = {
325
325
 
326
326
  5. **Errors tell you what to do.** Every error code includes `retryable` (can the client try again?) and `suggestedAction` (what should it do?). Agents can self-recover.
327
327
 
328
+ ## Conformance Testing
329
+
330
+ s402 ships 133 machine-readable JSON test vectors for cross-language conformance. If you're implementing s402 in Go, Python, Rust, or any other language, use these vectors to verify your implementation matches the spec.
331
+
332
+ ```bash
333
+ # Vectors are in the npm package
334
+ ls node_modules/s402/test/conformance/vectors/
335
+
336
+ # Or clone the repo
337
+ ls test/conformance/vectors/
338
+ ```
339
+
340
+ See [`test/conformance/README.md`](./test/conformance/README.md) for the vector format, encoding scheme, and implementation guide.
341
+
342
+ ## Related
343
+
344
+ - **[SweeFi](https://github.com/sweeinc/sweefi)** — Open-source payment SDK built on s402. 10 packages including PTB builders, MCP tools, CLI, and UI components.
345
+ - **[Sui Gas Station](https://github.com/Danny-Devs/sui-gas-station)** — Sponsored transaction infrastructure for Sui.
346
+
328
347
  ## License
329
348
 
330
349
  Apache-2.0 — see [LICENSE](./LICENSE) for details.
package/dist/compat.mjs CHANGED
@@ -133,10 +133,16 @@ function normalizeRequirements(obj) {
133
133
  validateRequirementsShape(obj);
134
134
  return pickRequirementsFields(obj);
135
135
  }
136
- if (isX402Envelope(obj)) return fromX402Envelope(obj);
136
+ if (isX402Envelope(obj)) {
137
+ const record = fromX402Envelope(obj);
138
+ validateRequirementsShape(record);
139
+ return pickRequirementsFields(record);
140
+ }
137
141
  if (isX402(obj)) {
138
142
  validateX402Shape(obj);
139
- return fromX402Requirements(obj);
143
+ const record = fromX402Requirements(obj);
144
+ validateRequirementsShape(record);
145
+ return pickRequirementsFields(record);
140
146
  }
141
147
  throw new s402Error("INVALID_PAYLOAD", "Unrecognized payment requirements format: missing s402Version or x402Version");
142
148
  }
package/dist/http.mjs CHANGED
@@ -318,10 +318,16 @@ function validatePrepaidShape(value) {
318
318
  assertString(obj, "minDeposit", "prepaid");
319
319
  if (typeof obj.minDeposit === "string" && !isValidAmount(obj.minDeposit)) throw new s402Error("INVALID_PAYLOAD", `prepaid.minDeposit must be a non-negative integer string, got "${obj.minDeposit}"`);
320
320
  assertString(obj, "withdrawalDelayMs", "prepaid");
321
- if (typeof obj.withdrawalDelayMs === "string" && !isValidAmount(obj.withdrawalDelayMs)) throw new s402Error("INVALID_PAYLOAD", `prepaid.withdrawalDelayMs must be a non-negative integer string (milliseconds), got "${obj.withdrawalDelayMs}"`);
321
+ if (typeof obj.withdrawalDelayMs === "string") {
322
+ if (!isValidAmount(obj.withdrawalDelayMs)) throw new s402Error("INVALID_PAYLOAD", `prepaid.withdrawalDelayMs must be a non-negative integer string (milliseconds), got "${obj.withdrawalDelayMs}"`);
323
+ const delayMs = BigInt(obj.withdrawalDelayMs);
324
+ if (delayMs < 60000n || delayMs > 604800000n) throw new s402Error("INVALID_PAYLOAD", `prepaid.withdrawalDelayMs must be between 60000 (1 min) and 604800000 (7 days), got "${obj.withdrawalDelayMs}"`);
325
+ }
322
326
  assertOptionalString(obj, "maxCalls", "prepaid");
323
327
  assertOptionalString(obj, "providerPubkey", "prepaid");
324
328
  assertOptionalString(obj, "disputeWindowMs", "prepaid");
329
+ const hasPubkey = typeof obj.providerPubkey === "string";
330
+ if (hasPubkey !== (typeof obj.disputeWindowMs === "string")) throw new s402Error("INVALID_PAYLOAD", `prepaid: providerPubkey and disputeWindowMs must both be present (v0.2) or both absent (v0.1), got ${hasPubkey ? "providerPubkey only" : "disputeWindowMs only"}`);
325
331
  }
326
332
  /**
327
333
  * Validate all optional sub-objects on a requirements record.
@@ -347,10 +353,11 @@ function validateRequirementsShape(obj) {
347
353
  if (typeof record.amount !== "string") missing.push("amount (string)");
348
354
  else if (!isValidU64Amount(record.amount)) throw new s402Error("INVALID_PAYLOAD", `Invalid amount "${record.amount}": must be a non-negative integer string within u64 range`);
349
355
  if (typeof record.payTo !== "string") missing.push("payTo (string)");
350
- else if (!/^0x[0-9a-fA-F]{64}$/.test(record.payTo)) throw new s402Error("INVALID_PAYLOAD", `payTo must be a 32-byte Sui address (0x + 64 hex chars), got "${record.payTo.substring(0, 20)}..."`);
356
+ else if (record.payTo.length === 0) throw new s402Error("INVALID_PAYLOAD", "payTo must be a non-empty string");
351
357
  if (missing.length > 0) throw new s402Error("INVALID_PAYLOAD", `Malformed payment requirements: missing ${missing.join(", ")}`);
352
358
  if (/[\x00-\x1f\x7f]/.test(record.network)) throw new s402Error("INVALID_PAYLOAD", "network contains control characters");
353
359
  if (/[\x00-\x1f\x7f]/.test(record.asset)) throw new s402Error("INVALID_PAYLOAD", "asset contains control characters");
360
+ if (/[\x00-\x1f\x7f]/.test(record.payTo)) throw new s402Error("INVALID_PAYLOAD", "payTo contains control characters");
354
361
  if (Array.isArray(record.accepts) && record.accepts.length === 0) throw new s402Error("INVALID_PAYLOAD", "accepts array must contain at least one scheme");
355
362
  const accepts = record.accepts;
356
363
  for (const scheme of accepts) if (typeof scheme !== "string") throw new s402Error("INVALID_PAYLOAD", `Invalid entry in accepts array: expected string, got ${typeof scheme}`);
@@ -361,7 +368,8 @@ function validateRequirementsShape(obj) {
361
368
  if (typeof record.expiresAt !== "number" || !Number.isFinite(record.expiresAt) || record.expiresAt <= 0) throw new s402Error("INVALID_PAYLOAD", `expiresAt must be a positive finite number (Unix timestamp ms), got ${record.expiresAt}`);
362
369
  }
363
370
  if (record.protocolFeeAddress !== void 0) {
364
- if (typeof record.protocolFeeAddress !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(record.protocolFeeAddress)) throw new s402Error("INVALID_PAYLOAD", `protocolFeeAddress must be a 32-byte Sui address (0x + 64 hex chars), got "${String(record.protocolFeeAddress).substring(0, 20)}..."`);
371
+ if (typeof record.protocolFeeAddress !== "string" || record.protocolFeeAddress.length === 0) throw new s402Error("INVALID_PAYLOAD", `protocolFeeAddress must be a non-empty string, got ${JSON.stringify(record.protocolFeeAddress)}`);
372
+ if (/[\x00-\x1f\x7f]/.test(record.protocolFeeAddress)) throw new s402Error("INVALID_PAYLOAD", "protocolFeeAddress contains control characters");
365
373
  }
366
374
  if (record.facilitatorUrl !== void 0) {
367
375
  if (typeof record.facilitatorUrl !== "string") throw new s402Error("INVALID_PAYLOAD", `facilitatorUrl must be a string, got ${typeof record.facilitatorUrl}`);
package/dist/index.d.mts CHANGED
@@ -1,6 +1,7 @@
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 { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader, s402Receipt, s402ReceiptSigner, s402ReceiptVerifier } from "./receipts.mjs";
4
5
 
5
6
  //#region src/scheme.d.ts
6
7
  /** Implemented by each scheme on the client side */
@@ -49,14 +50,14 @@ interface s402DirectScheme {
49
50
  interface s402RouteConfig {
50
51
  /** Which payment scheme(s) to accept. Always includes "exact" for x402 compat. */
51
52
  schemes: s402Scheme[];
52
- /** Amount in base units, same as wire format (e.g., "1000000" for 0.001 SUI in MIST) */
53
+ /** Amount in base units, same as wire format (e.g., "1000000") */
53
54
  price: string;
54
- /** Sui network */
55
+ /** Network identifier (e.g., "sui:mainnet", "solana:mainnet-beta") */
55
56
  network: string;
56
- /** Recipient address */
57
+ /** Recipient address (chain-specific format, validated by chain adapter) */
57
58
  payTo: string;
58
- /** Move coin type */
59
- asset?: string;
59
+ /** Asset/coin type identifier (chain-specific, e.g., Sui Move type or Solana mint address) */
60
+ asset: string;
60
61
  /** Facilitator URL (optional for direct settlement) */
61
62
  facilitatorUrl?: string;
62
63
  /** Settlement mode preference */
@@ -89,7 +90,9 @@ interface s402RouteConfig {
89
90
  ratePerCall: string;
90
91
  maxCalls?: string;
91
92
  minDeposit: string;
92
- withdrawalDelayMs: 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;
93
96
  };
94
97
  }
95
98
  //#endregion
@@ -192,4 +195,4 @@ declare class s402ResourceServer {
192
195
  process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
193
196
  }
194
197
  //#endregion
195
- export { S402_CONTENT_TYPE, S402_HEADERS, S402_VERSION, createS402Error, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, 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 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 };
198
+ 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 };
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { S402_HEADERS, S402_VERSION } from "./types.mjs";
2
2
  import { createS402Error, s402Error, s402ErrorCode } from "./errors.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 { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader } from "./receipts.mjs";
4
5
 
5
6
  //#region src/client.ts
6
7
  var s402Client = class {
@@ -81,7 +82,7 @@ var s402ResourceServer = class {
81
82
  s402Version: S402_VERSION,
82
83
  accepts: [...new Set([...config.schemes, "exact"])],
83
84
  network: config.network,
84
- asset: config.asset ?? "0x2::sui::SUI",
85
+ asset: config.asset,
85
86
  amount: config.price,
86
87
  payTo: config.payTo,
87
88
  facilitatorUrl: config.facilitatorUrl,
@@ -317,4 +318,4 @@ var s402Facilitator = class {
317
318
  };
318
319
 
319
320
  //#endregion
320
- export { S402_CONTENT_TYPE, S402_HEADERS, S402_VERSION, createS402Error, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, s402Client, s402Error, s402ErrorCode, s402Facilitator, s402ResourceServer, validateRequirementsShape };
321
+ 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, s402Error, s402ErrorCode, s402Facilitator, s402ResourceServer, validateRequirementsShape };
@@ -0,0 +1,61 @@
1
+ //#region src/receipts.d.ts
2
+ /**
3
+ * s402 Receipt HTTP Helpers — chain-agnostic receipt header format/parse.
4
+ *
5
+ * Providers sign each API response with Ed25519. The signature, call number,
6
+ * timestamp, and response hash are transported as a single HTTP header:
7
+ *
8
+ * X-S402-Receipt: v2:base64(signature):callNumber:timestampMs:base64(responseHash)
9
+ *
10
+ * This module handles the header wire format ONLY. It does not:
11
+ * - Construct BCS messages (chain-specific — see @sweefi/sui receipts.ts)
12
+ * - Generate Ed25519 keys (implementations provide their own)
13
+ * - Accumulate receipts (client-side concern — see @sweefi/sui ReceiptAccumulator)
14
+ *
15
+ * Zero runtime dependencies. Uses built-in btoa/atob.
16
+ *
17
+ * @see docs/schemes/prepaid.md — v0.2 spec
18
+ */
19
+ /** Receipt data extracted from an HTTP header. */
20
+ interface s402Receipt {
21
+ /** Header version — always 'v2' for signed receipts. */
22
+ version: 'v2';
23
+ /** 64-byte Ed25519 signature over the BCS-encoded receipt message. */
24
+ signature: Uint8Array;
25
+ /** Sequential call number (1-indexed). */
26
+ callNumber: bigint;
27
+ /** Unix timestamp in milliseconds when the response was generated. */
28
+ timestampMs: bigint;
29
+ /** Hash of the response body (typically SHA-256, 32 bytes). */
30
+ responseHash: Uint8Array;
31
+ }
32
+ /** Signing function — implementations inject their own Ed25519 signer. */
33
+ type s402ReceiptSigner = (message: Uint8Array) => Promise<Uint8Array>;
34
+ /** Verification function — implementations inject their own Ed25519 verifier. */
35
+ type s402ReceiptVerifier = (message: Uint8Array, signature: Uint8Array) => Promise<boolean>;
36
+ /** HTTP header name for s402 signed usage receipts. */
37
+ declare const S402_RECEIPT_HEADER = "X-S402-Receipt";
38
+ /**
39
+ * Encode receipt fields into the `X-S402-Receipt` HTTP header value.
40
+ *
41
+ * Format: `v2:base64(signature):callNumber:timestampMs:base64(responseHash)`
42
+ *
43
+ * @param receipt - Receipt fields to encode
44
+ * @returns Header string ready for HTTP response
45
+ */
46
+ declare function formatReceiptHeader(receipt: {
47
+ signature: Uint8Array;
48
+ callNumber: bigint;
49
+ timestampMs: bigint;
50
+ responseHash: Uint8Array;
51
+ }): string;
52
+ /**
53
+ * Decode an `X-S402-Receipt` header value back into typed fields.
54
+ *
55
+ * @param header - Raw header value string
56
+ * @returns Parsed receipt with typed fields
57
+ * @throws On empty string, wrong number of parts, or unknown version
58
+ */
59
+ declare function parseReceiptHeader(header: string): s402Receipt;
60
+ //#endregion
61
+ export { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader, s402Receipt, s402ReceiptSigner, s402ReceiptVerifier };
@@ -0,0 +1,62 @@
1
+ //#region src/receipts.ts
2
+ /** HTTP header name for s402 signed usage receipts. */
3
+ const S402_RECEIPT_HEADER = "X-S402-Receipt";
4
+ const HEADER_VERSION = "v2";
5
+ /** Encode a Uint8Array to base64 using built-in btoa. */
6
+ function uint8ToBase64(bytes) {
7
+ let binary = "";
8
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
9
+ return btoa(binary);
10
+ }
11
+ /** Decode a base64 string to Uint8Array using built-in atob. */
12
+ function base64ToUint8(b64) {
13
+ const binary = atob(b64);
14
+ const bytes = new Uint8Array(binary.length);
15
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
16
+ return bytes;
17
+ }
18
+ /**
19
+ * Encode receipt fields into the `X-S402-Receipt` HTTP header value.
20
+ *
21
+ * Format: `v2:base64(signature):callNumber:timestampMs:base64(responseHash)`
22
+ *
23
+ * @param receipt - Receipt fields to encode
24
+ * @returns Header string ready for HTTP response
25
+ */
26
+ function formatReceiptHeader(receipt) {
27
+ return [
28
+ HEADER_VERSION,
29
+ uint8ToBase64(receipt.signature),
30
+ receipt.callNumber.toString(),
31
+ receipt.timestampMs.toString(),
32
+ uint8ToBase64(receipt.responseHash)
33
+ ].join(":");
34
+ }
35
+ /**
36
+ * Decode an `X-S402-Receipt` header value back into typed fields.
37
+ *
38
+ * @param header - Raw header value string
39
+ * @returns Parsed receipt with typed fields
40
+ * @throws On empty string, wrong number of parts, or unknown version
41
+ */
42
+ function parseReceiptHeader(header) {
43
+ if (!header) throw new Error("Empty receipt header");
44
+ const parts = header.split(":");
45
+ if (parts.length !== 5) throw new Error(`Malformed receipt header: expected 5 colon-separated parts, got ${parts.length}`);
46
+ const [version, sigB64, callNumberStr, timestampMsStr, hashB64] = parts;
47
+ if (version !== HEADER_VERSION) throw new Error(`Unknown receipt header version: "${version}" (expected "${HEADER_VERSION}")`);
48
+ const callNumber = BigInt(callNumberStr);
49
+ const timestampMs = BigInt(timestampMsStr);
50
+ if (callNumber <= 0n) throw new Error(`Invalid receipt callNumber: must be positive, got ${callNumber}`);
51
+ if (timestampMs <= 0n) throw new Error(`Invalid receipt timestampMs: must be positive, got ${timestampMs}`);
52
+ return {
53
+ version: "v2",
54
+ signature: base64ToUint8(sigB64),
55
+ callNumber,
56
+ timestampMs,
57
+ responseHash: base64ToUint8(hashB64)
58
+ };
59
+ }
60
+
61
+ //#endregion
62
+ export { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader };
package/dist/types.d.mts CHANGED
@@ -16,11 +16,11 @@ interface s402PaymentRequirements {
16
16
  s402Version: typeof S402_VERSION;
17
17
  /** Which payment schemes the server accepts. Always includes "exact" for x402 compat. */
18
18
  accepts: s402Scheme[];
19
- /** Sui network (e.g., "sui:testnet", "sui:mainnet") */
19
+ /** Network identifier (e.g., "sui:mainnet", "solana:mainnet-beta", "eip155:8453") */
20
20
  network: string;
21
- /** Move coin type (e.g., "0x2::sui::SUI") */
21
+ /** Asset/coin type identifier (chain-specific format, opaque to s402 core) */
22
22
  asset: string;
23
- /** Amount in base units (MIST for SUI, 6-decimal for USDC) */
23
+ /** Amount in base units as a non-negative integer string */
24
24
  amount: string;
25
25
  /** Recipient address */
26
26
  payTo: string;
@@ -115,6 +115,11 @@ interface s402UnlockExtra {
115
115
  * Move cannot verify calls actually happened. Agent's protection: rate cap,
116
116
  * max_calls, deposit ceiling, small deposits + short refill cycles, reputation.
117
117
  * v0.2 adds signed usage receipts for cryptographic fraud proofs. See ADR-007.
118
+ *
119
+ * PAIRING INVARIANT: `providerPubkey` and `disputeWindowMs` are a pair.
120
+ * Both must be present (v0.2 signed receipt mode) or both absent (v0.1 default).
121
+ * Setting `providerPubkey` without `disputeWindowMs` (or vice versa) is invalid
122
+ * and will be rejected by the on-chain contract at deposit time.
118
123
  */
119
124
  interface s402PrepaidExtra {
120
125
  /** Maximum base units per API call (rate cap) */
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "s402",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
- "description": "s402 — Sui-native HTTP 402 wire format. Types, HTTP encoding, and scheme registry for five payment schemes. Wire-compatible with x402. Zero runtime dependencies.",
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",
7
7
  "author": "SweeInc <daniel@sweeinc.com> (https://s402-protocol.org)",
8
8
  "repository": {
@@ -36,7 +36,8 @@
36
36
  "README.md",
37
37
  "LICENSE",
38
38
  "CHANGELOG.md",
39
- "SECURITY.md"
39
+ "SECURITY.md",
40
+ "test/conformance/vectors"
40
41
  ],
41
42
  "main": "./dist/index.mjs",
42
43
  "module": "./dist/index.mjs",
@@ -76,6 +77,13 @@
76
77
  "default": "./dist/errors.mjs"
77
78
  },
78
79
  "default": "./dist/errors.mjs"
80
+ },
81
+ "./receipts": {
82
+ "import": {
83
+ "types": "./dist/receipts.d.mts",
84
+ "default": "./dist/receipts.mjs"
85
+ },
86
+ "default": "./dist/receipts.mjs"
79
87
  }
80
88
  },
81
89
  "scripts": {
@@ -0,0 +1,172 @@
1
+ # s402 Conformance Test Vectors
2
+
3
+ Machine-readable test vectors that define s402-compliant behavior. Use these to verify any s402 implementation — TypeScript, Go, Python, Rust, etc.
4
+
5
+ ## Getting the Vectors
6
+
7
+ The vectors ship with the `s402` npm package:
8
+
9
+ ```bash
10
+ npm pack s402
11
+ tar xzf s402-*.tgz
12
+ ls package/test/conformance/vectors/
13
+ ```
14
+
15
+ Or clone the repo directly:
16
+
17
+ ```bash
18
+ git clone https://github.com/s402-protocol/core.git
19
+ ls core/test/conformance/vectors/
20
+ ```
21
+
22
+ ## Vector Format
23
+
24
+ Each JSON file contains an array of test cases:
25
+
26
+ ```json
27
+ [
28
+ {
29
+ "description": "Human-readable test name",
30
+ "input": { ... },
31
+ "expected": { ... },
32
+ "shouldReject": false
33
+ }
34
+ ]
35
+ ```
36
+
37
+ ### Accept vectors (`shouldReject: false`)
38
+
39
+ The implementation MUST produce `expected` when given `input`.
40
+
41
+ ### Reject vectors (`shouldReject: true`)
42
+
43
+ The implementation MUST reject the input with an error. The `expectedErrorCode` field indicates which error:
44
+
45
+ ```json
46
+ {
47
+ "description": "Rejects negative amount",
48
+ "input": { "header": "base64-encoded-malformed-json" },
49
+ "shouldReject": true,
50
+ "expectedErrorCode": "INVALID_PAYLOAD"
51
+ }
52
+ ```
53
+
54
+ ## Vector Files
55
+
56
+ | File | Tests | Description |
57
+ |------|-------|-------------|
58
+ | `requirements-encode.json` | Encode | `s402PaymentRequirements` object → base64 header string |
59
+ | `requirements-decode.json` | Decode | base64 header string → `s402PaymentRequirements` object |
60
+ | `payload-encode.json` | Encode | `s402PaymentPayload` object → base64 header string |
61
+ | `payload-decode.json` | Decode | base64 header string → `s402PaymentPayload` object |
62
+ | `settle-encode.json` | Encode | `s402SettleResponse` object → base64 header string |
63
+ | `settle-decode.json` | Decode | base64 header string → `s402SettleResponse` object |
64
+ | `body-transport.json` | Both | Requirements/payload/settle via JSON body (no base64) |
65
+ | `compat-normalize.json` | Normalize | x402 V1/V2 input → normalized s402 output |
66
+ | `receipt-format.json` | Format | Receipt fields → `X-S402-Receipt` header string |
67
+ | `receipt-parse.json` | Parse | `X-S402-Receipt` header string → parsed receipt fields |
68
+ | `validation-reject.json` | Reject | Malformed inputs that MUST be rejected |
69
+ | `roundtrip.json` | Roundtrip | encode → decode → re-encode = identical output |
70
+
71
+ ## Encoding Scheme
72
+
73
+ s402 uses **Unicode-safe base64** for HTTP header transport:
74
+
75
+ 1. JSON-serialize the object: `JSON.stringify(obj)`
76
+ 2. UTF-8 encode the string: `TextEncoder.encode(json)`
77
+ 3. Base64 encode the bytes: `btoa(bytes.map(b => String.fromCharCode(b)).join(''))`
78
+
79
+ For ASCII-only content (the common case), this produces the same output as plain `btoa(json)`.
80
+
81
+ Body transport uses raw JSON (no base64).
82
+
83
+ ## Receipt Header Format
84
+
85
+ The `X-S402-Receipt` header uses colon-separated fields:
86
+
87
+ ```
88
+ v2:base64(signature):callNumber:timestampMs:base64(responseHash)
89
+ ```
90
+
91
+ - `v2` — version prefix (always "v2" for signed receipts)
92
+ - `signature` — 64-byte Ed25519 signature, base64-encoded
93
+ - `callNumber` — sequential call number (1-indexed, bigint string)
94
+ - `timestampMs` — Unix timestamp in milliseconds (bigint string)
95
+ - `responseHash` — response body hash (typically SHA-256, 32 bytes), base64-encoded
96
+
97
+ In the vector JSON files, `signature` and `responseHash` are represented as arrays of byte values (0-255) since JSON has no binary type.
98
+
99
+ ## Implementing in Another Language
100
+
101
+ 1. Load the JSON vector files
102
+ 2. For each vector where `shouldReject` is `false`:
103
+ - Feed `input` to your encode/decode function
104
+ - Compare the result to `expected`
105
+ 3. For each vector where `shouldReject` is `true`:
106
+ - Feed `input` to your decode function
107
+ - Verify it throws/returns an error with the matching error code
108
+ 4. For roundtrip vectors:
109
+ - Verify `expected.identical` is `true`
110
+ - Verify `expected.firstEncode === expected.reEncode`
111
+
112
+ ### Key stripping on decode
113
+
114
+ All three decode functions (`decodePaymentRequired`, `decodePaymentPayload`, `decodeSettleResponse`) MUST strip unknown top-level keys. Only known fields should survive the decode. This is a defense-in-depth measure at the HTTP trust boundary.
115
+
116
+ **Requirements known top-level keys:** `s402Version`, `accepts`, `network`, `asset`, `amount`, `payTo`, `facilitatorUrl`, `mandate`, `protocolFeeBps`, `protocolFeeAddress`, `receiptRequired`, `settlementMode`, `expiresAt`, `stream`, `escrow`, `unlock`, `prepaid`, `extensions`.
117
+
118
+ **Requirements sub-object known keys** (also stripped of unknowns):
119
+ - `mandate`: `required`, `minPerTx`, `coinType`
120
+ - `stream`: `ratePerSecond`, `budgetCap`, `minDeposit`, `streamSetupUrl`
121
+ - `escrow`: `seller`, `arbiter`, `deadlineMs`
122
+ - `unlock`: `encryptionId`, `walrusBlobId`, `encryptionPackageId`
123
+ - `prepaid`: `ratePerCall`, `maxCalls`, `minDeposit`, `withdrawalDelayMs`, `providerPubkey`, `disputeWindowMs`
124
+
125
+ **Payload known top-level keys:** `s402Version`, `scheme`, `payload`.
126
+
127
+ **Payload inner keys** (per scheme):
128
+ - `exact`, `stream`, `escrow`: `transaction`, `signature`
129
+ - `unlock`: `transaction`, `signature`, `encryptionId`
130
+ - `prepaid`: `transaction`, `signature`, `ratePerCall`, `maxCalls`
131
+
132
+ **Settle response known keys:** `success`, `txDigest`, `receiptId`, `finalityMs`, `streamId`, `escrowId`, `balanceId`, `error`, `errorCode`.
133
+
134
+ ### JSON key ordering
135
+
136
+ s402 vectors use JavaScript's `JSON.stringify()` key ordering (insertion order). Cross-language implementations MUST serialize keys in the same order as the vector files to produce identical base64 output. If your language's JSON serializer uses alphabetical ordering by default, you'll need ordered serialization (e.g., Go's `json.Marshal` on a struct with ordered fields, Python's `json.dumps` with `sort_keys=False`).
137
+
138
+ Note: **decode also reorders keys**. The decode functions iterate their allowlist in the order listed above, not the input order. If the input JSON has keys in a different order, the decoded output follows the allowlist order. This affects roundtrip tests — the re-encoded output uses the allowlist order, which may differ from the original input order.
139
+
140
+ ### Header size limits
141
+
142
+ Implementations SHOULD enforce a maximum header size of **64 KiB** (`MAX_HEADER_BYTES = 65536`). Headers exceeding this limit indicate abuse or misconfiguration and SHOULD be rejected before base64 decoding.
143
+
144
+ ### Rejection vector dispatch
145
+
146
+ Rejection vectors are dispatched in this precedence order:
147
+
148
+ 1. If `expectedErrorCode` is `"RECEIPT_PARSE_ERROR"` → test `parseReceiptHeader`. Note: receipts throw a plain `Error`, not an `s402Error`, since receipts are a separate subsystem.
149
+ 2. If `input.decodeAs` is `"payload"` → test `decodePaymentPayload`
150
+ 3. If `input.decodeAs` is `"compat"` → test `normalizeRequirements` with the raw JSON (not base64)
151
+ 4. Otherwise → test `decodePaymentRequired` (the default)
152
+
153
+ ### Error codes
154
+
155
+ Rejection vectors use `expectedErrorCode` values from the s402 error code enum:
156
+
157
+ - `INVALID_PAYLOAD` — malformed or invalid input
158
+ - `RECEIPT_PARSE_ERROR` — malformed receipt header (vector convention for receipt-specific parsing errors)
159
+
160
+ ## Regenerating Vectors
161
+
162
+ If you modify the s402 encoding logic, regenerate vectors:
163
+
164
+ ```bash
165
+ npx tsx test/conformance/generate-vectors.ts
166
+ ```
167
+
168
+ Then run the conformance tests to verify:
169
+
170
+ ```bash
171
+ pnpm run test
172
+ ```