mppx 0.6.12 → 0.6.14

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 (65) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  3. package/dist/cli/plugins/tempo.js +24 -1
  4. package/dist/cli/plugins/tempo.js.map +1 -1
  5. package/dist/middlewares/express.d.ts.map +1 -1
  6. package/dist/middlewares/express.js +22 -0
  7. package/dist/middlewares/express.js.map +1 -1
  8. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  9. package/dist/tempo/client/SessionManager.js +26 -1
  10. package/dist/tempo/client/SessionManager.js.map +1 -1
  11. package/dist/tempo/internal/fee-payer.d.ts +11 -1
  12. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  13. package/dist/tempo/internal/fee-payer.js +71 -6
  14. package/dist/tempo/internal/fee-payer.js.map +1 -1
  15. package/dist/tempo/server/Charge.d.ts.map +1 -1
  16. package/dist/tempo/server/Charge.js +53 -10
  17. package/dist/tempo/server/Charge.js.map +1 -1
  18. package/dist/tempo/server/Session.d.ts.map +1 -1
  19. package/dist/tempo/server/Session.js +80 -29
  20. package/dist/tempo/server/Session.js.map +1 -1
  21. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  22. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  25. package/dist/tempo/server/internal/request-body.d.ts +1 -1
  26. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  27. package/dist/tempo/server/internal/request-body.js +3 -0
  28. package/dist/tempo/server/internal/request-body.js.map +1 -1
  29. package/dist/tempo/server/internal/transport.d.ts +7 -0
  30. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  31. package/dist/tempo/server/internal/transport.js +16 -0
  32. package/dist/tempo/server/internal/transport.js.map +1 -1
  33. package/dist/tempo/session/Chain.d.ts +1 -0
  34. package/dist/tempo/session/Chain.d.ts.map +1 -1
  35. package/dist/tempo/session/Chain.js +28 -11
  36. package/dist/tempo/session/Chain.js.map +1 -1
  37. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  38. package/dist/tempo/session/ChannelStore.js +6 -0
  39. package/dist/tempo/session/ChannelStore.js.map +1 -1
  40. package/dist/tempo/session/Sse.d.ts +1 -0
  41. package/dist/tempo/session/Sse.d.ts.map +1 -1
  42. package/dist/tempo/session/Sse.js +34 -12
  43. package/dist/tempo/session/Sse.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/plugins/tempo.ts +28 -1
  46. package/src/middlewares/express.test.ts +27 -0
  47. package/src/middlewares/express.ts +24 -0
  48. package/src/tempo/client/SessionManager.ts +26 -1
  49. package/src/tempo/internal/fee-payer.test.ts +139 -0
  50. package/src/tempo/internal/fee-payer.ts +85 -6
  51. package/src/tempo/server/Charge.test.ts +119 -0
  52. package/src/tempo/server/Charge.ts +70 -10
  53. package/src/tempo/server/Session.test.ts +327 -0
  54. package/src/tempo/server/Session.ts +91 -39
  55. package/src/tempo/server/internal/html.gen.ts +1 -1
  56. package/src/tempo/server/internal/request-body.test.ts +26 -0
  57. package/src/tempo/server/internal/request-body.ts +4 -1
  58. package/src/tempo/server/internal/transport.test.ts +28 -2
  59. package/src/tempo/server/internal/transport.ts +23 -0
  60. package/src/tempo/session/Chain.test.ts +140 -1
  61. package/src/tempo/session/Chain.ts +34 -10
  62. package/src/tempo/session/ChannelStore.test.ts +21 -0
  63. package/src/tempo/session/ChannelStore.ts +6 -0
  64. package/src/tempo/session/Sse.test.ts +52 -0
  65. package/src/tempo/session/Sse.ts +22 -2
@@ -82,6 +82,17 @@ describe('request-body', () => {
82
82
  }),
83
83
  ).toBe(false)
84
84
  })
85
+
86
+ test('treats bodyless POST requests with query parameters as content requests', () => {
87
+ expect(
88
+ isSessionContentRequest({
89
+ hasBody: false,
90
+ headers: new Headers(),
91
+ method: 'POST',
92
+ url: new URL('https://api.example.com/search?q=paid'),
93
+ }),
94
+ ).toBe(true)
95
+ })
85
96
  })
86
97
 
87
98
  describe('shouldChargePlainResponse', () => {
@@ -121,6 +132,20 @@ describe('request-body', () => {
121
132
  ),
122
133
  ).toBe(false)
123
134
  })
135
+
136
+ test('charges bodyless POST query requests', () => {
137
+ expect(
138
+ shouldChargePlainResponse(
139
+ {
140
+ hasBody: false,
141
+ headers: new Headers(),
142
+ method: 'POST',
143
+ url: new URL('https://api.example.com/search?q=paid'),
144
+ },
145
+ { action: 'voucher' },
146
+ ),
147
+ ).toBe(true)
148
+ })
124
149
  })
125
150
 
126
151
  describe('captureRequestBodyProbe', () => {
@@ -136,6 +161,7 @@ describe('request-body', () => {
136
161
  headers: request.headers,
137
162
  hasBody: true,
138
163
  method: 'POST',
164
+ url: new URL('https://example.com/'),
139
165
  })
140
166
  })
141
167
  })
@@ -1,13 +1,15 @@
1
1
  import type * as Method from '../../../Method.js'
2
2
  import type { SessionCredentialPayload } from '../../session/Types.js'
3
3
 
4
- export type RequestBodyProbe = Pick<Method.CapturedRequest, 'headers' | 'hasBody' | 'method'>
4
+ export type RequestBodyProbe = Pick<Method.CapturedRequest, 'headers' | 'hasBody' | 'method'> &
5
+ Partial<Pick<Method.CapturedRequest, 'url'>>
5
6
 
6
7
  export function captureRequestBodyProbe(input: Request): RequestBodyProbe {
7
8
  return {
8
9
  headers: input.headers,
9
10
  hasBody: input.body !== null,
10
11
  method: input.method,
12
+ url: new URL(input.url),
11
13
  }
12
14
  }
13
15
 
@@ -25,6 +27,7 @@ export function hasCapturedRequestBody(
25
27
  export function isSessionContentRequest(input: RequestBodyProbe): boolean {
26
28
  if (input.method === 'HEAD') return false
27
29
  if (input.method !== 'POST') return true
30
+ if (input.url?.search) return true
28
31
  return hasCapturedRequestBody(input)
29
32
  }
30
33
 
@@ -7,7 +7,8 @@ import { chainId, escrowContract as escrowContractDefaults } from '../../interna
7
7
  import * as ChannelStore from '../../session/ChannelStore.js'
8
8
  import { deserializeSessionReceipt } from '../../session/Receipt.js'
9
9
  import { parseEvent } from '../../session/Sse.js'
10
- import { sse } from './transport.js'
10
+ import type { SessionReceipt } from '../../session/Types.js'
11
+ import { markPrepaidSessionTick, sse } from './transport.js'
11
12
 
12
13
  const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
13
14
  const challengeId = 'challenge-1'
@@ -129,7 +130,7 @@ type ReceiptOverrides = Partial<{
129
130
  units: number
130
131
  }>
131
132
 
132
- function makeReceipt(overrides: ReceiptOverrides = {}) {
133
+ function makeReceipt(overrides: ReceiptOverrides = {}): SessionReceipt {
133
134
  return {
134
135
  method: 'tempo',
135
136
  intent: 'session' as const,
@@ -666,6 +667,31 @@ describe('sse transport', () => {
666
667
  expect(response.headers.get('Payment-Receipt')).toBeTruthy()
667
668
  })
668
669
 
670
+ test('respondReceipt with prepaid plain Response does not deduct again', async () => {
671
+ const store = memoryStore()
672
+ await seedChannel(store, 10000000n)
673
+ const transport = sse({ store })
674
+
675
+ const response = transport.respondReceipt({
676
+ credential: makeCredential(),
677
+ input: makeAuthorizedRequest(),
678
+ receipt: markPrepaidSessionTick(makeReceipt({ spent: '1000000', units: 1 })),
679
+ response: new Response('ok', {
680
+ headers: { 'Content-Type': 'application/json' },
681
+ }),
682
+ challengeId,
683
+ })
684
+
685
+ expect(await response.text()).toBe('ok')
686
+ const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
687
+ const channel = await store.getChannel(channelId)
688
+
689
+ expect(channel!.spent).toBe(0n)
690
+ expect(channel!.units).toBe(0)
691
+ expect(receipt.spent).toBe('1000000')
692
+ expect(receipt.units).toBe(1)
693
+ })
694
+
669
695
  test('respondReceipt no longer depends on prior getCredential side effects', async () => {
670
696
  const store = memoryStore()
671
697
  await seedChannel(store, 10000000n)
@@ -13,9 +13,28 @@ import * as Sse_core from '../../session/Sse.js'
13
13
  import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js'
14
14
  import { captureRequestBodyProbe, shouldChargePlainResponse } from './request-body.js'
15
15
 
16
+ const prepaidSessionTick = Symbol('mppx.prepaidSessionTick')
17
+
16
18
  /** SSE transport with Tempo session controller. */
17
19
  export type Sse = Transport.Sse<Sse_core.SessionController>
18
20
 
21
+ export type PrepaidSessionReceipt = SessionReceipt & {
22
+ [prepaidSessionTick]?: true | undefined
23
+ }
24
+
25
+ export function markPrepaidSessionTick(receipt: SessionReceipt): SessionReceipt {
26
+ Object.defineProperty(receipt, prepaidSessionTick, {
27
+ configurable: false,
28
+ enumerable: false,
29
+ value: true,
30
+ })
31
+ return receipt
32
+ }
33
+
34
+ function hasPrepaidSessionTick(receipt: SessionReceipt): boolean {
35
+ return (receipt as PrepaidSessionReceipt)[prepaidSessionTick] === true
36
+ }
37
+
19
38
  /**
20
39
  * Creates a Tempo-metered SSE transport.
21
40
  *
@@ -93,6 +112,7 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
93
112
  tickCost,
94
113
  pollIntervalMs: pollingInterval,
95
114
  generate,
115
+ prepaidUnits: hasPrepaidSessionTick(receipt as SessionReceipt) ? 1 : 0,
96
116
  signal: input.signal,
97
117
  })
98
118
  return Sse_core.toResponse(stream)
@@ -113,6 +133,9 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
113
133
  }
114
134
 
115
135
  const currentReceipt = receipt as SessionReceipt
136
+ if (hasPrepaidSessionTick(currentReceipt)) {
137
+ return baseResponse
138
+ }
116
139
  const available = BigInt(currentReceipt.acceptedCumulative) - BigInt(currentReceipt.spent)
117
140
  if (available < tickCost) {
118
141
  const error = new Errors.InsufficientBalanceError({
@@ -1,4 +1,12 @@
1
- import { type Address, encodeFunctionData, erc20Abi, type Hex, zeroAddress } from 'viem'
1
+ import {
2
+ type Address,
3
+ createClient,
4
+ custom,
5
+ encodeFunctionData,
6
+ erc20Abi,
7
+ type Hex,
8
+ zeroAddress,
9
+ } from 'viem'
2
10
  import { prepareTransactionRequest, signTransaction, waitForTransactionReceipt } from 'viem/actions'
3
11
  import { Addresses, Transaction } from 'viem/tempo'
4
12
  import { beforeAll, describe, expect, test } from 'vp/test'
@@ -450,6 +458,70 @@ describe.runIf(isLocalnet)('on-chain', () => {
450
458
  ).rejects.toThrow('gas exceeds sponsor policy')
451
459
  })
452
460
 
461
+ test('fee-payer: simulates open before broadcasting', async () => {
462
+ const rpcMethods: string[] = []
463
+ const interceptingClient = createClient({
464
+ account: accounts[0],
465
+ chain: client.chain,
466
+ transport: custom({
467
+ async request(args: any) {
468
+ rpcMethods.push(args.method)
469
+ return client.transport.request(args)
470
+ },
471
+ }),
472
+ })
473
+
474
+ const salt = nextSalt()
475
+ const deposit = 5_000_000n
476
+ const approveData = encodeFunctionData({
477
+ abi: erc20Abi,
478
+ functionName: 'approve',
479
+ args: [escrowContract, deposit],
480
+ })
481
+ const openData = encodeFunctionData({
482
+ abi: escrowAbi,
483
+ functionName: 'open',
484
+ args: [recipient, currency, deposit, salt, zeroAddress],
485
+ })
486
+ const channelId = Channel.computeId({
487
+ authorizedSigner: zeroAddress,
488
+ chainId: chain.id,
489
+ escrowContract,
490
+ payee: recipient,
491
+ payer: payer.address,
492
+ salt,
493
+ token: currency,
494
+ }) as Hex
495
+ const prepared = await prepareTransactionRequest(client, {
496
+ account: payer,
497
+ calls: [
498
+ { to: currency, data: approveData },
499
+ { to: escrowContract, data: openData },
500
+ ],
501
+ feePayer: true,
502
+ feeToken: currency,
503
+ } as never)
504
+ prepared.gas = prepared.gas! + 5_000n
505
+ const serializedTransaction = await signTransaction(client, prepared as never)
506
+
507
+ await broadcastOpenTransaction({
508
+ client: interceptingClient,
509
+ serializedTransaction,
510
+ escrowContract,
511
+ channelId,
512
+ recipient,
513
+ currency,
514
+ feePayer: accounts[0],
515
+ })
516
+
517
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
518
+ const simulationIndex = rpcMethods.indexOf('eth_call')
519
+
520
+ expect(broadcastIndex).toBeGreaterThan(-1)
521
+ expect(simulationIndex).toBeGreaterThan(-1)
522
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
523
+ })
524
+
453
525
  test('fee-payer: rejects smuggled second open call', async () => {
454
526
  const deposit = 5_000_000n
455
527
  const smuggledDeposit = 7_000_000n
@@ -892,6 +964,73 @@ describe.runIf(isLocalnet)('on-chain', () => {
892
964
  ).rejects.toThrow('gas exceeds sponsor policy')
893
965
  })
894
966
 
967
+ test('fee-payer: simulates topUp before broadcasting', async () => {
968
+ const rpcMethods: string[] = []
969
+ const interceptingClient = createClient({
970
+ account: accounts[0],
971
+ chain: client.chain,
972
+ transport: custom({
973
+ async request(args: any) {
974
+ rpcMethods.push(args.method)
975
+ return client.transport.request(args)
976
+ },
977
+ }),
978
+ })
979
+
980
+ const salt = nextSalt()
981
+ const deposit = 5_000_000n
982
+ const topUpAmount = 3_000_000n
983
+
984
+ const { channelId } = await openChannel({
985
+ escrow: escrowContract,
986
+ payer,
987
+ payee: recipient,
988
+ token: currency,
989
+ deposit,
990
+ salt,
991
+ })
992
+
993
+ const approveData = encodeFunctionData({
994
+ abi: erc20Abi,
995
+ functionName: 'approve',
996
+ args: [escrowContract, topUpAmount],
997
+ })
998
+ const topUpData = encodeFunctionData({
999
+ abi: escrowAbi,
1000
+ functionName: 'topUp',
1001
+ args: [channelId, topUpAmount],
1002
+ })
1003
+ const prepared = await prepareTransactionRequest(client, {
1004
+ account: payer,
1005
+ calls: [
1006
+ { to: currency, data: approveData },
1007
+ { to: escrowContract, data: topUpData },
1008
+ ],
1009
+ feePayer: true,
1010
+ feeToken: currency,
1011
+ } as never)
1012
+ prepared.gas = prepared.gas! + 5_000n
1013
+ const serializedTransaction = await signTransaction(client, prepared as never)
1014
+
1015
+ await broadcastTopUpTransaction({
1016
+ client: interceptingClient,
1017
+ serializedTransaction,
1018
+ escrowContract,
1019
+ channelId,
1020
+ currency: asset,
1021
+ declaredDeposit: topUpAmount,
1022
+ previousDeposit: deposit,
1023
+ feePayer: accounts[0],
1024
+ })
1025
+
1026
+ const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransactionSync')
1027
+ const simulationIndex = rpcMethods.indexOf('eth_call')
1028
+
1029
+ expect(broadcastIndex).toBeGreaterThan(-1)
1030
+ expect(simulationIndex).toBeGreaterThan(-1)
1031
+ expect(simulationIndex).toBeLessThan(broadcastIndex)
1032
+ })
1033
+
895
1034
  test('fee-payer: rejects smuggled second topUp call', async () => {
896
1035
  const salt = nextSalt()
897
1036
  const deposit = 5_000_000n
@@ -449,6 +449,7 @@ export async function broadcastOpenTransaction(parameters: {
449
449
  challengeExpires?: string | undefined
450
450
  feePayerPolicy?: Partial<FeePayer.Policy> | undefined
451
451
  feePayer?: Account | undefined
452
+ beforeBroadcast?: ((onChain: OnChainChannel) => Promise<void> | void) | undefined
452
453
  /** When false, simulates instead of waiting for confirmation and returns derived on-chain state. @default true */
453
454
  waitForConfirmation?: boolean | undefined
454
455
  }): Promise<BroadcastResult> {
@@ -462,6 +463,7 @@ export async function broadcastOpenTransaction(parameters: {
462
463
  challengeExpires,
463
464
  feePayerPolicy,
464
465
  feePayer,
466
+ beforeBroadcast,
465
467
  waitForConfirmation = true,
466
468
  } = parameters
467
469
 
@@ -551,6 +553,19 @@ export async function broadcastOpenTransaction(parameters: {
551
553
  const resolvedFeeToken =
552
554
  transaction.feeToken ?? defaults.currency[client.chain?.id as keyof typeof defaults.currency]
553
555
 
556
+ const pendingOnChain = {
557
+ finalized: false,
558
+ closeRequestedAt: 0n,
559
+ payer: transaction.from,
560
+ payee,
561
+ token,
562
+ authorizedSigner,
563
+ deposit,
564
+ settled: 0n,
565
+ } as OnChainChannel
566
+
567
+ await beforeBroadcast?.(pendingOnChain)
568
+
554
569
  const serializedTransaction_final = await (async () => {
555
570
  if (feePayer) {
556
571
  if (!sponsoredOpenCall)
@@ -588,21 +603,20 @@ export async function broadcastOpenTransaction(parameters: {
588
603
 
589
604
  return {
590
605
  txHash,
591
- onChain: {
592
- finalized: false,
593
- closeRequestedAt: 0n,
594
- payer: transaction.from,
595
- payee,
596
- token,
597
- authorizedSigner,
598
- deposit,
599
- settled: 0n,
600
- } as OnChainChannel,
606
+ onChain: pendingOnChain,
601
607
  }
602
608
  }
603
609
 
604
610
  let txHash: Hex | undefined
605
611
  try {
612
+ if (feePayer)
613
+ await call(client, {
614
+ ...transaction,
615
+ account: transaction.from,
616
+ feeToken: resolvedFeeToken,
617
+ calls,
618
+ } as never)
619
+
606
620
  const receipt = await sendRawTransactionSync(client, {
607
621
  serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
608
622
  })
@@ -732,6 +746,16 @@ export async function broadcastTopUpTransaction(parameters: {
732
746
  return serializedTransaction
733
747
  })()
734
748
 
749
+ if (feePayer)
750
+ await call(client, {
751
+ ...transaction,
752
+ account: transaction.from,
753
+ feeToken:
754
+ transaction.feeToken ??
755
+ defaults.currency[client.chain?.id as keyof typeof defaults.currency],
756
+ calls,
757
+ } as never)
758
+
735
759
  const receipt = await sendRawTransactionSync(client, {
736
760
  serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
737
761
  })
@@ -292,6 +292,27 @@ describe('ChannelStore.deductFromChannel', () => {
292
292
  expect(result.channel.spent).toBe(0n)
293
293
  })
294
294
 
295
+ test.each([
296
+ { label: 'atomic backend', create: () => ChannelStore.fromStore(Store.memory()) },
297
+ {
298
+ label: 'mutex fallback',
299
+ create: () => ChannelStore.fromStore(stripUpdateMethod(Store.memory())),
300
+ },
301
+ ])('rejects deduction when channel close has been requested ($label)', async ({ create }) => {
302
+ const cs = create()
303
+ await seedChannel(cs, {
304
+ highestVoucherAmount: 10_000_000n,
305
+ spent: 0n,
306
+ closeRequestedAt: 1n,
307
+ })
308
+
309
+ const result = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n)
310
+ expect(result.ok).toBe(false)
311
+ expect(result.channel.closeRequestedAt).toBe(1n)
312
+ expect(result.channel.spent).toBe(0n)
313
+ expect(result.channel.units).toBe(0)
314
+ })
315
+
295
316
  test('exact balance succeeds', async () => {
296
317
  const cs = ChannelStore.fromStore(Store.memory())
297
318
  await seedChannel(cs, { highestVoucherAmount: 1_000_000n, spent: 0n })
@@ -140,6 +140,8 @@ export async function deductFromChannel(
140
140
  if (!current) return { op: 'noop', result: null }
141
141
  if (current.finalized)
142
142
  return { op: 'noop', result: { ok: false, channel: current } as const }
143
+ if (current.closeRequestedAt !== 0n)
144
+ return { op: 'noop', result: { ok: false, channel: current } as const }
143
145
  if (current.highestVoucherAmount - current.spent >= amount) {
144
146
  const next = { ...current, spent: current.spent + amount, units: current.units + 1 }
145
147
  return { op: 'set', value: next, result: { ok: true, channel: next } as const }
@@ -158,6 +160,10 @@ export async function deductFromChannel(
158
160
  result = { ok: false, channel: current }
159
161
  return current
160
162
  }
163
+ if (current.closeRequestedAt !== 0n) {
164
+ result = { ok: false, channel: current }
165
+ return current
166
+ }
161
167
  if (current.highestVoucherAmount - current.spent >= amount) {
162
168
  const next = { ...current, spent: current.spent + amount, units: current.units + 1 }
163
169
  result = { ok: true, channel: next }
@@ -1,6 +1,7 @@
1
1
  import type { Address, Hex } from 'viem'
2
2
  import { describe, expect, test } from 'vp/test'
3
3
 
4
+ import { ChannelClosedError } from '../../Errors.js'
4
5
  import { chainId, escrowContract as escrowContractDefaults } from '../internal/defaults.js'
5
6
  import type * as ChannelStore from './ChannelStore.js'
6
7
  import { formatNeedVoucherEvent, formatReceiptEvent, parseEvent, serve } from './Sse.js'
@@ -257,6 +258,31 @@ describe('serve', () => {
257
258
  expect(channel!.units).toBe(3)
258
259
  })
259
260
 
261
+ test('uses a prepaid unit for the first generated value', async () => {
262
+ const storage = memoryStore()
263
+ await seedChannel(storage, 3000000n)
264
+ await storage.updateChannel(channelId, (current) =>
265
+ current ? { ...current, spent: 1000000n, units: 1 } : current,
266
+ )
267
+
268
+ const stream = serve({
269
+ store: storage,
270
+ channelId,
271
+ challengeId,
272
+ tickCost: 1000000n,
273
+ generate: generate(['hello', 'world']),
274
+ prepaidUnits: 1,
275
+ })
276
+
277
+ const output = await readStream(stream)
278
+ expect(output).toContain('event: message\ndata: hello\n\n')
279
+ expect(output).toContain('event: message\ndata: world\n\n')
280
+
281
+ const channel = await storage.getChannel(channelId)
282
+ expect(channel!.spent).toBe(2000000n)
283
+ expect(channel!.units).toBe(2)
284
+ })
285
+
260
286
  test('emits multiline message values as a single SSE message event', async () => {
261
287
  const storage = memoryStore()
262
288
  await seedChannel(storage, 1000000n)
@@ -468,4 +494,30 @@ describe('serve', () => {
468
494
  const reader = stream.getReader()
469
495
  await expect(reader.read()).rejects.toThrow('channel not found')
470
496
  })
497
+
498
+ test('rejects a reserved charge when channel close is requested before commit', async () => {
499
+ const storage = memoryStore()
500
+ await seedChannel(storage, 1000000n)
501
+
502
+ const stream = serve({
503
+ store: storage,
504
+ channelId,
505
+ challengeId,
506
+ tickCost: 1000000n,
507
+ generate: async function* (stream) {
508
+ await stream.charge()
509
+ await storage.updateChannel(channelId, (current) =>
510
+ current ? { ...current, closeRequestedAt: 1n } : null,
511
+ )
512
+ yield 'blocked'
513
+ },
514
+ })
515
+
516
+ const reader = stream.getReader()
517
+ await expect(reader.read()).rejects.toThrow(ChannelClosedError)
518
+
519
+ const channel = await storage.getChannel(channelId)
520
+ expect(channel!.spent).toBe(0n)
521
+ expect(channel!.units).toBe(0)
522
+ })
471
523
  })
@@ -9,6 +9,7 @@
9
9
  import type { Hex } from 'viem'
10
10
 
11
11
  import * as Credential from '../../Credential.js'
12
+ import { ChannelClosedError } from '../../Errors.js'
12
13
  import * as ChannelStore from './ChannelStore.js'
13
14
  import { createSessionReceipt } from './Receipt.js'
14
15
  import type { NeedVoucherEvent, SessionCredentialPayload, SessionReceipt } from './Types.js'
@@ -140,11 +141,16 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
140
141
  async start(controller) {
141
142
  const aborted = () => signal?.aborted ?? false
142
143
  const emit = (event: string) => controller.enqueue(encoder.encode(event))
144
+ let prepaidUnits = options.prepaidUnits ?? 0
143
145
  let reservedAmount = 0n
144
146
  let reservedUnits = 0
145
147
 
146
- const charge = () =>
147
- reserveChargeOrWait({
148
+ const charge = () => {
149
+ if (prepaidUnits > 0) {
150
+ prepaidUnits -= 1
151
+ return Promise.resolve()
152
+ }
153
+ return reserveChargeOrWait({
148
154
  store,
149
155
  channelId,
150
156
  amount: tickCost,
@@ -156,6 +162,7 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
156
162
  reservedAmount += tickCost
157
163
  reservedUnits += 1
158
164
  })
165
+ }
159
166
 
160
167
  const iterable: AsyncIterable<string> =
161
168
  typeof generate === 'function' ? generate({ charge }) : generate
@@ -206,6 +213,7 @@ export declare namespace serve {
206
213
  tickCost: bigint
207
214
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>)
208
215
  pollIntervalMs?: number | undefined
216
+ prepaidUnits?: number | undefined
209
217
  signal?: AbortSignal | undefined
210
218
  }
211
219
  }
@@ -274,6 +282,7 @@ async function reserveChargeOrWait(options: {
274
282
 
275
283
  let channel = await store.getChannel(channelId)
276
284
  if (!channel) throw new Error('channel not found')
285
+ throwIfChannelClosed(channel)
277
286
 
278
287
  const hasHeadroom = (state: ChannelStore.State) =>
279
288
  state.highestVoucherAmount - state.spent - reservedAmount >= amount
@@ -297,6 +306,7 @@ async function reserveChargeOrWait(options: {
297
306
  await waitForUpdate(store, channelId, pollIntervalMs, signal)
298
307
  channel = await store.getChannel(channelId)
299
308
  if (!channel) throw new Error('channel not found')
309
+ throwIfChannelClosed(channel)
300
310
  }
301
311
  }
302
312
 
@@ -313,6 +323,7 @@ async function commitReservedCharges(options: {
313
323
  const channel = await store.updateChannel(channelId, (current) => {
314
324
  if (!current) return null
315
325
  if (current.finalized) return current
326
+ if (current.closeRequestedAt !== 0n) return current
316
327
  if (current.highestVoucherAmount - current.spent < amount) return current
317
328
  committed = true
318
329
  return {
@@ -323,9 +334,18 @@ async function commitReservedCharges(options: {
323
334
  })
324
335
 
325
336
  if (!channel) throw new Error('channel not found')
337
+ if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
338
+ if (channel.closeRequestedAt !== 0n)
339
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
326
340
  if (!committed) throw new Error('reserved voucher coverage is no longer available')
327
341
  }
328
342
 
343
+ function throwIfChannelClosed(channel: ChannelStore.State): void {
344
+ if (channel.finalized) throw new ChannelClosedError({ reason: 'channel is finalized' })
345
+ if (channel.closeRequestedAt !== 0n)
346
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
347
+ }
348
+
329
349
  async function waitForUpdate(
330
350
  store: ChannelStore.ChannelStore,
331
351
  channelId: Hex,