mppx 0.13.5 → 0.13.6

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/Credential.d.ts +3 -0
  3. package/dist/Credential.js +9 -0
  4. package/dist/cli/cli.js +131 -131
  5. package/dist/cli/plugins/evm.js +2 -2
  6. package/dist/cli/plugins/index.js +11 -11
  7. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  8. package/dist/client/internal/MethodChallenge.js +4 -0
  9. package/dist/internal/version.d.ts +2 -2
  10. package/dist/internal/version.js +1 -1
  11. package/dist/server/Mppx.js +31 -5
  12. package/dist/stripe/server/Methods.js +4 -1
  13. package/dist/tempo/client/Charge.js +1 -0
  14. package/dist/tempo/internal/auto-swap.d.ts +10 -0
  15. package/dist/tempo/internal/auto-swap.js +35 -8
  16. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  17. package/dist/tempo/internal/fee-payer.js +20 -12
  18. package/dist/tempo/legacy/session/Chain.js +8 -2
  19. package/dist/tempo/server/Methods.js +12 -1
  20. package/dist/tempo/server/Relay.js +6 -22
  21. package/dist/tempo/server/Subscription.js +3 -0
  22. package/dist/tempo/server/internal/html.gen.js +1 -1
  23. package/dist/tempo/server/internal/transport.d.ts +3 -0
  24. package/dist/tempo/server/internal/transport.js +5 -1
  25. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  26. package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
  27. package/dist/tempo/session/client/ChannelStore.js +47 -0
  28. package/dist/tempo/session/client/CredentialState.js +8 -3
  29. package/dist/tempo/session/client/Session.js +50 -33
  30. package/dist/tempo/session/client/SessionManager.js +186 -65
  31. package/dist/tempo/session/client/Transports.d.ts +6 -0
  32. package/dist/tempo/session/client/Transports.js +52 -7
  33. package/dist/tempo/session/precompile/Chain.js +5 -2
  34. package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
  35. package/dist/tempo/session/server/ChannelStore.js +69 -9
  36. package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
  37. package/dist/tempo/session/server/CredentialVerification.js +220 -48
  38. package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
  39. package/dist/tempo/session/server/MeteredStream.js +10 -26
  40. package/dist/tempo/session/server/Session.d.ts +29 -2
  41. package/dist/tempo/session/server/Session.js +114 -33
  42. package/dist/tempo/session/server/Settlement.d.ts +9 -0
  43. package/dist/tempo/session/server/Settlement.js +12 -0
  44. package/dist/tempo/session/server/Sse.d.ts +4 -3
  45. package/dist/tempo/session/server/Sse.js +3 -3
  46. package/dist/tempo/session/server/Ws.d.ts +9 -1
  47. package/dist/tempo/session/server/Ws.js +1 -0
  48. package/package.json +1 -1
@@ -5,12 +5,11 @@ import * as ChannelStore from './ChannelStore.js';
5
5
  /** Controller passed to manual-charge streaming generators. */
6
6
  export type SessionController = {
7
7
  /**
8
- * Reserve voucher coverage for the next emitted chunk.
8
+ * Commit a charge before performing billable work.
9
9
  *
10
- * The reservation blocks until sufficient voucher headroom exists, but the
11
- * charge is committed when a chunk is emitted or when the generator finishes
12
- * successfully. A reservation is only dropped when the stream aborts or the
13
- * generator fails before the charge can be committed.
10
+ * Waits for sufficient voucher coverage and commits the charge before resolving.
11
+ * Once resolved, the charge is retained even if the stream is canceled or the
12
+ * generator fails. Await this method before starting the corresponding work.
14
13
  *
15
14
  * Pass an explicit raw-unit `amount` for request-aware or otherwise dynamic
16
15
  * pricing. When omitted, the session challenge's configured tick cost is
@@ -32,6 +31,8 @@ export type MeteredStreamOptions = {
32
31
  formatNeedVoucher(parameters: NeedVoucherEvent): string;
33
32
  /** Async source or manual-charge source. */
34
33
  generate: SessionStreamGenerator;
34
+ /** Verifies that the channel can still fund work and delivery; failures stop the stream. */
35
+ beforeCharge?: (() => MaybePromise<void>) | undefined;
35
36
  /** Runs after a nonzero reserved charge has been committed. */
36
37
  onChargeCommitted?: ((channel: ChannelStore.State) => MaybePromise<unknown>) | undefined;
37
38
  /** Store polling interval when `waitForUpdate` is unavailable. */
@@ -1,38 +1,24 @@
1
- import { commitReservedCharges, releaseReservedCharges, renewReservedCharges, reserveChargeOrWait, streamReservationLeaseMs, } from './Transports.js';
1
+ import { commitReservedCharges, releaseReservedCharges, reserveChargeOrWait } from './Transports.js';
2
2
  /** Applies voucher reservation and spend commits to an async session stream. */
3
3
  export async function* meterIterable(options) {
4
+ const manual = typeof options.generate === 'function';
4
5
  let prepaidUnits = options.prepaidUnits ?? 0;
5
- const controller = new AbortController();
6
- const signal = controller.signal;
6
+ const signal = options.signal ?? new AbortController().signal;
7
7
  const reservation = {
8
8
  store: options.store,
9
9
  channelId: options.channelId,
10
10
  reservationId: globalThis.crypto.randomUUID(),
11
11
  };
12
12
  let pending = false;
13
- let cancelled = false;
14
- const onAbort = () => {
15
- if (signal.aborted)
16
- return;
17
- cancelled = true;
18
- controller.abort(options.signal?.reason);
19
- };
20
- options.signal?.addEventListener('abort', onAbort, { once: true });
21
- if (options.signal?.aborted)
22
- onAbort();
23
- const renewal = setInterval(() => {
24
- if (pending)
25
- void renewReservedCharges(reservation).catch((error) => controller.abort(error));
26
- }, streamReservationLeaseMs / 3);
27
- renewal.unref?.();
28
13
  const releaseOnAbort = () => {
29
- clearInterval(renewal);
30
14
  void releaseReservedCharges(reservation).catch(() => { });
31
15
  };
32
16
  signal.addEventListener('abort', releaseOnAbort, { once: true });
33
17
  if (signal.aborted)
34
18
  releaseOnAbort();
35
19
  const charge = async (amount) => {
20
+ signal.throwIfAborted();
21
+ await options.beforeCharge?.();
36
22
  signal.throwIfAborted();
37
23
  if (amount === undefined && prepaidUnits > 0) {
38
24
  prepaidUnits -= 1;
@@ -50,9 +36,13 @@ export async function* meterIterable(options) {
50
36
  signal,
51
37
  });
52
38
  pending = true;
39
+ if (manual)
40
+ await commit();
53
41
  signal.throwIfAborted();
54
42
  };
55
43
  const commit = async () => {
44
+ signal.throwIfAborted();
45
+ await options.beforeCharge?.();
56
46
  signal.throwIfAborted();
57
47
  if (!pending)
58
48
  return;
@@ -63,7 +53,6 @@ export async function* meterIterable(options) {
63
53
  await options.onChargeCommitted?.(channel);
64
54
  };
65
55
  try {
66
- const manual = typeof options.generate === 'function';
67
56
  const iterable = typeof options.generate === 'function'
68
57
  ? options.generate({ charge, signal })
69
58
  : options.generate;
@@ -74,7 +63,7 @@ export async function* meterIterable(options) {
74
63
  if (signal.aborted)
75
64
  break;
76
65
  if (done) {
77
- if (manual)
66
+ if (manual && pending)
78
67
  await commit();
79
68
  break;
80
69
  }
@@ -83,18 +72,13 @@ export async function* meterIterable(options) {
83
72
  await commit();
84
73
  yield value;
85
74
  }
86
- if (!cancelled)
87
- signal.throwIfAborted();
88
75
  }
89
76
  finally {
90
- clearInterval(renewal);
91
77
  await releaseReservedCharges(reservation);
92
78
  await iterator.return?.();
93
79
  }
94
80
  }
95
81
  finally {
96
- clearInterval(renewal);
97
- options.signal?.removeEventListener('abort', onAbort);
98
82
  signal.removeEventListener('abort', releaseOnAbort);
99
83
  await releaseReservedCharges(reservation);
100
84
  }
@@ -21,6 +21,8 @@ import { type ResolveSessionChannelId } from './RequestState.js';
21
21
  import { type SettleChargedSessionChannel } from './Settlement.js';
22
22
  import { type OnSessionSettlement, type OnSessionSettlementFailure, type SettlementSchedule } from './Settlement.js';
23
23
  import * as Ws from './Ws.js';
24
+ /** @internal Reuses a method's escrow verifier when composing shared currency helpers. */
25
+ export declare function verifyStream(method: object, channelId: Hex): Promise<void>;
24
26
  /** Server-side automatic settlement schedule. */
25
27
  export type { SettlementSchedule } from './Settlement.js';
26
28
  /** Server-side settlement event hook types. */
@@ -131,15 +133,27 @@ export declare namespace session {
131
133
  serveWebSocket: (options: ServeWebSocketOptions) => Promise<void>;
132
134
  };
133
135
  /** WebSocket server options supplied after the session binds shared dependencies. */
134
- type ServeWebSocketOptions = Omit<Ws.serve.Options, 'onChargeCommitted' | 'settleScheduled' | 'store'>;
136
+ type ServeWebSocketOptions = Omit<Ws.serve.Options, 'beforeCharge' | 'onChargeCommitted' | 'settleScheduled' | 'store'>;
135
137
  /** Request defaults inferred from the Tempo session method schema. */
136
138
  type Defaults = LooseOmit<Method.RequestDefaults<typeof Methods.session>, 'escrowContract' | 'feePayer' | 'recipient'>;
137
139
  /** Partial fee-sponsor policy used for server-submitted session transactions. */
138
140
  type FeePayerPolicy = Partial<FeePayer.Policy>;
139
141
  /** Parameters accepted by the TIP-1034 server session payment method. */
140
142
  type Parameters = {
141
- /** TTL in milliseconds for cached on-chain channel state during non-mutating validation. Voucher acceptance always refreshes on-chain state. @default 5_000 */
143
+ /** TTL in milliseconds for cached on-chain channel state during non-mutating validation, and before a voucher accepted under `voucherStateMaxAge` starts a fresh read. @default 5_000 */
142
144
  channelStateTtl?: number | undefined;
145
+ /**
146
+ * Maximum age in milliseconds of verified on-chain channel state that a voucher may be validated
147
+ * and accepted against without waiting for a fresh read. The fresh read then finishes in the background and
148
+ * records a pending close, so later vouchers are rejected. `0` reads on-chain state before every
149
+ * acceptance.
150
+ *
151
+ * A payer can withdraw only after the escrow's `CLOSE_GRACE_PERIOD` (900 s on Tempo) following a
152
+ * close request, so vouchers accepted this way stay settleable as long as the payee settles them
153
+ * within the grace period minus this age. Keep it well below that bound.
154
+ * @default 0
155
+ */
156
+ voucherStateMaxAge?: number | undefined;
143
157
  /** Override the fee-sponsor policy used for sponsored open/topUp transactions and server-driven close transactions. */
144
158
  feePayerPolicy?: FeePayerPolicy | undefined;
145
159
  /** Minimum voucher delta to accept (numeric string, default: "0"). */
@@ -159,6 +173,19 @@ export declare namespace session {
159
173
  * containing the signed highest voucher for client rehydration.
160
174
  */
161
175
  bootstrap?: boolean | undefined;
176
+ /**
177
+ * Lets a session challenge pay for later requests to the same route until it expires. The
178
+ * challenge is bound to its route, currency, recipient and channel terms but not to the amount
179
+ * it quoted, and every request is charged its own price from the channel's accepted balance.
180
+ * A client can then send a voucher with a request instead of waiting for a `402`.
181
+ */
182
+ reusableChallenges?: boolean | undefined;
183
+ /**
184
+ * Charges request-priced content (`unitType: 'request'`) in the same store update that accepts
185
+ * its voucher, before the response, instead of in a second update when the response is
186
+ * delivered. The server must then refund requests it cannot serve.
187
+ */
188
+ chargeOnAcceptance?: boolean | undefined;
162
189
  /**
163
190
  * Atomic store backend for channel state.
164
191
  *
@@ -20,17 +20,26 @@ import * as Methods from '../../Methods.js';
20
20
  import * as ChargeServer from '../../server/Charge.js';
21
21
  import { fallbackStore } from '../../server/internal/fallback-store.js';
22
22
  import * as Transport from '../../server/internal/transport.js';
23
+ import * as Chain from '../precompile/Chain.js';
23
24
  import { tip20ChannelEscrow } from '../precompile/Protocol.js';
24
25
  import { deserializeSnapshot as deserializeSessionSnapshot, serializeSnapshot as serializeSessionSnapshot, } from '../Snapshot.js';
25
26
  import * as ChannelStore from './ChannelStore.js';
26
27
  import { broadcastCredentialPayload, validateCredentialPayload } from './CredentialVerification.js';
27
- import { requireSessionCredentialPayload } from './CredentialVerification.js';
28
+ import { requireSessionCredentialPayload, validateChannelState } from './CredentialVerification.js';
28
29
  import { resolveCredentialVerificationContext, resolveSessionChannelId, resolveSessionSnapshot, resolveSessionPaymentRequest, } from './RequestState.js';
29
30
  import { respondToSessionCredential } from './RequestState.js';
30
- import { applyVerifiedHttpAccounting, chargeSessionChannel, shouldApplyVerifiedHttpAccounting, } from './Settlement.js';
31
+ import { applyVerifiedHttpAccounting, chargeSessionChannel, shouldApplyVerifiedHttpAccounting, shouldChargeVoucherOnAcceptance, } from './Settlement.js';
31
32
  import { ignoreRetryableSettlementFailure, isSettlementDue, maybeSettleScheduled, reportSessionSettlementFailure, } from './Settlement.js';
32
33
  import { resolveSettlementSchedule, } from './Settlement.js';
33
34
  import * as Ws from './Ws.js';
35
+ const streamVerifiers = new WeakMap();
36
+ /** @internal Reuses a method's escrow verifier when composing shared currency helpers. */
37
+ export function verifyStream(method, channelId) {
38
+ const verify = streamVerifiers.get(method);
39
+ if (!verify)
40
+ throw new Error('Session stream verifier is not configured');
41
+ return verify(channelId);
42
+ }
34
43
  export { settle, settleBatch } from './Settlement.js';
35
44
  function deriveServerDefaults(values) {
36
45
  // `Method.toServer()` models defaults from request input fields. Tempo session
@@ -191,6 +200,19 @@ async function handleBootstrapPreflight(parameters) {
191
200
  }
192
201
  return new Response(null, { status: 204, headers });
193
202
  }
203
+ /** Binds a reusable session challenge to its route and channel terms but not to its quoted amount. */
204
+ const reusableSessionBinding = (request) => {
205
+ const { methodDetails } = request;
206
+ return {
207
+ chainId: methodDetails.chainId,
208
+ currency: request.currency,
209
+ escrowContract: methodDetails.escrowContract,
210
+ operator: methodDetails.operator,
211
+ recipient: request.recipient,
212
+ sessionProtocol: methodDetails[Constants.MethodDetailKeys.sessionProtocol],
213
+ unitType: request.unitType,
214
+ };
215
+ };
194
216
  /** Creates a server-side TIP20EscrowChannel precompile session payment method. */
195
217
  export function session(p) {
196
218
  const parameters = p;
@@ -236,8 +258,33 @@ export function session(p) {
236
258
  // A failed scheduled settlement is reported and retried by the next one; the charged request
237
259
  // stays served because the payer's voucher already covers the charge.
238
260
  const settleCharged = (channel) => settleScheduled(channel).catch(ignoreRetryableSettlementFailure);
261
+ // Stream checks have a bounded TTL even when callers disable validation caching.
262
+ // Cache only successful reads; RPC failures must stop delivery.
263
+ const beforeStreamCharge = async (channelId) => {
264
+ channelId = ChannelStore.normalizeChannelId(channelId);
265
+ const channel = await store.getChannel(channelId);
266
+ if (!channel || channel.finalized || channel.closeRequestedAt !== 0n)
267
+ throw new Errors.ChannelClosedError({ reason: 'stream channel is closed' });
268
+ const now = Date.now();
269
+ const ttl = Math.min(Math.max(channelStateTtl, 0), 5_000);
270
+ const lastVerified = lastOnChainVerified.get(channelId);
271
+ if (lastVerified !== undefined && now >= lastVerified && now - lastVerified < ttl)
272
+ return;
273
+ let state;
274
+ try {
275
+ const client = await getClient({ chainId: channel.chainId });
276
+ state = await Chain.getChannelState(client, channelId, channel.escrowContract);
277
+ }
278
+ catch {
279
+ // Transport errors can include private RPC URLs or authentication details.
280
+ throw new Error('Unable to verify stream escrow');
281
+ }
282
+ validateChannelState(state);
283
+ lastOnChainVerified.set(channelId, now);
284
+ };
239
285
  const serveWebSocket = (options) => Ws.serve({
240
286
  ...options,
287
+ beforeCharge: beforeStreamCharge,
241
288
  onChargeCommitted: settleCharged,
242
289
  store,
243
290
  });
@@ -253,9 +300,11 @@ export function session(p) {
253
300
  });
254
301
  const transport = parameters.sse
255
302
  ? Transport.sse({
303
+ ...(typeof parameters.sse === 'object' ? parameters.sse : {}),
304
+ beforeCharge: beforeStreamCharge,
305
+ reusableChallenges: parameters.reusableChallenges,
256
306
  settleCharged,
257
307
  store,
258
- ...(typeof parameters.sse === 'object' ? parameters.sse : undefined),
259
308
  })
260
309
  : undefined;
261
310
  const validateCredential = async ({ credential, operation, request, }) => {
@@ -267,24 +316,31 @@ export function session(p) {
267
316
  minVoucherDelta: parameters.minVoucherDelta,
268
317
  request,
269
318
  });
270
- const details = await validateCredentialPayload({
271
- account,
272
- allowPendingClose: operation === 'broadcast',
273
- challenge: credential.challenge,
274
- channelStateTtl,
275
- chainId: context.chainId,
276
- client: context.client,
277
- credentialSource: credential.source,
278
- escrow: context.escrow,
279
- expectedOperator: context.methodDetails.operator,
280
- feePayer: context.feePayer,
281
- feePayerPolicy: parameters.feePayerPolicy,
282
- feeToken: context.methodDetails.feeToken,
283
- lastOnChainVerified,
284
- minVoucherDelta: context.minVoucherDelta,
285
- payload,
286
- store,
287
- });
319
+ // A broadcast voucher is checked against the stored channel by the update that accepts it.
320
+ const details = operation === 'broadcast' && payload.action === 'voucher'
321
+ ? {
322
+ action: payload.action,
323
+ channelId: ChannelStore.normalizeChannelId(payload.channelId),
324
+ }
325
+ : await validateCredentialPayload({
326
+ account,
327
+ allowPendingClose: operation === 'broadcast',
328
+ challenge: credential.challenge,
329
+ channelStateTtl,
330
+ chainId: context.chainId,
331
+ client: context.client,
332
+ credentialSource: credential.source,
333
+ escrow: context.escrow,
334
+ expectedOperator: context.methodDetails.operator,
335
+ feePayer: context.feePayer,
336
+ feePayerPolicy: parameters.feePayerPolicy,
337
+ feeToken: context.methodDetails.feeToken,
338
+ lastOnChainVerified,
339
+ minVoucherDelta: context.minVoucherDelta,
340
+ payload,
341
+ store,
342
+ voucherStateMaxAge: parameters.voucherStateMaxAge,
343
+ });
288
344
  return {
289
345
  challenge: credential.challenge,
290
346
  credential: { ...credential, payload },
@@ -305,9 +361,34 @@ export function session(p) {
305
361
  minVoucherDelta: parameters.minVoucherDelta,
306
362
  request,
307
363
  });
364
+ const chargeAmount = parameters.chargeOnAcceptance &&
365
+ shouldChargeVoucherOnAcceptance({
366
+ capturedRequest: envelope?.capturedRequest,
367
+ payloadAction: payload.action,
368
+ unitType: context.request.unitType,
369
+ })
370
+ ? BigInt(context.request.amount ?? challenge.request.amount)
371
+ : undefined;
372
+ const acceptance = {};
373
+ const settleAcceptedCharge = (channel) => maybeSettleScheduled({
374
+ account,
375
+ client: context.client,
376
+ ...(context.feePayer ? { feePayer: context.feePayer } : {}),
377
+ feePayerPolicy: parameters.feePayerPolicy,
378
+ feeToken: parameters.feeToken,
379
+ onSessionSettlement,
380
+ onSessionSettlementFailure,
381
+ schedule: settlementSchedule,
382
+ store,
383
+ channel,
384
+ }).catch(ignoreRetryableSettlementFailure);
308
385
  const sessionReceipt = await broadcastCredentialPayload({
309
386
  account,
310
387
  challenge,
388
+ chargeAmount,
389
+ onCharged: (channel) => {
390
+ acceptance.charged = channel;
391
+ },
311
392
  channelStateTtl,
312
393
  chainId: context.chainId,
313
394
  client: context.client,
@@ -328,7 +409,16 @@ export function session(p) {
328
409
  sseEnabled: Boolean(parameters.sse),
329
410
  }),
330
411
  store,
412
+ voucherStateMaxAge: parameters.voucherStateMaxAge,
331
413
  });
414
+ if (acceptance.charged) {
415
+ const settlementTxHash = await settleAcceptedCharge(acceptance.charged);
416
+ const receipt = {
417
+ ...sessionReceipt,
418
+ ...(settlementTxHash ? { txHash: settlementTxHash } : {}),
419
+ };
420
+ return parameters.sse ? Transport.markPrepaidSessionTick(receipt) : receipt;
421
+ }
332
422
  return applyVerifiedHttpAccounting({
333
423
  capturedRequest: envelope?.capturedRequest,
334
424
  payloadAction: payload.action,
@@ -337,18 +427,7 @@ export function session(p) {
337
427
  sseEnabled: Boolean(parameters.sse),
338
428
  markPrepaidReceipt: Transport.markPrepaidSessionTick,
339
429
  charge: (channelId, requestAmount) => chargeSessionChannel({ store, channelId, amount: requestAmount }),
340
- settleCharged: (channel) => maybeSettleScheduled({
341
- account,
342
- client: context.client,
343
- ...(context.feePayer ? { feePayer: context.feePayer } : {}),
344
- feePayerPolicy: parameters.feePayerPolicy,
345
- feeToken: parameters.feeToken,
346
- onSessionSettlement,
347
- onSessionSettlementFailure,
348
- schedule: settlementSchedule,
349
- store,
350
- channel,
351
- }).catch(ignoreRetryableSettlementFailure),
430
+ settleCharged: settleAcceptedCharge,
352
431
  });
353
432
  };
354
433
  const method = Method.toServer(Methods.session, {
@@ -364,6 +443,7 @@ export function session(p) {
364
443
  unitType,
365
444
  }),
366
445
  extensions: { serveWebSocket, settleScheduled },
446
+ ...(parameters.reusableChallenges ? { stableBinding: reusableSessionBinding } : {}),
367
447
  transport: deriveTransport(transport),
368
448
  preflight: parameters.bootstrap
369
449
  ? async ({ capturedRequest, credential, expires, input, options, realm, secretKey }) => {
@@ -430,6 +510,7 @@ export function session(p) {
430
510
  });
431
511
  },
432
512
  });
513
+ streamVerifiers.set(method, beforeStreamCharge);
433
514
  return method;
434
515
  }
435
516
  (function (session) {
@@ -174,6 +174,15 @@ export type ApplyVerifiedHttpAccountingParameters = {
174
174
  };
175
175
  /** Returns whether an accepted credential will authorize and charge an HTTP content response. */
176
176
  export declare function shouldApplyVerifiedHttpAccounting(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction' | 'sseEnabled'>): boolean;
177
+ /**
178
+ * Returns whether a voucher pays for its request in the store update that accepts it. A
179
+ * request-priced content request is charged exactly once, so this is the charge the HTTP
180
+ * accounting or the streamed response would make, without a second store update; the receipt is
181
+ * then marked prepaid so an SSE response does not charge again.
182
+ */
183
+ export declare function shouldChargeVoucherOnAcceptance(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction'> & {
184
+ unitType: unknown;
185
+ }): boolean;
177
186
  /** Applies the default HTTP content charge after a session credential has been accepted. */
178
187
  export declare function applyVerifiedHttpAccounting(parameters: ApplyVerifiedHttpAccountingParameters): Promise<SessionReceipt>;
179
188
  /** Atomically deducts spend from a channel and maps store failures to typed session errors. */
@@ -171,6 +171,18 @@ export function shouldApplyVerifiedHttpAccounting(parameters) {
171
171
  return false;
172
172
  return isSessionContentRequest(capturedRequest);
173
173
  }
174
+ /**
175
+ * Returns whether a voucher pays for its request in the store update that accepts it. A
176
+ * request-priced content request is charged exactly once, so this is the charge the HTTP
177
+ * accounting or the streamed response would make, without a second store update; the receipt is
178
+ * then marked prepaid so an SSE response does not charge again.
179
+ */
180
+ export function shouldChargeVoucherOnAcceptance(parameters) {
181
+ const { capturedRequest, payloadAction, unitType } = parameters;
182
+ if (!capturedRequest || payloadAction !== 'voucher' || unitType !== 'request')
183
+ return false;
184
+ return isSessionContentRequest(capturedRequest);
185
+ }
174
186
  /** Applies the default HTTP content charge after a session credential has been accepted. */
175
187
  export async function applyVerifiedHttpAccounting(parameters) {
176
188
  const { receipt, sseEnabled } = parameters;
@@ -27,9 +27,8 @@ export type { SessionController } from './MeteredStream.js';
27
27
  * 2. If balance is sufficient, emits `event: message` with the value.
28
28
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
29
29
  * and polls store until the client tops up the channel.
30
- * 4. Commits the reserved charge immediately before the chunk is emitted. If a
31
- * manual generator completes after charging without yielding another chunk,
32
- * commits that terminal charge on successful completion.
30
+ * 4. Commits automatic charges before emission and manual charges before
31
+ * `stream.charge()` resolves, retaining them on cancellation or failure.
33
32
  * 5. On generator completion, emits a final `event: payment-receipt`.
34
33
  *
35
34
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
@@ -43,6 +42,8 @@ export declare namespace serve {
43
42
  challengeId: string;
44
43
  tickCost: bigint;
45
44
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
45
+ /** Verifies live escrow before charging and delivering stream items. */
46
+ beforeCharge?: MeteredStreamOptions['beforeCharge'];
46
47
  /** Callback invoked after each nonzero stream charge is committed. */
47
48
  onChargeCommitted?: MeteredStreamOptions['onChargeCommitted'];
48
49
  pollIntervalMs?: number | undefined;
@@ -26,9 +26,8 @@ export { extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptE
26
26
  * 2. If balance is sufficient, emits `event: message` with the value.
27
27
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
28
28
  * and polls store until the client tops up the channel.
29
- * 4. Commits the reserved charge immediately before the chunk is emitted. If a
30
- * manual generator completes after charging without yielding another chunk,
31
- * commits that terminal charge on successful completion.
29
+ * 4. Commits automatic charges before emission and manual charges before
30
+ * `stream.charge()` resolves, retaining them on cancellation or failure.
32
31
  * 5. On generator completion, emits a final `event: payment-receipt`.
33
32
  *
34
33
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
@@ -44,6 +43,7 @@ export function serve(options) {
44
43
  channelId,
45
44
  tickCost,
46
45
  generate,
46
+ beforeCharge: options.beforeCharge,
47
47
  onChargeCommitted: options.onChargeCommitted,
48
48
  pollIntervalMs,
49
49
  prepaidUnits: options.prepaidUnits,
@@ -1,3 +1,4 @@
1
+ import { type SessionCredentialContext } from '../precompile/Protocol.js';
1
2
  import * as ChannelStore from './ChannelStore.js';
2
3
  import type { MeteredStreamOptions, SessionController } from './MeteredStream.js';
3
4
  import type { SettleChargedSessionChannel } from './Settlement.js';
@@ -16,6 +17,11 @@ export type SessionRouteResult = {
16
17
  };
17
18
  /** HTTP route used to perform a WebSocket payment probe. */
18
19
  export type SessionRoute = (request: Request) => Promise<SessionRouteResult>;
20
+ type StreamContext = {
21
+ challengeId: string;
22
+ channelId: SessionCredentialContext['channelId'];
23
+ tickCost: bigint;
24
+ };
19
25
  /**
20
26
  * Bridge a WebSocket connection to a Tempo session payment flow.
21
27
  *
@@ -47,7 +53,9 @@ export declare namespace serve {
47
53
  /** Payment route handler. Receives synthetic `POST` requests with only
48
54
  * the `Authorization` header — no cookies, bodies, or upgrade headers. */
49
55
  route: SessionRoute;
50
- /** Optional low-level hook invoked after each committed stream charge. */
56
+ /** Verifies live escrow before charging and delivering stream items. */
57
+ beforeCharge?: ((channelId: StreamContext['channelId']) => void | Promise<void>) | undefined;
58
+ /** Callback invoked after each nonzero stream charge is committed. */
51
59
  onChargeCommitted?: MeteredStreamOptions['onChargeCommitted'];
52
60
  /** @deprecated Use `onChargeCommitted`. */
53
61
  settleScheduled?: SettleChargedSessionChannel | undefined;
@@ -65,6 +65,7 @@ export async function serve(options) {
65
65
  channelId: context.channelId,
66
66
  tickCost: context.tickCost,
67
67
  generate,
68
+ beforeCharge: () => options.beforeCharge?.(context.channelId),
68
69
  onChargeCommitted: options.onChargeCommitted ?? options.settleScheduled,
69
70
  pollIntervalMs,
70
71
  signal: abortController.signal,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.13.5",
4
+ "version": "0.13.6",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",