mppx 0.3.9 → 0.3.11

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 (61) hide show
  1. package/README.md +3 -3
  2. package/dist/Challenge.d.ts.map +1 -1
  3. package/dist/Challenge.js +2 -0
  4. package/dist/Challenge.js.map +1 -1
  5. package/dist/Errors.d.ts +0 -2
  6. package/dist/Errors.d.ts.map +1 -1
  7. package/dist/Errors.js +1 -3
  8. package/dist/Errors.js.map +1 -1
  9. package/dist/internal/constantTimeEqual.d.ts.map +1 -1
  10. package/dist/internal/constantTimeEqual.js +4 -6
  11. package/dist/internal/constantTimeEqual.js.map +1 -1
  12. package/dist/internal/env.d.ts +2 -2
  13. package/dist/internal/env.d.ts.map +1 -1
  14. package/dist/internal/env.js +1 -2
  15. package/dist/internal/env.js.map +1 -1
  16. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  17. package/dist/middlewares/internal/mppx.js +6 -2
  18. package/dist/middlewares/internal/mppx.js.map +1 -1
  19. package/dist/server/Mppx.d.ts +13 -3
  20. package/dist/server/Mppx.d.ts.map +1 -1
  21. package/dist/server/Mppx.js +46 -3
  22. package/dist/server/Mppx.js.map +1 -1
  23. package/dist/tempo/internal/simulate.d.ts +21 -0
  24. package/dist/tempo/internal/simulate.d.ts.map +1 -0
  25. package/dist/tempo/internal/simulate.js +31 -0
  26. package/dist/tempo/internal/simulate.js.map +1 -0
  27. package/dist/tempo/server/Charge.d.ts +12 -0
  28. package/dist/tempo/server/Charge.d.ts.map +1 -1
  29. package/dist/tempo/server/Charge.js +28 -6
  30. package/dist/tempo/server/Charge.js.map +1 -1
  31. package/dist/tempo/server/Session.d.ts +14 -0
  32. package/dist/tempo/server/Session.d.ts.map +1 -1
  33. package/dist/tempo/server/Session.js +59 -40
  34. package/dist/tempo/server/Session.js.map +1 -1
  35. package/dist/tempo/session/Chain.d.ts +3 -0
  36. package/dist/tempo/session/Chain.d.ts.map +1 -1
  37. package/dist/tempo/session/Chain.js +27 -6
  38. package/dist/tempo/session/Chain.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/Challenge.ts +2 -0
  41. package/src/Errors.test.ts +43 -18
  42. package/src/Errors.ts +1 -4
  43. package/src/client/Mppx.test.ts +1 -0
  44. package/src/internal/constantTimeEqual.ts +5 -4
  45. package/src/internal/env.test.ts +2 -2
  46. package/src/internal/env.ts +4 -5
  47. package/src/middlewares/express.test.ts +5 -0
  48. package/src/middlewares/hono.test.ts +5 -0
  49. package/src/middlewares/internal/mppx.ts +5 -2
  50. package/src/middlewares/nextjs.test.ts +5 -0
  51. package/src/proxy/Proxy.test.ts +3 -0
  52. package/src/proxy/services/openai.test.ts +3 -0
  53. package/src/server/Mppx.test.ts +93 -2
  54. package/src/server/Mppx.ts +81 -6
  55. package/src/tempo/internal/simulate.ts +49 -0
  56. package/src/tempo/server/Charge.test.ts +62 -0
  57. package/src/tempo/server/Charge.ts +44 -6
  58. package/src/tempo/server/Session.test.ts +49 -0
  59. package/src/tempo/server/Session.ts +76 -34
  60. package/src/tempo/session/Chain.test.ts +36 -0
  61. package/src/tempo/session/Chain.ts +38 -2
@@ -1101,6 +1101,55 @@ describe('session', () => {
1101
1101
  expect(result).toBeUndefined()
1102
1102
  })
1103
1103
 
1104
+ test('returns undefined for open POST with content-length > 0 (content request)', () => {
1105
+ const server = createServer()
1106
+ const result = server.respond!({
1107
+ credential: {
1108
+ challenge: makeChallenge({
1109
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1110
+ }),
1111
+ payload: { action: 'open' },
1112
+ },
1113
+ input: new Request('http://localhost', {
1114
+ method: 'POST',
1115
+ headers: { 'content-length': '42' },
1116
+ }),
1117
+ } as any)
1118
+ expect(result).toBeUndefined()
1119
+ })
1120
+
1121
+ test('returns undefined for open POST with transfer-encoding header (content request)', () => {
1122
+ const server = createServer()
1123
+ const result = server.respond!({
1124
+ credential: {
1125
+ challenge: makeChallenge({
1126
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1127
+ }),
1128
+ payload: { action: 'open' },
1129
+ },
1130
+ input: new Request('http://localhost', {
1131
+ method: 'POST',
1132
+ headers: { 'transfer-encoding': 'chunked' },
1133
+ }),
1134
+ } as any)
1135
+ expect(result).toBeUndefined()
1136
+ })
1137
+
1138
+ test('returns 204 for GET with topUp action', () => {
1139
+ const server = createServer()
1140
+ const result = server.respond!({
1141
+ credential: {
1142
+ challenge: makeChallenge({
1143
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
1144
+ }),
1145
+ payload: { action: 'topUp' },
1146
+ },
1147
+ input: new Request('http://localhost', { method: 'GET' }),
1148
+ } as any)
1149
+ expect(result).toBeInstanceOf(Response)
1150
+ expect((result as Response).status).toBe(204)
1151
+ })
1152
+
1104
1153
  test('returns undefined for voucher POST with content-length > 0 (content request)', () => {
1105
1154
  const server = createServer()
1106
1155
  const result = server.respond!({
@@ -85,13 +85,17 @@ export function session<const parameters extends session.Parameters>(p?: paramet
85
85
  const parameters = p as parameters
86
86
  const {
87
87
  amount,
88
+ channelStateTtl = 60_000,
88
89
  currency = defaults.resolveCurrency(parameters),
89
90
  decimals = defaults.decimals,
90
91
  store: rawStore = Store.memory(),
91
92
  suggestedDeposit,
92
93
  unitType,
94
+ waitForConfirmation = true,
93
95
  } = parameters
94
96
 
97
+ const lastOnChainVerified = new Map<Hex, number>()
98
+
95
99
  const store = ChannelStore.fromStore(rawStore)
96
100
 
97
101
  const getClient = Client.getResolver({
@@ -187,7 +191,9 @@ export function session<const parameters extends session.Parameters>(p?: paramet
187
191
  payload,
188
192
  methodDetails,
189
193
  resolvedFeePayer,
194
+ waitForConfirmation,
190
195
  )
196
+ lastOnChainVerified.set(payload.channelId, Date.now())
191
197
  break
192
198
 
193
199
  case 'topUp':
@@ -199,6 +205,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
199
205
  methodDetails,
200
206
  resolvedFeePayer,
201
207
  )
208
+ lastOnChainVerified.set(payload.channelId, Date.now())
202
209
  break
203
210
 
204
211
  case 'voucher':
@@ -209,6 +216,8 @@ export function session<const parameters extends session.Parameters>(p?: paramet
209
216
  challenge,
210
217
  payload,
211
218
  methodDetails,
219
+ channelStateTtl,
220
+ lastOnChainVerified,
212
221
  )
213
222
  break
214
223
 
@@ -238,34 +247,30 @@ export function session<const parameters extends session.Parameters>(p?: paramet
238
247
  // invoking the user's route handler. When it returns undefined, the
239
248
  // user's handler runs normally and serves content.
240
249
  //
241
- // Management actions (open, topUp, close) are always gated — they
242
- // return 204 regardless of request method.
250
+ // close and topUp are always gated (204) — they are pure management.
243
251
  //
244
- // Voucher POSTs are gated only when they have no request body, which
245
- // signals a mid-session voucher update (the client is just topping up
246
- // the channel balance). Voucher POSTs WITH a body are content requests
247
- // (e.g., an API call to a POST endpoint) and fall through to the
248
- // user's handler. GET requests with vouchers always fall through so
249
- // auto-mode clients (whose fetch wrapper bundles open+voucher into a
250
- // single GET retry) receive content as expected.
252
+ // open and voucher are gated only for bodyless POSTs (management
253
+ // updates). POSTs with a body are content requests the client's
254
+ // original request piggybacked on the credential so they fall
255
+ // through to serve content. GETs always fall through so auto-mode
256
+ // clients (whose fetch wrapper bundles open+voucher into a single
257
+ // GET retry) receive content as expected.
251
258
  respond({ credential, input }) {
252
259
  const { payload } = credential as Credential.Credential<SessionCredentialPayload>
253
260
 
254
261
  if (payload.action === 'close') return new Response(null, { status: 204 })
255
-
256
- const isManagement = payload.action === 'open' || payload.action === 'topUp'
257
- if (isManagement && input.method === 'POST') return new Response(null, { status: 204 })
258
-
259
- const isVoucher = payload.action === 'voucher'
260
- if (!isVoucher) return undefined
261
-
262
- // Only gate voucher POSTs with no body (mid-session balance updates).
263
- // POSTs with a body are content requests that should reach the handler.
264
- if (input.method !== 'POST') return undefined
265
- const contentLength = input.headers.get('content-length')
266
- if (contentLength !== null && contentLength !== '0') return undefined
267
- if (input.headers.has('transfer-encoding')) return undefined
268
- return new Response(null, { status: 204 })
262
+ if (payload.action === 'topUp') return new Response(null, { status: 204 })
263
+
264
+ // open and voucher: gate only bodyless POSTs (management updates).
265
+ // POSTs with a body are content requests — fall through so the
266
+ // upstream response is returned to the client.
267
+ if (input.method === 'POST') {
268
+ const contentLength = input.headers.get('content-length')
269
+ if (contentLength !== null && contentLength !== '0') return undefined
270
+ if (input.headers.has('transfer-encoding')) return undefined
271
+ return new Response(null, { status: 204 })
272
+ }
273
+ return undefined
269
274
  },
270
275
  })
271
276
  }
@@ -277,8 +282,22 @@ export declare namespace session {
277
282
  >
278
283
 
279
284
  type Parameters = {
285
+ /** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 60_000 */
286
+ channelStateTtl?: number | undefined
280
287
  /** Minimum voucher delta to accept (numeric string, default: "0"). */
281
288
  minVoucherDelta?: string | undefined
289
+ /**
290
+ * Whether to wait for the open transaction to confirm on-chain before
291
+ * responding. @default true
292
+ *
293
+ * When `false`, the transaction is simulated via `eth_estimateGas` and
294
+ * broadcast without waiting for inclusion. The receipt will optimistically
295
+ * report `status: 'success'` based on simulation alone — if the
296
+ * transaction reverts on-chain after broadcast (e.g. due to a state
297
+ * change between simulation and inclusion), the receipt will not reflect
298
+ * the failure.
299
+ */
300
+ waitForConfirmation?: boolean | undefined
282
301
  /** Store backend for channel state. */
283
302
  store?: Store.Store | undefined
284
303
  /**
@@ -492,7 +511,12 @@ async function verifyAndAcceptVoucher(parameters: {
492
511
  }
493
512
 
494
513
  /**
495
- * Handle 'open' action - broadcast open transaction, verify voucher, and create channel.
514
+ * Handle 'open' action - verify voucher, create channel, and broadcast.
515
+ *
516
+ * When `waitForConfirmation` is true (default), the open transaction is
517
+ * broadcast and confirmed on-chain before responding. When false, the
518
+ * transaction is validated and simulated via `eth_estimateGas`; the receipt
519
+ * is returned immediately and the broadcast runs in the background.
496
520
  */
497
521
  async function handleOpen(
498
522
  store: ChannelStore.ChannelStore,
@@ -501,6 +525,7 @@ async function handleOpen(
501
525
  payload: SessionCredentialPayload & { action: 'open' },
502
526
  methodDetails: SessionMethodDetails,
503
527
  feePayer: viem_Account | undefined,
528
+ waitForConfirmation: boolean,
504
529
  ): Promise<SessionReceipt> {
505
530
  const voucher = parseVoucherFromPayload(
506
531
  payload.channelId,
@@ -520,6 +545,7 @@ async function handleOpen(
520
545
  recipient,
521
546
  currency,
522
547
  feePayer,
548
+ waitForConfirmation,
523
549
  })
524
550
 
525
551
  validateOnChainChannel(onChain, recipient, currency, amount)
@@ -631,6 +657,7 @@ async function handleTopUp(
631
657
  serializedTransaction: payload.transaction,
632
658
  escrowContract: methodDetails.escrowContract,
633
659
  channelId: payload.channelId,
660
+ currency: challenge.request.currency as Address,
634
661
  declaredDeposit,
635
662
  previousDeposit: channel.deposit,
636
663
  feePayer,
@@ -655,11 +682,13 @@ async function handleTopUp(
655
682
  */
656
683
  async function handleVoucher(
657
684
  store: ChannelStore.ChannelStore,
658
- _client: viem_Client,
685
+ client: viem_Client,
659
686
  minVoucherDelta: bigint,
660
687
  challenge: Challenge.Challenge,
661
688
  payload: SessionCredentialPayload & { action: 'voucher' },
662
689
  methodDetails: SessionMethodDetails,
690
+ channelStateTtl: number,
691
+ lastOnChainVerified: Map<Hex, number>,
663
692
  ): Promise<SessionReceipt> {
664
693
  const channel = await store.getChannel(payload.channelId)
665
694
  if (!channel) {
@@ -681,15 +710,28 @@ async function handleVoucher(
681
710
  // same session can safely use the cached deposit/signer values.
682
711
  // This avoids an RPC round-trip per voucher, which is critical for
683
712
  // high-frequency SSE streaming where vouchers arrive per-token.
684
- const cachedOnChain: OnChainChannel = {
685
- payer: channel.payer,
686
- payee: channel.payee,
687
- token: channel.token,
688
- deposit: channel.deposit,
689
- settled: channel.settledOnChain,
690
- finalized: channel.finalized,
691
- authorizedSigner: channel.authorizedSigner,
692
- closeRequestedAt: 0n,
713
+ //
714
+ // To guard against the payer initiating a forced close while vouchers
715
+ // are still being accepted, re-query on-chain state when the cache
716
+ // exceeds the configured staleness TTL.
717
+ const lastVerified = lastOnChainVerified.get(payload.channelId) ?? 0
718
+ const isStale = Date.now() - lastVerified > channelStateTtl
719
+
720
+ let cachedOnChain: OnChainChannel
721
+ if (isStale) {
722
+ cachedOnChain = await getOnChainChannel(client, methodDetails.escrowContract, payload.channelId)
723
+ lastOnChainVerified.set(payload.channelId, Date.now())
724
+ } else {
725
+ cachedOnChain = {
726
+ payer: channel.payer,
727
+ payee: channel.payee,
728
+ token: channel.token,
729
+ deposit: channel.deposit,
730
+ settled: channel.settledOnChain,
731
+ finalized: channel.finalized,
732
+ authorizedSigner: channel.authorizedSigner,
733
+ closeRequestedAt: 0n,
734
+ }
693
735
  }
694
736
 
695
737
  return verifyAndAcceptVoucher({
@@ -347,6 +347,38 @@ describe('on-chain', () => {
347
347
  expect(result.txHash).toBeUndefined()
348
348
  expect(result.onChain.deposit).toBe(deposit)
349
349
  })
350
+
351
+ test('waitForConfirmation: false returns derived on-chain state', async () => {
352
+ const salt = nextSalt()
353
+ const deposit = 10_000_000n
354
+
355
+ const { channelId, serializedTransaction } = await signOpenChannel({
356
+ escrow: escrowContract,
357
+ payer,
358
+ payee: recipient,
359
+ token: currency,
360
+ deposit,
361
+ salt,
362
+ })
363
+
364
+ const result = await broadcastOpenTransaction({
365
+ client,
366
+ serializedTransaction,
367
+ escrowContract,
368
+ channelId,
369
+ recipient,
370
+ currency,
371
+ waitForConfirmation: false,
372
+ })
373
+
374
+ expect(result.txHash).toBeDefined()
375
+ expect(result.onChain.payer.toLowerCase()).toBe(payer.address.toLowerCase())
376
+ expect(result.onChain.payee.toLowerCase()).toBe(recipient.toLowerCase())
377
+ expect(result.onChain.token.toLowerCase()).toBe(currency.toLowerCase())
378
+ expect(result.onChain.deposit).toBe(deposit)
379
+ expect(result.onChain.settled).toBe(0n)
380
+ expect(result.onChain.finalized).toBe(false)
381
+ })
350
382
  })
351
383
 
352
384
  describe('broadcastTopUpTransaction', () => {
@@ -381,6 +413,7 @@ describe('on-chain', () => {
381
413
  serializedTransaction,
382
414
  escrowContract,
383
415
  channelId: wrongChannelId,
416
+ currency: asset,
384
417
  declaredDeposit: topUpAmount,
385
418
  previousDeposit: deposit,
386
419
  }),
@@ -415,6 +448,7 @@ describe('on-chain', () => {
415
448
  serializedTransaction,
416
449
  escrowContract,
417
450
  channelId,
451
+ currency: asset,
418
452
  declaredDeposit: 9_999_999n,
419
453
  previousDeposit: deposit,
420
454
  }),
@@ -448,6 +482,7 @@ describe('on-chain', () => {
448
482
  serializedTransaction,
449
483
  escrowContract,
450
484
  channelId,
485
+ currency: asset,
451
486
  declaredDeposit: topUpAmount,
452
487
  previousDeposit: deposit,
453
488
  })
@@ -502,6 +537,7 @@ describe('on-chain', () => {
502
537
  serializedTransaction: tampered,
503
538
  escrowContract,
504
539
  channelId,
540
+ currency: asset,
505
541
  declaredDeposit: topUpAmount,
506
542
  previousDeposit: deposit,
507
543
  feePayer: accounts[0],
@@ -5,6 +5,7 @@ import {
5
5
  decodeFunctionData,
6
6
  encodeFunctionData,
7
7
  getAbiItem,
8
+ getAddress,
8
9
  type Hex,
9
10
  isAddressEqual,
10
11
  type ReadContractReturnType,
@@ -13,6 +14,7 @@ import {
13
14
  import {
14
15
  prepareTransactionRequest,
15
16
  readContract,
17
+ sendRawTransaction,
16
18
  sendRawTransactionSync,
17
19
  signTransaction,
18
20
  writeContract,
@@ -20,6 +22,7 @@ import {
20
22
  import { Transaction } from 'viem/tempo'
21
23
  import { BadRequestError, ChannelClosedError, VerificationFailedError } from '../../Errors.js'
22
24
  import * as defaults from '../internal/defaults.js'
25
+ import { simulateTransaction } from '../internal/simulate.js'
23
26
  import { escrowAbi } from './escrow.abi.js'
24
27
  import type { SignedVoucher } from './Types.js'
25
28
 
@@ -205,6 +208,8 @@ export async function broadcastOpenTransaction(parameters: {
205
208
  recipient: Address
206
209
  currency: Address
207
210
  feePayer?: Account | undefined
211
+ /** When false, simulates instead of waiting for confirmation and returns derived on-chain state. @default true */
212
+ waitForConfirmation?: boolean | undefined
208
213
  }): Promise<BroadcastResult> {
209
214
  const {
210
215
  client,
@@ -214,6 +219,7 @@ export async function broadcastOpenTransaction(parameters: {
214
219
  recipient,
215
220
  currency,
216
221
  feePayer,
222
+ waitForConfirmation = true,
217
223
  } = parameters
218
224
 
219
225
  const transaction = Transaction.deserialize(
@@ -252,7 +258,13 @@ export async function broadcastOpenTransaction(parameters: {
252
258
  }
253
259
 
254
260
  const { args: openArgs } = decodeFunctionData({ abi: escrowAbi, data: openCall.data! })
255
- const [payee, token] = openArgs as readonly [Address, Address, ...unknown[]]
261
+ const [payee, token, deposit, , authorizedSigner] = openArgs as readonly [
262
+ Address,
263
+ Address,
264
+ bigint,
265
+ Hex,
266
+ Address,
267
+ ]
256
268
 
257
269
  if (!isAddressEqual(payee, recipient)) {
258
270
  throw new VerificationFailedError({
@@ -276,6 +288,28 @@ export async function broadcastOpenTransaction(parameters: {
276
288
  return serializedTransaction
277
289
  })()
278
290
 
291
+ if (!waitForConfirmation) {
292
+ const from = getAddress(transaction.from as Address)
293
+ await simulateTransaction(client, { ...transaction, from, calls })
294
+ const txHash = await sendRawTransaction(client, {
295
+ serializedTransaction: serializedTransaction_final as Transaction.TransactionSerializedTempo,
296
+ })
297
+
298
+ return {
299
+ txHash,
300
+ onChain: {
301
+ payer: from,
302
+ payee,
303
+ token,
304
+ authorizedSigner,
305
+ deposit,
306
+ settled: 0n,
307
+ closeRequestedAt: 0n,
308
+ finalized: false,
309
+ } as OnChainChannel,
310
+ }
311
+ }
312
+
279
313
  let txHash: Hex | undefined
280
314
  try {
281
315
  const receipt = await sendRawTransactionSync(client, {
@@ -307,6 +341,7 @@ export async function broadcastTopUpTransaction(parameters: {
307
341
  serializedTransaction: Hex
308
342
  escrowContract: Address
309
343
  channelId: Hex
344
+ currency: Address
310
345
  declaredDeposit: bigint
311
346
  previousDeposit: bigint
312
347
  feePayer?: Account | undefined
@@ -316,6 +351,7 @@ export async function broadcastTopUpTransaction(parameters: {
316
351
  serializedTransaction,
317
352
  escrowContract,
318
353
  channelId,
354
+ currency,
319
355
  declaredDeposit,
320
356
  previousDeposit,
321
357
  feePayer,
@@ -347,7 +383,7 @@ export async function broadcastTopUpTransaction(parameters: {
347
383
  const selector = call.data.slice(0, 10)
348
384
  const isEscrowTopUp =
349
385
  isAddressEqual(call.to, escrowContract) && selector === escrowTopUpSelector
350
- const isTokenApprove = selector === erc20ApproveSelector
386
+ const isTokenApprove = isAddressEqual(call.to, currency) && selector === erc20ApproveSelector
351
387
  if (!isEscrowTopUp && !isTokenApprove) {
352
388
  throw new BadRequestError({
353
389
  reason: 'fee-sponsored topUp transaction contains an unauthorized call',