s402 0.4.0 → 0.5.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,37 @@ 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.5.0] - 2026-04-12
9
+
10
+ ### Added
11
+
12
+ - **`upto` scheme V2 features (DAN-284).** Two new fields close x402's upto overcharge vulnerability:
13
+ - `estimatedAmount` on `s402UptoExtra` — server's advisory cost estimate so clients can set tight ceilings
14
+ - `settlementCeiling` on `s402UptoPayload` — client-chosen, on-chain-enforced cap. Move contract rejects `actualAmount > settlementCeiling`. Must satisfy `1 <= settlementCeiling <= maxAmount`. See ADR-003 §Decision 3 and §Decision 8.
15
+ - **Extension system (DAN-285, ADR-004).** Typed, lifecycle-aware plugin architecture:
16
+ - Three actor-specific interfaces: `s402ClientExtension`, `s402ServerExtension`, `s402FacilitatorExtension`
17
+ - Four facilitator hooks in pipeline order: `beforeVerify` → `afterVerify` → `beforeSettle` → `afterSettle`
18
+ - `s402ExtensionRegistry` with dependency ordering via Kahn's topological sort
19
+ - Critical vs advisory error handling: `critical: true` extensions throw, advisory extensions log and continue
20
+ - `getExtensionData<T>()` / `setExtensionData()` type-safe helpers
21
+ - `./extensions` sub-path export added to package.json
22
+ - **`skipVerify` option on `process()`.** New `s402ProcessOptions` interface with `skipVerify?: boolean`. Eliminates the verify() dry-run RPC round-trip (~200-400ms) for chains where failed transactions cost zero gas (Sui PTBs). All pre-flight checks (expiration, scheme-mismatch, dedup) still run.
23
+ - **`EXTENSION_FAILED` error code** — `retryable: false`, for critical extension pipeline failures.
24
+ - **154 conformance test vectors** (was ~130). New vectors for: upto requirements with estimatedAmount, upto payloads with settlementCeiling, settle responses with actualAmount/depositId, V2 rejection vectors, upto roundtrips, mandate.minPerTx validation.
25
+
26
+ ### Fixed
27
+
28
+ - **Settle response type validation (M1).** `validateSettleShape` now rejects non-string `actualAmount` and `depositId` — a malicious facilitator could previously inject numeric types that passed through to consumer code.
29
+ - **Prepaid payload amount validation (M2).** `ratePerCall` and `maxCalls` in payload now validated with `isValidAmount()`, matching the requirements-side validation. Previously only type-checked as strings.
30
+ - **Mandate minPerTx amount validation (L1).** `mandate.minPerTx` now validated with `isValidAmount()` for consistency with other amount fields.
31
+ - **afterSettle error observability.** Catch block now forwards to `extensionErrorHandler` instead of silently swallowing critical extension errors (the settlement result is still never changed — tx is already on-chain).
32
+ - **Stale comment in validatePayloadShape.** Updated to document upto's scheme-specific inner keys alongside prepaid and unlock.
33
+
34
+ ### Changed
35
+
36
+ - **831 tests across 17 files** (was 798). New coverage: standalone verify/settle guard tests, V2 validation edge cases, settle response type checks, prepaid amount validation, extension system integration.
37
+ - Conformance README updated with `estimatedAmount` in upto sub-object keys and `settlementCeiling` in payload inner keys.
38
+
8
39
  ## [0.4.0] - 2026-04-11
9
40
 
10
41
  ### Changed
package/dist/compat.d.mts CHANGED
@@ -58,7 +58,7 @@ interface x402PaymentPayload {
58
58
  * Handles both V1 (`maxAmountRequired`) and V2 (`amount`) wire formats.
59
59
  * Maps x402's single scheme to s402's accepts array.
60
60
  */
61
- declare function fromX402Requirements(x402: x402PaymentRequirements): s402PaymentRequirements;
61
+ declare function fromX402Requirements(x402: x402PaymentRequirements, now?: number): s402PaymentRequirements;
62
62
  /**
63
63
  * Convert inbound x402 payment payload to s402 format.
64
64
  * Validates that required fields are present and correctly typed.
@@ -66,7 +66,7 @@ declare function fromX402Requirements(x402: x402PaymentRequirements): s402Paymen
66
66
  declare function fromX402Payload(x402: x402PaymentPayload): s402ExactPayload;
67
67
  /**
68
68
  * Convert outbound s402 requirements to x402 V1 wire format.
69
- * Strips s402-only fields (mandate, stream, escrow, unlock extensions).
69
+ * Strips s402-only fields (mandate, upto, prepaid, stream, escrow, unlock extensions).
70
70
  * Only works for "exact" scheme — other schemes have no x402 equivalent.
71
71
  *
72
72
  * Includes both `maxAmountRequired` (V1) and `amount` (V2) for maximum interop.
@@ -101,7 +101,7 @@ declare function isX402Envelope(obj: Record<string, unknown>): boolean;
101
101
  * Picks the first requirement from the `accepts` array.
102
102
  * Copies `x402Version` from the envelope onto the requirement for downstream processing.
103
103
  */
104
- declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope): s402PaymentRequirements;
104
+ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope, now?: number): s402PaymentRequirements;
105
105
  /**
106
106
  * Auto-detect and normalize: if x402, convert to s402. If already s402, validate and pass through.
107
107
  * Handles x402 V1 (flat), x402 V2 (envelope with accepts array), and s402 formats.
@@ -123,6 +123,6 @@ declare function fromX402Envelope(envelope: x402PaymentRequiredEnvelope): s402Pa
123
123
  * // Always returns s402PaymentRequirements regardless of input format
124
124
  * ```
125
125
  */
126
- declare function normalizeRequirements(obj: Record<string, unknown>): s402PaymentRequirements;
126
+ declare function normalizeRequirements(obj: Record<string, unknown>, now?: number): s402PaymentRequirements;
127
127
  //#endregion
128
128
  export { fromX402Envelope, fromX402Payload, fromX402Requirements, isS402, isX402, isX402Envelope, normalizeRequirements, toX402Payload, toX402Requirements, x402PaymentPayload, x402PaymentRequiredEnvelope, x402PaymentRequirements };
package/dist/compat.mjs CHANGED
@@ -8,7 +8,7 @@ import { isValidAmount, pickRequirementsFields, validateRequirementsShape } from
8
8
  * Handles both V1 (`maxAmountRequired`) and V2 (`amount`) wire formats.
9
9
  * Maps x402's single scheme to s402's accepts array.
10
10
  */
11
- function fromX402Requirements(x402) {
11
+ function fromX402Requirements(x402, now) {
12
12
  const amount = x402.amount ?? x402.maxAmountRequired;
13
13
  if (!amount) throw new s402Error("INVALID_PAYLOAD", "x402 requirements missing both \"amount\" (V2) and \"maxAmountRequired\" (V1)");
14
14
  if (!isValidAmount(amount)) throw new s402Error("INVALID_PAYLOAD", `Invalid amount "${amount}": must be a non-negative integer string`);
@@ -20,6 +20,7 @@ function fromX402Requirements(x402) {
20
20
  if (e instanceof s402Error) throw e;
21
21
  throw new s402Error("INVALID_PAYLOAD", "facilitatorUrl is not a valid URL");
22
22
  }
23
+ const expiresAt = x402.maxTimeoutSeconds != null && x402.maxTimeoutSeconds > 0 ? (now ?? Date.now()) + x402.maxTimeoutSeconds * 1e3 : void 0;
23
24
  return {
24
25
  s402Version: S402_VERSION,
25
26
  accepts: ["exact"],
@@ -28,6 +29,7 @@ function fromX402Requirements(x402) {
28
29
  amount,
29
30
  payTo: x402.payTo,
30
31
  facilitatorUrl: x402.facilitatorUrl,
32
+ expiresAt,
31
33
  extensions: x402.extensions
32
34
  };
33
35
  }
@@ -50,7 +52,7 @@ function fromX402Payload(x402) {
50
52
  }
51
53
  /**
52
54
  * Convert outbound s402 requirements to x402 V1 wire format.
53
- * Strips s402-only fields (mandate, stream, escrow, unlock extensions).
55
+ * Strips s402-only fields (mandate, upto, prepaid, stream, escrow, unlock extensions).
54
56
  * Only works for "exact" scheme — other schemes have no x402 equivalent.
55
57
  *
56
58
  * Includes both `maxAmountRequired` (V1) and `amount` (V2) for maximum interop.
@@ -113,14 +115,14 @@ function isX402Envelope(obj) {
113
115
  * Picks the first requirement from the `accepts` array.
114
116
  * Copies `x402Version` from the envelope onto the requirement for downstream processing.
115
117
  */
116
- function fromX402Envelope(envelope) {
118
+ function fromX402Envelope(envelope, now) {
117
119
  if (!envelope.accepts || envelope.accepts.length === 0) throw new s402Error("INVALID_PAYLOAD", "x402 V2 envelope has empty accepts array");
118
120
  const req = {
119
121
  ...envelope.accepts[0],
120
122
  x402Version: envelope.x402Version
121
123
  };
122
124
  validateX402Shape(req);
123
- return fromX402Requirements(req);
125
+ return fromX402Requirements(req, now);
124
126
  }
125
127
  /**
126
128
  * Auto-detect and normalize: if x402, convert to s402. If already s402, validate and pass through.
@@ -143,20 +145,20 @@ function fromX402Envelope(envelope) {
143
145
  * // Always returns s402PaymentRequirements regardless of input format
144
146
  * ```
145
147
  */
146
- function normalizeRequirements(obj) {
148
+ function normalizeRequirements(obj, now) {
147
149
  if (obj == null || typeof obj !== "object" || Array.isArray(obj)) throw new s402Error("INVALID_PAYLOAD", `Payment requirements must be a plain object, got ${obj === null ? "null" : Array.isArray(obj) ? "array" : typeof obj}`);
148
150
  if (isS402(obj)) {
149
151
  validateRequirementsShape(obj);
150
152
  return pickRequirementsFields(obj);
151
153
  }
152
154
  if (isX402Envelope(obj)) {
153
- const record = fromX402Envelope(obj);
155
+ const record = fromX402Envelope(obj, now);
154
156
  validateRequirementsShape(record);
155
157
  return pickRequirementsFields(record);
156
158
  }
157
159
  if (isX402(obj)) {
158
160
  validateX402Shape(obj);
159
- const record = fromX402Requirements(obj);
161
+ const record = fromX402Requirements(obj, now);
160
162
  validateRequirementsShape(record);
161
163
  return pickRequirementsFields(record);
162
164
  }
package/dist/errors.d.mts CHANGED
@@ -24,6 +24,7 @@ declare const s402ErrorCode: {
24
24
  readonly VERIFICATION_FAILED: "VERIFICATION_FAILED";
25
25
  readonly SETTLEMENT_FAILED: "SETTLEMENT_FAILED";
26
26
  readonly DIGEST_MISMATCH: "DIGEST_MISMATCH";
27
+ readonly EXTENSION_FAILED: "EXTENSION_FAILED";
27
28
  };
28
29
  type s402ErrorCodeType = (typeof s402ErrorCode)[keyof typeof s402ErrorCode];
29
30
  interface s402ErrorInfo {
package/dist/errors.mjs CHANGED
@@ -23,7 +23,8 @@ const s402ErrorCode = {
23
23
  REQUIREMENTS_EXPIRED: "REQUIREMENTS_EXPIRED",
24
24
  VERIFICATION_FAILED: "VERIFICATION_FAILED",
25
25
  SETTLEMENT_FAILED: "SETTLEMENT_FAILED",
26
- DIGEST_MISMATCH: "DIGEST_MISMATCH"
26
+ DIGEST_MISMATCH: "DIGEST_MISMATCH",
27
+ EXTENSION_FAILED: "EXTENSION_FAILED"
27
28
  };
28
29
  /** Error recovery hints for each error code */
29
30
  const ERROR_HINTS = {
@@ -90,6 +91,10 @@ const ERROR_HINTS = {
90
91
  DIGEST_MISMATCH: {
91
92
  retryable: false,
92
93
  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."
94
+ },
95
+ EXTENSION_FAILED: {
96
+ retryable: false,
97
+ suggestedAction: "A critical extension blocked the payment flow. Contact the extension provider or disable the extension."
93
98
  }
94
99
  };
95
100
  /**
package/dist/http.d.mts CHANGED
@@ -117,9 +117,13 @@ declare function isValidU64Amount(s: string): boolean;
117
117
  */
118
118
  declare function validateMandateShape(value: unknown): void;
119
119
  /**
120
- * Validate stream sub-object.
121
- * Checks required fields are present and correctly typed.
120
+ * Validate upto sub-object (usage-based variable settlement).
122
121
  */
122
+ declare function validateUptoShape(value: unknown): void;
123
+ /**
124
+ * Validate settlementOverrides sub-object.
125
+ */
126
+ declare function validateSettlementOverridesShape(value: unknown): void;
123
127
  declare function validateStreamShape(value: unknown): void;
124
128
  /**
125
129
  * Validate escrow sub-object.
@@ -195,4 +199,4 @@ declare function detectProtocol(headers: Headers): 's402' | 'x402' | 'unknown';
195
199
  */
196
200
  declare function extractRequirementsFromResponse(response: Response): s402PaymentRequirements | null;
197
201
  //#endregion
198
- export { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateStreamShape, validateSubObjects, validateUnlockShape };
202
+ export { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateSettlementOverridesShape, validateStreamShape, validateSubObjects, validateUnlockShape, validateUptoShape };
package/dist/http.mjs CHANGED
@@ -83,10 +83,12 @@ const S402_REQUIREMENTS_KEYS = new Set([
83
83
  "receiptRequired",
84
84
  "settlementMode",
85
85
  "expiresAt",
86
+ "upto",
87
+ "settlementOverrides",
88
+ "prepaid",
86
89
  "stream",
87
90
  "escrow",
88
91
  "unlock",
89
- "prepaid",
90
92
  "extensions"
91
93
  ]);
92
94
  /** Known keys for each sub-object type — used to strip extra keys at the trust boundary. */
@@ -96,6 +98,21 @@ const S402_SUB_OBJECT_KEYS = {
96
98
  "minPerTx",
97
99
  "coinType"
98
100
  ]),
101
+ upto: new Set([
102
+ "maxAmount",
103
+ "settlementDeadlineMs",
104
+ "usageReportUrl",
105
+ "estimatedAmount"
106
+ ]),
107
+ settlementOverrides: new Set(["actualAmount"]),
108
+ prepaid: new Set([
109
+ "ratePerCall",
110
+ "maxCalls",
111
+ "minDeposit",
112
+ "withdrawalDelayMs",
113
+ "providerPubkey",
114
+ "disputeWindowMs"
115
+ ]),
99
116
  stream: new Set([
100
117
  "ratePerSecond",
101
118
  "budgetCap",
@@ -111,14 +128,6 @@ const S402_SUB_OBJECT_KEYS = {
111
128
  "encryptionId",
112
129
  "encryptedContentId",
113
130
  "encryptionServiceId"
114
- ]),
115
- prepaid: new Set([
116
- "ratePerCall",
117
- "maxCalls",
118
- "minDeposit",
119
- "withdrawalDelayMs",
120
- "providerPubkey",
121
- "disputeWindowMs"
122
131
  ])
123
132
  };
124
133
  /** Strip unknown keys from a sub-object, returning a clean copy. */
@@ -178,22 +187,29 @@ const S402_PAYLOAD_TOP_KEYS = new Set([
178
187
  ]);
179
188
  /**
180
189
  * Known inner payload keys per scheme. All schemes share transaction + signature;
181
- * unlock adds encryptionId, prepaid adds ratePerCall + maxCalls.
190
+ * upto adds maxAmount + settlementCeiling, prepaid adds ratePerCall + maxCalls,
191
+ * unlock adds encryptionId.
182
192
  */
183
193
  const S402_PAYLOAD_INNER_KEYS = {
184
194
  exact: new Set(["transaction", "signature"]),
185
- stream: new Set(["transaction", "signature"]),
186
- escrow: new Set(["transaction", "signature"]),
187
- unlock: new Set([
195
+ upto: new Set([
188
196
  "transaction",
189
197
  "signature",
190
- "encryptionId"
198
+ "maxAmount",
199
+ "settlementCeiling"
191
200
  ]),
192
201
  prepaid: new Set([
193
202
  "transaction",
194
203
  "signature",
195
204
  "ratePerCall",
196
205
  "maxCalls"
206
+ ]),
207
+ stream: new Set(["transaction", "signature"]),
208
+ escrow: new Set(["transaction", "signature"]),
209
+ unlock: new Set([
210
+ "transaction",
211
+ "signature",
212
+ "encryptionId"
197
213
  ])
198
214
  };
199
215
  /** Return a clean payload object with only known s402 payload fields. */
@@ -249,9 +265,11 @@ const S402_SETTLE_RESPONSE_KEYS = new Set([
249
265
  "txDigest",
250
266
  "receiptId",
251
267
  "finalityMs",
268
+ "actualAmount",
269
+ "depositId",
270
+ "balanceId",
252
271
  "streamId",
253
272
  "escrowId",
254
- "balanceId",
255
273
  "error",
256
274
  "errorCode"
257
275
  ]);
@@ -277,10 +295,11 @@ function decodeSettleResponse(header) {
277
295
  /** Valid s402 payment scheme values */
278
296
  const VALID_SCHEMES = new Set([
279
297
  "exact",
298
+ "upto",
299
+ "prepaid",
280
300
  "stream",
281
301
  "escrow",
282
- "unlock",
283
- "prepaid"
302
+ "unlock"
284
303
  ]);
285
304
  /**
286
305
  * Check that a string represents a canonical non-negative integer.
@@ -338,12 +357,37 @@ function validateMandateShape(value) {
338
357
  const obj = value;
339
358
  if (typeof obj.required !== "boolean") throw new s402Error("INVALID_PAYLOAD", `mandate.required must be a boolean, got ${typeof obj.required}`);
340
359
  assertOptionalString(obj, "minPerTx", "mandate");
360
+ if (typeof obj.minPerTx === "string" && !isValidAmount(obj.minPerTx)) throw new s402Error("INVALID_PAYLOAD", `mandate.minPerTx must be a non-negative integer string, got "${obj.minPerTx}"`);
341
361
  assertOptionalString(obj, "coinType", "mandate");
342
362
  }
343
363
  /**
344
- * Validate stream sub-object.
345
- * Checks required fields are present and correctly typed.
364
+ * Validate upto sub-object (usage-based variable settlement).
365
+ */
366
+ function validateUptoShape(value) {
367
+ assertPlainObject(value, "upto");
368
+ const obj = value;
369
+ assertString(obj, "maxAmount", "upto");
370
+ if (typeof obj.maxAmount === "string" && !isValidAmount(obj.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto.maxAmount must be a non-negative integer string, got "${obj.maxAmount}"`);
371
+ assertString(obj, "settlementDeadlineMs", "upto");
372
+ if (typeof obj.settlementDeadlineMs === "string" && !isValidAmount(obj.settlementDeadlineMs)) throw new s402Error("INVALID_PAYLOAD", `upto.settlementDeadlineMs must be a non-negative integer string (Unix timestamp ms), got "${obj.settlementDeadlineMs}"`);
373
+ assertOptionalString(obj, "usageReportUrl", "upto");
374
+ assertOptionalString(obj, "estimatedAmount", "upto");
375
+ if (typeof obj.estimatedAmount === "string") {
376
+ if (!isValidAmount(obj.estimatedAmount)) throw new s402Error("INVALID_PAYLOAD", `upto.estimatedAmount must be a non-negative integer string, got "${obj.estimatedAmount}"`);
377
+ if (typeof obj.maxAmount === "string" && isValidAmount(obj.maxAmount)) {
378
+ if (BigInt(obj.estimatedAmount) > BigInt(obj.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto.estimatedAmount (${obj.estimatedAmount}) must be <= maxAmount (${obj.maxAmount})`);
379
+ }
380
+ }
381
+ }
382
+ /**
383
+ * Validate settlementOverrides sub-object.
346
384
  */
385
+ function validateSettlementOverridesShape(value) {
386
+ assertPlainObject(value, "settlementOverrides");
387
+ const obj = value;
388
+ assertString(obj, "actualAmount", "settlementOverrides");
389
+ if (typeof obj.actualAmount === "string" && !isValidAmount(obj.actualAmount)) throw new s402Error("INVALID_PAYLOAD", `settlementOverrides.actualAmount must be a non-negative integer string, got "${obj.actualAmount}"`);
390
+ }
347
391
  function validateStreamShape(value) {
348
392
  assertPlainObject(value, "stream");
349
393
  const obj = value;
@@ -404,10 +448,12 @@ function validatePrepaidShape(value) {
404
448
  */
405
449
  function validateSubObjects(record) {
406
450
  if (record.mandate !== void 0) validateMandateShape(record.mandate);
451
+ if (record.upto !== void 0) validateUptoShape(record.upto);
452
+ if (record.settlementOverrides !== void 0) validateSettlementOverridesShape(record.settlementOverrides);
453
+ if (record.prepaid !== void 0) validatePrepaidShape(record.prepaid);
407
454
  if (record.stream !== void 0) validateStreamShape(record.stream);
408
455
  if (record.escrow !== void 0) validateEscrowShape(record.escrow);
409
456
  if (record.unlock !== void 0) validateUnlockShape(record.unlock);
410
- if (record.prepaid !== void 0) validatePrepaidShape(record.prepaid);
411
457
  }
412
458
  /** Validate that decoded payment requirements have the required shape. */
413
459
  function validateRequirementsShape(obj) {
@@ -474,8 +520,25 @@ function validatePayloadShape(obj) {
474
520
  if (typeof inner.transaction !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.transaction must be a string, got ${typeof inner.transaction}`);
475
521
  if (typeof inner.signature !== "string") throw new s402Error("INVALID_PAYLOAD", `payload.signature must be a string, got ${typeof inner.signature}`);
476
522
  if (record.scheme === "unlock" && typeof inner.encryptionId !== "string") throw new s402Error("INVALID_PAYLOAD", `unlock payload requires encryptionId (string), got ${typeof inner.encryptionId}`);
477
- if (record.scheme === "prepaid" && typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
478
- if (record.scheme === "prepaid" && inner.maxCalls !== void 0 && typeof inner.maxCalls !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a string if provided, got ${typeof inner.maxCalls}`);
523
+ if (record.scheme === "upto") {
524
+ if (typeof inner.maxAmount !== "string") throw new s402Error("INVALID_PAYLOAD", `upto payload requires maxAmount (string), got ${typeof inner.maxAmount}`);
525
+ if (!isValidAmount(inner.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto payload maxAmount must be a non-negative integer string, got "${inner.maxAmount}"`);
526
+ if (inner.settlementCeiling !== void 0) {
527
+ if (typeof inner.settlementCeiling !== "string") throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling must be a string if provided, got ${typeof inner.settlementCeiling}`);
528
+ if (!isValidAmount(inner.settlementCeiling)) throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling must be a non-negative integer string, got "${inner.settlementCeiling}"`);
529
+ const ceiling = BigInt(inner.settlementCeiling);
530
+ if (ceiling < 1n) throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling must be >= 1, got "${inner.settlementCeiling}"`);
531
+ if (ceiling > BigInt(inner.maxAmount)) throw new s402Error("INVALID_PAYLOAD", `upto payload settlementCeiling (${inner.settlementCeiling}) must be <= maxAmount (${inner.maxAmount})`);
532
+ }
533
+ }
534
+ if (record.scheme === "prepaid") {
535
+ if (typeof inner.ratePerCall !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload requires ratePerCall (string), got ${typeof inner.ratePerCall}`);
536
+ if (!isValidAmount(inner.ratePerCall)) throw new s402Error("INVALID_PAYLOAD", `prepaid payload ratePerCall must be a non-negative integer string, got "${inner.ratePerCall}"`);
537
+ if (inner.maxCalls !== void 0) {
538
+ if (typeof inner.maxCalls !== "string") throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a string if provided, got ${typeof inner.maxCalls}`);
539
+ if (!isValidAmount(inner.maxCalls)) throw new s402Error("INVALID_PAYLOAD", `prepaid payload maxCalls must be a non-negative integer string, got "${inner.maxCalls}"`);
540
+ }
541
+ }
479
542
  }
480
543
  /** Validate that a decoded settle response has the required shape. */
481
544
  function validateSettleShape(obj) {
@@ -488,6 +551,8 @@ function validateSettleShape(obj) {
488
551
  if (record.streamId !== void 0 && typeof record.streamId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: streamId must be a string, got ${typeof record.streamId}`);
489
552
  if (record.escrowId !== void 0 && typeof record.escrowId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: escrowId must be a string, got ${typeof record.escrowId}`);
490
553
  if (record.balanceId !== void 0 && typeof record.balanceId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: balanceId must be a string, got ${typeof record.balanceId}`);
554
+ if (record.actualAmount !== void 0 && typeof record.actualAmount !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: actualAmount must be a string, got ${typeof record.actualAmount}`);
555
+ if (record.depositId !== void 0 && typeof record.depositId !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: depositId must be a string, got ${typeof record.depositId}`);
491
556
  if (record.error !== void 0 && typeof record.error !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: error must be a string, got ${typeof record.error}`);
492
557
  if (record.errorCode !== void 0 && typeof record.errorCode !== "string") throw new s402Error("INVALID_PAYLOAD", `Malformed settle response: errorCode must be a string, got ${typeof record.errorCode}`);
493
558
  }
@@ -605,4 +670,4 @@ function extractRequirementsFromResponse(response) {
605
670
  }
606
671
 
607
672
  //#endregion
608
- export { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateStreamShape, validateSubObjects, validateUnlockShape };
673
+ export { S402_CONTENT_TYPE, decodePayloadBody, decodePaymentPayload, decodePaymentRequired, decodeRequirementsBody, decodeSettleBody, decodeSettleResponse, detectProtocol, detectTransport, encodePayloadBody, encodePaymentPayload, encodePaymentRequired, encodeRequirementsBody, encodeSettleBody, encodeSettleResponse, extractRequirementsFromResponse, isValidAmount, isValidU64Amount, pickPayloadFields, pickRequirementsFields, pickSettleResponseFields, validateEscrowShape, validateMandateShape, validatePrepaidShape, validateRequirementsShape, validateSettlementOverridesShape, validateStreamShape, validateSubObjects, validateUnlockShape, validateUptoShape };
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createS402Error, s402Error, s402ErrorCode, s402ErrorCodeType, s402ErrorInfo } from "./errors.mjs";
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";
2
+ import { S402_HEADERS, S402_VERSION, s402Discovery, s402EscrowExtra, s402EscrowPayload, s402ExactPayload, s402Mandate, s402MandateRequirements, s402PaymentPayload, s402PaymentPayloadBase, s402PaymentRequirements, s402PaymentSession, s402PrepaidExtra, s402PrepaidPayload, s402RegistryQuery, s402Scheme, s402ServiceEntry, s402SettleResponse, s402SettlementMode, s402SettlementOverrides, s402StreamExtra, s402StreamPayload, s402UnlockExtra, s402UnlockPayload, s402UptoExtra, s402UptoPayload, 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
+ import { a as s402ServerScheme, i as s402RouteConfig, n as s402DirectScheme, o as s402SettlementVerification, r as s402FacilitatorScheme, t as s402ClientScheme } from "./scheme-m-uk4zyH.mjs";
5
5
  import { S402_RECEIPT_HEADER, formatReceiptHeader, parseReceiptHeader, s402Receipt, s402ReceiptSigner, s402ReceiptVerifier } from "./receipts.mjs";
6
6
 
7
7
  //#region src/client.d.ts
@@ -58,14 +58,170 @@ declare class s402Client {
58
58
  supports(network: string, scheme: s402Scheme): boolean;
59
59
  }
60
60
  //#endregion
61
+ //#region src/extensions.d.ts
62
+ /**
63
+ * Base extension interface. All three actor-specific interfaces extend this.
64
+ *
65
+ * Extensions use reverse-domain keys (per ADR-001 §4a) to avoid conflicts:
66
+ * e.g., "org.s402.discovery", "com.mycompany.ratelimit".
67
+ */
68
+ interface s402Extension {
69
+ /** Reverse-domain key: e.g., "org.s402.discovery" */
70
+ readonly key: string;
71
+ /** Semver version (per ADR-001 §4b) */
72
+ readonly version: string;
73
+ /**
74
+ * If true, failure in this extension blocks the payment flow.
75
+ * If false, failure is logged but doesn't block (advisory).
76
+ */
77
+ readonly critical: boolean;
78
+ /** Keys of extensions that must run before this one. */
79
+ readonly dependsOn?: string[];
80
+ }
81
+ /**
82
+ * Client-side extension.
83
+ * Hooks fire during payment creation and settlement verification.
84
+ */
85
+ interface s402ClientExtension extends s402Extension {
86
+ /** Enrich the payment payload before sending. */
87
+ enrichPayload?(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402PaymentPayload>;
88
+ /** Process extension data from the settle response. */
89
+ onSettlement?(response: s402SettleResponse, payload: s402PaymentPayload): Promise<void>;
90
+ }
91
+ /**
92
+ * Server-side extension (resource server).
93
+ * Hooks fire during requirements building and settlement response.
94
+ */
95
+ interface s402ServerExtension extends s402Extension {
96
+ /** Enrich payment requirements before sending the 402 response. */
97
+ enrichRequirements?(requirements: s402PaymentRequirements, config: s402RouteConfig): s402PaymentRequirements;
98
+ /** Enrich the settlement response before returning to client. */
99
+ enrichSettleResponse?(response: s402SettleResponse, payload: s402PaymentPayload): Promise<s402SettleResponse>;
100
+ }
101
+ /**
102
+ * Facilitator-side extension.
103
+ * Hooks fire during the verify→settle pipeline.
104
+ *
105
+ * Four hooks covering every phase:
106
+ * beforeVerify → verify() → afterVerify → beforeSettle → settle() → afterSettle
107
+ */
108
+ interface s402FacilitatorExtension extends s402Extension {
109
+ /** Called before verify(). Can reject by throwing. */
110
+ beforeVerify?(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<void>;
111
+ /** Called after successful verify(), before settle(). */
112
+ afterVerify?(payload: s402PaymentPayload, verifyResult: s402VerifyResponse): Promise<void>;
113
+ /** Called before settle(). Last chance to abort. */
114
+ beforeSettle?(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<void>;
115
+ /** Called after successful settle() only (not on settlement failure).
116
+ * Failures here never change the settle result — the tx is already on-chain. */
117
+ afterSettle?(payload: s402PaymentPayload, settleResult: s402SettleResponse): Promise<void>;
118
+ }
119
+ /**
120
+ * Type-safe extension data retrieval.
121
+ *
122
+ * The wire format is `Record<string, unknown>` for interop, but this helper
123
+ * provides typed access for TypeScript consumers.
124
+ *
125
+ * @example
126
+ * ```ts
127
+ * interface DiscoveryData { services: string[] }
128
+ * const data = getExtensionData<DiscoveryData>(requirements.extensions, 'org.s402.discovery');
129
+ * if (data) console.log(data.services);
130
+ * ```
131
+ */
132
+ declare function getExtensionData<T>(extensions: Record<string, unknown> | undefined, key: string): T | undefined;
133
+ /**
134
+ * Set extension data on an extensions record (creates if needed).
135
+ * Returns a new extensions object (does not mutate the input).
136
+ */
137
+ declare function setExtensionData(extensions: Record<string, unknown> | undefined, key: string, data: unknown): Record<string, unknown>;
138
+ /**
139
+ * Registry for extensions with dependency-ordered execution.
140
+ *
141
+ * Extensions are stored by key and sorted topologically based on `dependsOn`.
142
+ * Within the same dependency level, registration order is preserved.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const registry = new s402ExtensionRegistry<s402FacilitatorExtension>();
147
+ * registry.register(rateLimitExtension);
148
+ * registry.register(analyticsExtension);
149
+ * const sorted = registry.sorted(); // dependency-ordered list
150
+ * ```
151
+ */
152
+ declare class s402ExtensionRegistry<T extends s402Extension> {
153
+ private extensions;
154
+ private sortedCache;
155
+ /**
156
+ * Register an extension. Throws on duplicate key or dependency cycle.
157
+ */
158
+ register(ext: T): void;
159
+ /** Get a registered extension by key. */
160
+ get(key: string): T | undefined;
161
+ /** Number of registered extensions. */
162
+ get size(): number;
163
+ /**
164
+ * Return extensions in topological (dependency) order.
165
+ * Cached until a new extension is registered.
166
+ */
167
+ sorted(): T[];
168
+ }
169
+ /** Callback for advisory extension failures. */
170
+ type s402ExtensionErrorHandler = (ext: s402Extension, error: unknown) => void;
171
+ /**
172
+ * Run an async hook on all extensions in order.
173
+ * Critical extensions throw on failure; advisory extensions call the error handler.
174
+ */
175
+ declare function runExtensionHooks<T extends s402Extension>(extensions: T[], hookName: string, runner: (ext: T) => Promise<void>, onError?: s402ExtensionErrorHandler): Promise<void>;
176
+ //#endregion
61
177
  //#region src/facilitator.d.ts
178
+ /**
179
+ * Options for `s402Facilitator.process()`.
180
+ *
181
+ * Callers can tune the verify→settle pipeline per-request.
182
+ */
183
+ interface s402ProcessOptions {
184
+ /**
185
+ * Skip the verify() dry-run and go straight to settle().
186
+ *
187
+ * On chains where failed transactions cost zero gas (e.g., Sui PTBs revert
188
+ * atomically with no gas charge), the dry-run is pure latency overhead — it
189
+ * adds a full RPC round-trip (~200-400ms) just to predict what settle() will
190
+ * discover anyway. Setting `skipVerify: true` eliminates that round-trip.
191
+ *
192
+ * When `true`, process() still performs: expiration checks, scheme-mismatch
193
+ * checks, deduplication, and settle timeout. Only the dry-run is skipped.
194
+ *
195
+ * **Use when:** your chain adapter knows failures are free (Sui, Aptos).
196
+ * **Don't use when:** failed settlements cost gas (EVM L1s) — the dry-run
197
+ * saves real money by catching bad txs before broadcast.
198
+ *
199
+ * @default false
200
+ */
201
+ skipVerify?: boolean;
202
+ }
62
203
  declare class s402Facilitator {
63
204
  private schemes;
64
205
  private inFlight;
206
+ private extensionRegistry;
207
+ private extensionErrorHandler?;
65
208
  /**
66
209
  * Register a scheme-specific facilitator for a network.
67
210
  */
68
211
  register(network: string, scheme: s402FacilitatorScheme): this;
212
+ /**
213
+ * Register a facilitator extension. Extensions fire in dependency order
214
+ * at four points in the process() pipeline: beforeVerify, afterVerify,
215
+ * beforeSettle, afterSettle.
216
+ *
217
+ * @throws {s402Error} `EXTENSION_FAILED` on duplicate key or dependency cycle
218
+ */
219
+ registerExtension(ext: s402FacilitatorExtension): this;
220
+ /**
221
+ * Set the handler for advisory (non-critical) extension failures.
222
+ * Critical extensions always throw; advisory extensions call this handler.
223
+ */
224
+ onExtensionError(handler: s402ExtensionErrorHandler): this;
69
225
  /**
70
226
  * Verify a payment payload by dispatching to the correct scheme.
71
227
  * Includes expiration guard and scheme-mismatch check.
@@ -88,6 +244,7 @@ declare class s402Facilitator {
88
244
  *
89
245
  * @param payload - Client's payment payload
90
246
  * @param requirements - Server's payment requirements
247
+ * @param options - Optional process configuration (e.g., `{ skipVerify: true }` for zero-cost-failure chains)
91
248
  * @returns Settlement result (check `result.success` and `result.errorCode`)
92
249
  *
93
250
  * @example
@@ -105,7 +262,7 @@ declare class s402Facilitator {
105
262
  * }
106
263
  * ```
107
264
  */
108
- process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
265
+ process(payload: s402PaymentPayload, requirements: s402PaymentRequirements, options?: s402ProcessOptions): Promise<s402SettleResponse>;
109
266
  /**
110
267
  * Check if a scheme is supported for a network.
111
268
  */
@@ -185,4 +342,4 @@ declare class s402ResourceServer {
185
342
  process(payload: s402PaymentPayload, requirements: s402PaymentRequirements): Promise<s402SettleResponse>;
186
343
  }
187
344
  //#endregion
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 };
345
+ 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, getExtensionData, isValidAmount, isValidU64Amount, parseReceiptHeader, runExtensionHooks, s402Client, type s402ClientExtension, type s402ClientScheme, type s402DirectScheme, type s402Discovery, s402Error, s402ErrorCode, type s402ErrorCodeType, type s402ErrorInfo, type s402EscrowExtra, type s402EscrowPayload, type s402ExactPayload, type s402Extension, type s402ExtensionErrorHandler, s402ExtensionRegistry, s402Facilitator, type s402FacilitatorExtension, type s402FacilitatorScheme, type s402Mandate, type s402MandateRequirements, type s402PaymentPayload, type s402PaymentPayloadBase, type s402PaymentRequirements, type s402PaymentSession, type s402PrepaidExtra, type s402PrepaidPayload, type s402ProcessOptions, type s402Receipt, type s402ReceiptSigner, type s402ReceiptVerifier, type s402RegistryQuery, s402ResourceServer, type s402RouteConfig, type s402Scheme, type s402ServerExtension, type s402ServerScheme, type s402ServiceEntry, type s402SettleResponse, type s402SettlementMode, type s402SettlementOverrides, type s402SettlementVerification, type s402StreamExtra, type s402StreamPayload, type s402UnlockExtra, type s402UnlockPayload, type s402UptoExtra, type s402UptoPayload, type s402VerifyResponse, setExtensionData, validateRequirementsShape };