mppx 0.6.13 → 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 (60) hide show
  1. package/CHANGELOG.md +8 -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 +76 -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/Sse.d.ts +1 -0
  38. package/dist/tempo/session/Sse.d.ts.map +1 -1
  39. package/dist/tempo/session/Sse.js +19 -12
  40. package/dist/tempo/session/Sse.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/cli/plugins/tempo.ts +28 -1
  43. package/src/middlewares/express.test.ts +27 -0
  44. package/src/middlewares/express.ts +24 -0
  45. package/src/tempo/client/SessionManager.ts +26 -1
  46. package/src/tempo/internal/fee-payer.test.ts +139 -0
  47. package/src/tempo/internal/fee-payer.ts +85 -6
  48. package/src/tempo/server/Charge.test.ts +119 -0
  49. package/src/tempo/server/Charge.ts +70 -10
  50. package/src/tempo/server/Session.test.ts +327 -0
  51. package/src/tempo/server/Session.ts +88 -39
  52. package/src/tempo/server/internal/html.gen.ts +1 -1
  53. package/src/tempo/server/internal/request-body.test.ts +26 -0
  54. package/src/tempo/server/internal/request-body.ts +4 -1
  55. package/src/tempo/server/internal/transport.test.ts +28 -2
  56. package/src/tempo/server/internal/transport.ts +23 -0
  57. package/src/tempo/session/Chain.test.ts +140 -1
  58. package/src/tempo/session/Chain.ts +34 -10
  59. package/src/tempo/session/Sse.test.ts +25 -0
  60. package/src/tempo/session/Sse.ts +9 -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
  })
@@ -258,6 +258,31 @@ describe('serve', () => {
258
258
  expect(channel!.units).toBe(3)
259
259
  })
260
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
+
261
286
  test('emits multiline message values as a single SSE message event', async () => {
262
287
  const storage = memoryStore()
263
288
  await seedChannel(storage, 1000000n)
@@ -141,11 +141,16 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
141
141
  async start(controller) {
142
142
  const aborted = () => signal?.aborted ?? false
143
143
  const emit = (event: string) => controller.enqueue(encoder.encode(event))
144
+ let prepaidUnits = options.prepaidUnits ?? 0
144
145
  let reservedAmount = 0n
145
146
  let reservedUnits = 0
146
147
 
147
- const charge = () =>
148
- reserveChargeOrWait({
148
+ const charge = () => {
149
+ if (prepaidUnits > 0) {
150
+ prepaidUnits -= 1
151
+ return Promise.resolve()
152
+ }
153
+ return reserveChargeOrWait({
149
154
  store,
150
155
  channelId,
151
156
  amount: tickCost,
@@ -157,6 +162,7 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
157
162
  reservedAmount += tickCost
158
163
  reservedUnits += 1
159
164
  })
165
+ }
160
166
 
161
167
  const iterable: AsyncIterable<string> =
162
168
  typeof generate === 'function' ? generate({ charge }) : generate
@@ -207,6 +213,7 @@ export declare namespace serve {
207
213
  tickCost: bigint
208
214
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>)
209
215
  pollIntervalMs?: number | undefined
216
+ prepaidUnits?: number | undefined
210
217
  signal?: AbortSignal | undefined
211
218
  }
212
219
  }