mppx 0.4.6 → 0.4.8

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 (89) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Store.d.ts +5 -4
  3. package/dist/Store.d.ts.map +1 -1
  4. package/dist/Store.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +22 -7
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  9. package/dist/cli/plugins/tempo.js +9 -22
  10. package/dist/cli/plugins/tempo.js.map +1 -1
  11. package/dist/middlewares/elysia.d.ts.map +1 -1
  12. package/dist/middlewares/elysia.js +5 -1
  13. package/dist/middlewares/elysia.js.map +1 -1
  14. package/dist/proxy/Proxy.d.ts.map +1 -1
  15. package/dist/proxy/Proxy.js +3 -1
  16. package/dist/proxy/Proxy.js.map +1 -1
  17. package/dist/proxy/internal/Route.d.ts +2 -2
  18. package/dist/proxy/internal/Route.d.ts.map +1 -1
  19. package/dist/proxy/internal/Route.js +4 -2
  20. package/dist/proxy/internal/Route.js.map +1 -1
  21. package/dist/server/Mppx.d.ts.map +1 -1
  22. package/dist/server/Mppx.js +26 -8
  23. package/dist/server/Mppx.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  25. package/dist/tempo/client/SessionManager.js +12 -1
  26. package/dist/tempo/client/SessionManager.js.map +1 -1
  27. package/dist/tempo/internal/address.d.ts +3 -0
  28. package/dist/tempo/internal/address.d.ts.map +1 -0
  29. package/dist/tempo/internal/address.js +4 -0
  30. package/dist/tempo/internal/address.js.map +1 -0
  31. package/dist/tempo/internal/auto-swap.js +3 -3
  32. package/dist/tempo/internal/auto-swap.js.map +1 -1
  33. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  34. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  35. package/dist/tempo/internal/fee-payer.js +11 -3
  36. package/dist/tempo/internal/fee-payer.js.map +1 -1
  37. package/dist/tempo/server/Charge.d.ts +11 -0
  38. package/dist/tempo/server/Charge.d.ts.map +1 -1
  39. package/dist/tempo/server/Charge.js +109 -50
  40. package/dist/tempo/server/Charge.js.map +1 -1
  41. package/dist/tempo/server/Session.d.ts +1 -1
  42. package/dist/tempo/server/Session.d.ts.map +1 -1
  43. package/dist/tempo/server/Session.js +39 -32
  44. package/dist/tempo/server/Session.js.map +1 -1
  45. package/dist/tempo/server/internal/transport.d.ts +1 -1
  46. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/transport.js +41 -1
  48. package/dist/tempo/server/internal/transport.js.map +1 -1
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +51 -10
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  53. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  54. package/dist/tempo/session/ChannelStore.js +4 -2
  55. package/dist/tempo/session/ChannelStore.js.map +1 -1
  56. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  57. package/dist/tempo/session/Voucher.js +3 -2
  58. package/dist/tempo/session/Voucher.js.map +1 -1
  59. package/package.json +6 -2
  60. package/src/Store.test-d.ts +58 -0
  61. package/src/Store.ts +6 -4
  62. package/src/cli/cli.test.ts +124 -0
  63. package/src/cli/cli.ts +19 -7
  64. package/src/cli/plugins/tempo.ts +17 -23
  65. package/src/middlewares/elysia.test.ts +89 -0
  66. package/src/middlewares/elysia.ts +4 -1
  67. package/src/proxy/Proxy.test.ts +56 -0
  68. package/src/proxy/Proxy.ts +6 -1
  69. package/src/proxy/internal/Route.test.ts +57 -0
  70. package/src/proxy/internal/Route.ts +3 -1
  71. package/src/server/Mppx.test.ts +246 -0
  72. package/src/server/Mppx.ts +27 -8
  73. package/src/tempo/client/SessionManager.ts +11 -1
  74. package/src/tempo/internal/address.ts +6 -0
  75. package/src/tempo/internal/auto-swap.ts +3 -3
  76. package/src/tempo/internal/fee-payer.ts +18 -4
  77. package/src/tempo/server/Charge.test.ts +1080 -31
  78. package/src/tempo/server/Charge.ts +158 -63
  79. package/src/tempo/server/Session.test.ts +929 -111
  80. package/src/tempo/server/Session.ts +48 -33
  81. package/src/tempo/server/Sse.test.ts +1 -0
  82. package/src/tempo/server/internal/transport.test.ts +29 -0
  83. package/src/tempo/server/internal/transport.ts +41 -2
  84. package/src/tempo/session/Chain.test.ts +144 -0
  85. package/src/tempo/session/Chain.ts +58 -10
  86. package/src/tempo/session/ChannelStore.test.ts +10 -0
  87. package/src/tempo/session/ChannelStore.ts +6 -3
  88. package/src/tempo/session/Sse.test.ts +1 -0
  89. package/src/tempo/session/Voucher.ts +3 -2
@@ -85,7 +85,7 @@ 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
+ channelStateTtl = 5_000,
89
89
  currency = defaults.resolveCurrency(parameters),
90
90
  decimals = defaults.decimals,
91
91
  store: rawStore = Store.memory(),
@@ -284,7 +284,7 @@ export declare namespace session {
284
284
  >
285
285
 
286
286
  type Parameters = {
287
- /** 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 */
287
+ /** 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 5_000 */
288
288
  channelStateTtl?: number | undefined
289
289
  /** Minimum voucher delta to accept (numeric string, default: "0"). */
290
290
  minVoucherDelta?: string | undefined
@@ -451,7 +451,7 @@ async function verifyAndAcceptVoucher(parameters: {
451
451
  throw new ChannelClosedError({ reason: 'channel has a pending close request' })
452
452
  }
453
453
 
454
- if (voucher.cumulativeAmount < onChain.settled) {
454
+ if (voucher.cumulativeAmount <= onChain.settled) {
455
455
  throw new VerificationFailedError({
456
456
  reason: 'voucher cumulativeAmount is below on-chain settled amount',
457
457
  })
@@ -462,12 +462,8 @@ async function verifyAndAcceptVoucher(parameters: {
462
462
  }
463
463
 
464
464
  if (voucher.cumulativeAmount <= channel.highestVoucherAmount) {
465
- return createSessionReceipt({
466
- challengeId: challenge.id,
467
- channelId,
468
- acceptedCumulative: channel.highestVoucherAmount,
469
- spent: channel.spent,
470
- units: channel.units,
465
+ throw new VerificationFailedError({
466
+ reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
471
467
  })
472
468
  }
473
469
 
@@ -561,7 +557,7 @@ async function handleOpen(
561
557
  throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
562
558
  }
563
559
 
564
- if (voucher.cumulativeAmount < onChain.settled) {
560
+ if (voucher.cumulativeAmount <= onChain.settled) {
565
561
  throw new VerificationFailedError({
566
562
  reason: 'voucher cumulativeAmount is below on-chain settled amount',
567
563
  })
@@ -580,16 +576,22 @@ async function handleOpen(
580
576
 
581
577
  const updated = await store.updateChannel(payload.channelId, (existing) => {
582
578
  if (existing) {
583
- if (voucher.cumulativeAmount < existing.settledOnChain) {
579
+ if (voucher.cumulativeAmount <= existing.settledOnChain) {
584
580
  throw new VerificationFailedError({
585
581
  reason: 'voucher amount is below settled on-chain amount',
586
582
  })
587
583
  }
588
584
 
585
+ const settledOnChain =
586
+ onChain.settled > existing.settledOnChain ? onChain.settled : existing.settledOnChain
587
+ const spent = settledOnChain > existing.spent ? settledOnChain : existing.spent
588
+
589
589
  if (voucher.cumulativeAmount > existing.highestVoucherAmount) {
590
590
  return {
591
591
  ...existing,
592
592
  deposit: onChain.deposit,
593
+ settledOnChain,
594
+ spent,
593
595
  highestVoucherAmount: voucher.cumulativeAmount,
594
596
  highestVoucher: voucher,
595
597
  authorizedSigner,
@@ -598,6 +600,8 @@ async function handleOpen(
598
600
  return {
599
601
  ...existing,
600
602
  deposit: onChain.deposit,
603
+ settledOnChain,
604
+ spent,
601
605
  authorizedSigner,
602
606
  }
603
607
  }
@@ -605,15 +609,16 @@ async function handleOpen(
605
609
  channelId: payload.channelId,
606
610
  chainId: methodDetails.chainId,
607
611
  escrowContract: methodDetails.escrowContract,
612
+ closeRequestedAt: onChain.closeRequestedAt,
608
613
  payer: onChain.payer,
609
614
  payee: onChain.payee,
610
615
  token: onChain.token,
611
616
  authorizedSigner,
612
617
  deposit: onChain.deposit,
613
- settledOnChain: 0n,
618
+ settledOnChain: onChain.settled,
614
619
  highestVoucherAmount: voucher.cumulativeAmount,
615
620
  highestVoucher: voucher,
616
- spent: 0n,
621
+ spent: onChain.settled,
617
622
  units: 0,
618
623
  finalized: false,
619
624
  createdAt: new Date().toISOString(),
@@ -715,18 +720,30 @@ async function handleVoucher(
715
720
  //
716
721
  // To guard against the payer initiating a forced close while vouchers
717
722
  // are still being accepted, re-query on-chain state when the cache
718
- // exceeds the configured staleness TTL.
723
+ // exceeds the configured staleness TTL (default: 5s).
719
724
  const lastVerified = lastOnChainVerified.get(payload.channelId) ?? 0
720
725
  const isStale = Date.now() - lastVerified > channelStateTtl
721
726
 
722
- let cachedOnChain: OnChainChannel
723
- if (isStale) {
724
- cachedOnChain = await getOnChainChannel(client, methodDetails.escrowContract, payload.channelId)
725
- lastOnChainVerified.set(payload.channelId, Date.now())
726
- } else {
727
- cachedOnChain = {
727
+ const onChain = await (async () => {
728
+ if (isStale) {
729
+ const onChainChannel = await getOnChainChannel(
730
+ client,
731
+ methodDetails.escrowContract,
732
+ payload.channelId,
733
+ )
734
+ lastOnChainVerified.set(payload.channelId, Date.now())
735
+ // Persist closeRequestedAt so the cached path detects force-close
736
+ // between re-queries.
737
+ if (onChainChannel.closeRequestedAt !== 0n) {
738
+ await store.updateChannel(payload.channelId, (current) =>
739
+ current ? { ...current, closeRequestedAt: onChainChannel.closeRequestedAt } : current,
740
+ )
741
+ }
742
+ return onChainChannel
743
+ }
744
+ return {
728
745
  finalized: channel.finalized,
729
- closeRequestedAt: 0n,
746
+ closeRequestedAt: channel.closeRequestedAt,
730
747
  payer: channel.payer,
731
748
  payee: channel.payee,
732
749
  token: channel.token,
@@ -734,7 +751,7 @@ async function handleVoucher(
734
751
  deposit: channel.deposit,
735
752
  settled: channel.settledOnChain,
736
753
  }
737
- }
754
+ })()
738
755
 
739
756
  return verifyAndAcceptVoucher({
740
757
  store,
@@ -743,7 +760,7 @@ async function handleVoucher(
743
760
  channel,
744
761
  channelId: payload.channelId,
745
762
  voucher,
746
- onChain: cachedOnChain,
763
+ onChain,
747
764
  methodDetails,
748
765
  })
749
766
  }
@@ -774,21 +791,16 @@ async function handleClose(
774
791
  payload.signature,
775
792
  )
776
793
 
777
- if (voucher.cumulativeAmount < channel.highestVoucherAmount) {
778
- throw new VerificationFailedError({
779
- reason: 'close voucher amount must be >= highest accepted voucher',
780
- })
781
- }
782
-
783
794
  const onChain = await getOnChainChannel(client, methodDetails.escrowContract, payload.channelId)
784
795
 
785
796
  if (onChain.finalized) {
786
797
  throw new ChannelClosedError({ reason: 'channel is finalized on-chain' })
787
798
  }
788
799
 
789
- if (voucher.cumulativeAmount < onChain.settled) {
800
+ const minCloseAmount = channel.spent > onChain.settled ? channel.spent : onChain.settled
801
+ if (voucher.cumulativeAmount < minCloseAmount) {
790
802
  throw new VerificationFailedError({
791
- reason: 'close voucher cumulativeAmount is below on-chain settled amount',
803
+ reason: `close voucher amount must be >= ${minCloseAmount} (max of spent and on-chain settled)`,
792
804
  })
793
805
  }
794
806
 
@@ -819,11 +831,14 @@ async function handleClose(
819
831
 
820
832
  const updated = await store.updateChannel(payload.channelId, (current) => {
821
833
  if (!current) return null
834
+ const updateVoucher = voucher.cumulativeAmount > current.highestVoucherAmount
822
835
  return {
823
836
  ...current,
824
837
  deposit: onChain.deposit,
825
- highestVoucherAmount: voucher.cumulativeAmount,
826
- highestVoucher: voucher,
838
+ ...(updateVoucher && {
839
+ highestVoucherAmount: voucher.cumulativeAmount,
840
+ highestVoucher: voucher,
841
+ }),
827
842
  finalized: true,
828
843
  }
829
844
  })
@@ -41,6 +41,7 @@ function seedChannel(
41
41
  highestVoucher: null,
42
42
  spent: 0n,
43
43
  units: 0,
44
+ closeRequestedAt: 0n,
44
45
  finalized: false,
45
46
  createdAt: new Date().toISOString(),
46
47
  }))
@@ -31,6 +31,7 @@ function seedChannel(
31
31
  highestVoucher: null,
32
32
  spent: 0n,
33
33
  units: 0,
34
+ closeRequestedAt: 0n,
34
35
  finalized: false,
35
36
  createdAt: new Date().toISOString(),
36
37
  }))
@@ -262,6 +263,34 @@ describe('sse transport', () => {
262
263
  ).toThrow('No SSE context available')
263
264
  })
264
265
 
266
+ test('respondReceipt with non-SSE upstream Response still deducts from channel', async () => {
267
+ const store = memoryStore()
268
+ await seedChannel(store, 10000000n)
269
+ const transport = sse({ store })
270
+
271
+ transport.getCredential(makeAuthorizedRequest())
272
+
273
+ const plainResponse = new Response(JSON.stringify({ content: 'hello' }), {
274
+ headers: { 'Content-Type': 'application/json' },
275
+ })
276
+
277
+ const response = transport.respondReceipt({
278
+ receipt: makeReceipt(),
279
+ response: plainResponse,
280
+ challengeId,
281
+ })
282
+
283
+ const body = await response.text()
284
+
285
+ const channel = await store.getChannel(channelId)
286
+ expect(channel!.spent).toBe(1000000n)
287
+ expect(channel!.units).toBe(1)
288
+
289
+ expect(JSON.parse(body)).toEqual({ content: 'hello' })
290
+ expect(response.headers.get('Content-Type')).toBe('application/json')
291
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
292
+ })
293
+
265
294
  test('poll: true strips waitForUpdate from store', async () => {
266
295
  const store = memoryStore()
267
296
  ;(store as any).waitForUpdate = async () => {}
@@ -6,7 +6,7 @@
6
6
  * @internal
7
7
  */
8
8
  import * as Transport from '../../../server/Transport.js'
9
- import type * as ChannelStore from '../../session/ChannelStore.js'
9
+ import * as ChannelStore from '../../session/ChannelStore.js'
10
10
  import * as Sse_core from '../../session/Sse.js'
11
11
 
12
12
  /** SSE transport with Tempo session controller. */
@@ -89,7 +89,46 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
89
89
  return Sse_core.toResponse(stream)
90
90
  }
91
91
 
92
- return base.respondReceipt({ receipt, response: response as Response, challengeId })
92
+ const baseResponse = base.respondReceipt({
93
+ receipt,
94
+ response: response as Response,
95
+ challengeId,
96
+ })
97
+
98
+ // Non-SSE response (e.g. upstream returned JSON instead of event-stream).
99
+ // Need to deduct tickCost so request isn't free.
100
+ const ctx = contextMap.get(challengeId)
101
+ if (ctx) {
102
+ contextMap.delete(challengeId)
103
+ const stream = new ReadableStream<Uint8Array>({
104
+ async start(controller) {
105
+ // deduction completes before consumer reads
106
+ await ChannelStore.deductFromChannel(store, ctx.channelId, ctx.tickCost)
107
+ if (!baseResponse.body) {
108
+ controller.close()
109
+ return
110
+ }
111
+ const reader = baseResponse.body.getReader()
112
+ try {
113
+ while (true) {
114
+ const { done, value } = await reader.read()
115
+ if (done) break
116
+ controller.enqueue(value)
117
+ }
118
+ } finally {
119
+ reader.releaseLock()
120
+ controller.close()
121
+ }
122
+ },
123
+ })
124
+ return new Response(stream, {
125
+ status: baseResponse.status,
126
+ statusText: baseResponse.statusText,
127
+ headers: baseResponse.headers,
128
+ })
129
+ }
130
+
131
+ return baseResponse
93
132
  },
94
133
  })
95
134
  }
@@ -242,6 +242,33 @@ describe('on-chain', () => {
242
242
  ).rejects.toThrow('open transaction token does not match server currency')
243
243
  })
244
244
 
245
+ test('rejects when transaction channelId does not match claimed channelId', async () => {
246
+ const salt = nextSalt()
247
+
248
+ const { serializedTransaction } = await signOpenChannel({
249
+ escrow: escrowContract,
250
+ payer,
251
+ payee: recipient,
252
+ token: currency,
253
+ deposit: 5_000_000n,
254
+ salt,
255
+ })
256
+
257
+ const fakeChannelId =
258
+ '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
259
+
260
+ await expect(
261
+ broadcastOpenTransaction({
262
+ client,
263
+ serializedTransaction,
264
+ escrowContract,
265
+ channelId: fakeChannelId,
266
+ recipient,
267
+ currency,
268
+ }),
269
+ ).rejects.toThrow('open transaction does not match claimed channelId')
270
+ })
271
+
245
272
  test('successful broadcast returns txHash and onChain state', async () => {
246
273
  const salt = nextSalt()
247
274
  const deposit = 10_000_000n
@@ -313,6 +340,59 @@ describe('on-chain', () => {
313
340
  ).rejects.toThrow('fee-sponsored open transaction contains an unauthorized call')
314
341
  })
315
342
 
343
+ test('fee-payer: rejects unsigned transaction', async () => {
344
+ const salt = nextSalt()
345
+ const deposit = 5_000_000n
346
+
347
+ const { channelId, serializedTransaction } = await signOpenChannel({
348
+ escrow: escrowContract,
349
+ payer,
350
+ payee: recipient,
351
+ token: currency,
352
+ deposit,
353
+ salt,
354
+ })
355
+
356
+ // Strip the sender signature to simulate the POC attack
357
+ const deserialized = Transaction.deserialize(
358
+ serializedTransaction as Transaction.TransactionSerializedTempo,
359
+ )
360
+ const unsigned = await Transaction.serialize({
361
+ ...deserialized,
362
+ signature: undefined,
363
+ from: undefined,
364
+ })
365
+
366
+ await expect(
367
+ broadcastOpenTransaction({
368
+ client,
369
+ serializedTransaction: unsigned,
370
+ escrowContract,
371
+ channelId,
372
+ recipient,
373
+ currency,
374
+ feePayer: accounts[0],
375
+ }),
376
+ ).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing')
377
+ })
378
+
379
+ test('fee-payer: rejects non-Tempo transaction', async () => {
380
+ const fakeEip1559 =
381
+ '0x02f8650182a5bf843b9aca00843b9aca008252089400000000000000000000000000000000000000008080c001a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' as Hex
382
+
383
+ await expect(
384
+ broadcastOpenTransaction({
385
+ client,
386
+ serializedTransaction: fakeEip1559,
387
+ escrowContract,
388
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex,
389
+ recipient,
390
+ currency,
391
+ feePayer: accounts[0],
392
+ }),
393
+ ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
394
+ })
395
+
316
396
  test('duplicate broadcast returns fallback with txHash undefined', async () => {
317
397
  const salt = nextSalt()
318
398
  const deposit = 5_000_000n
@@ -544,6 +624,70 @@ describe('on-chain', () => {
544
624
  }),
545
625
  ).rejects.toThrow('fee-sponsored topUp transaction contains an unauthorized call')
546
626
  })
627
+
628
+ test('fee-payer: rejects unsigned transaction', async () => {
629
+ const salt = nextSalt()
630
+ const deposit = 5_000_000n
631
+ const topUpAmount = 3_000_000n
632
+
633
+ const { channelId } = await openChannel({
634
+ escrow: escrowContract,
635
+ payer,
636
+ payee: recipient,
637
+ token: currency,
638
+ deposit,
639
+ salt,
640
+ })
641
+
642
+ const { serializedTransaction } = await signTopUpChannel({
643
+ escrow: escrowContract,
644
+ payer,
645
+ channelId,
646
+ token: currency,
647
+ amount: topUpAmount,
648
+ })
649
+
650
+ // Strip the sender signature to simulate the POC attack
651
+ const deserialized = Transaction.deserialize(
652
+ serializedTransaction as Transaction.TransactionSerializedTempo,
653
+ )
654
+ const unsigned = await Transaction.serialize({
655
+ ...deserialized,
656
+ signature: undefined,
657
+ from: undefined,
658
+ })
659
+
660
+ await expect(
661
+ broadcastTopUpTransaction({
662
+ client,
663
+ serializedTransaction: unsigned,
664
+ escrowContract,
665
+ channelId,
666
+ currency: asset,
667
+ declaredDeposit: topUpAmount,
668
+ previousDeposit: deposit,
669
+ feePayer: accounts[0],
670
+ }),
671
+ ).rejects.toThrow('Transaction must be signed by the sender before fee payer co-signing')
672
+ })
673
+
674
+ test('fee-payer: rejects non-Tempo transaction', async () => {
675
+ const fakeEip1559 =
676
+ '0x02f8650182a5bf843b9aca00843b9aca008252089400000000000000000000000000000000000000008080c001a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000' as Hex
677
+
678
+ await expect(
679
+ broadcastTopUpTransaction({
680
+ client,
681
+ serializedTransaction: fakeEip1559,
682
+ escrowContract,
683
+ channelId: '0x0000000000000000000000000000000000000000000000000000000000000000' as Hex,
684
+ currency: asset,
685
+ declaredDeposit: 1_000_000n,
686
+ previousDeposit: 0n,
687
+ feePayer: accounts[0],
688
+ }),
689
+ ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
690
+ })
547
691
  })
548
692
 
549
693
  describe('settleOnChain', () => {
@@ -6,7 +6,6 @@ import {
6
6
  encodeFunctionData,
7
7
  getAbiItem,
8
8
  type Hex,
9
- isAddressEqual,
10
9
  type ReadContractReturnType,
11
10
  toFunctionSelector,
12
11
  } from 'viem'
@@ -21,12 +20,27 @@ import {
21
20
  } from 'viem/actions'
22
21
  import { Transaction } from 'viem/tempo'
23
22
  import { BadRequestError, ChannelClosedError, VerificationFailedError } from '../../Errors.js'
23
+ import * as TempoAddress from '../internal/address.js'
24
24
  import * as defaults from '../internal/defaults.js'
25
+ import { isTempoTransaction } from '../internal/fee-payer.js'
26
+ import * as Channel from './Channel.js'
25
27
  import { escrowAbi } from './escrow.abi.js'
26
28
  import type { SignedVoucher } from './Types.js'
27
29
 
28
30
  export { escrowAbi }
29
31
 
32
+ /**
33
+ * Asserts that a deserialized transaction has an existing sender signature —
34
+ * required before fee payer co-signing to prevent the fee payer from becoming
35
+ * the sender.
36
+ */
37
+ function assertSenderSigned(transaction: any): void {
38
+ if (!transaction.signature || !transaction.from)
39
+ throw new BadRequestError({
40
+ reason: 'Transaction must be signed by the sender before fee payer co-signing',
41
+ })
42
+ }
43
+
30
44
  const UINT128_MAX = 2n ** 128n - 1n
31
45
 
32
46
  /**
@@ -221,13 +235,21 @@ export async function broadcastOpenTransaction(parameters: {
221
235
  waitForConfirmation = true,
222
236
  } = parameters
223
237
 
238
+ if (feePayer && !isTempoTransaction(serializedTransaction))
239
+ throw new BadRequestError({
240
+ reason: 'Only Tempo (0x76/0x78) transactions are supported',
241
+ })
242
+
224
243
  const transaction = Transaction.deserialize(
225
244
  serializedTransaction as Transaction.TransactionSerializedTempo,
226
245
  )
246
+
247
+ if (feePayer) assertSenderSigned(transaction)
248
+
227
249
  const calls = transaction.calls ?? []
228
250
 
229
251
  const openCall = calls.find((call) => {
230
- if (!call.to || !isAddressEqual(call.to, escrowContract)) return false
252
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
231
253
  if (!call.data) return false
232
254
  return call.data.slice(0, 10) === escrowOpenSelector
233
255
  })
@@ -246,8 +268,9 @@ export async function broadcastOpenTransaction(parameters: {
246
268
  }
247
269
  const selector = call.data.slice(0, 10)
248
270
  const isEscrowOpen =
249
- isAddressEqual(call.to, escrowContract) && selector === escrowOpenSelector
250
- const isTokenApprove = isAddressEqual(call.to, currency) && selector === erc20ApproveSelector
271
+ TempoAddress.isEqual(call.to, escrowContract) && selector === escrowOpenSelector
272
+ const isTokenApprove =
273
+ TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
251
274
  if (!isEscrowOpen && !isTokenApprove) {
252
275
  throw new BadRequestError({
253
276
  reason: 'fee-sponsored open transaction contains an unauthorized call',
@@ -257,7 +280,7 @@ export async function broadcastOpenTransaction(parameters: {
257
280
  }
258
281
 
259
282
  const { args: openArgs } = decodeFunctionData({ abi: escrowAbi, data: openCall.data! })
260
- const [payee, token, deposit, , authorizedSigner] = openArgs as readonly [
283
+ const [payee, token, deposit, salt, authorizedSigner] = openArgs as readonly [
261
284
  Address,
262
285
  Address,
263
286
  bigint,
@@ -265,17 +288,33 @@ export async function broadcastOpenTransaction(parameters: {
265
288
  Address,
266
289
  ]
267
290
 
268
- if (!isAddressEqual(payee, recipient)) {
291
+ if (!TempoAddress.isEqual(payee, recipient)) {
269
292
  throw new VerificationFailedError({
270
293
  reason: 'open transaction payee does not match server recipient',
271
294
  })
272
295
  }
273
- if (!isAddressEqual(token, currency)) {
296
+ if (!TempoAddress.isEqual(token, currency)) {
274
297
  throw new VerificationFailedError({
275
298
  reason: 'open transaction token does not match server currency',
276
299
  })
277
300
  }
278
301
 
302
+ if (!transaction.from) throw new BadRequestError({ reason: 'open transaction has no sender' })
303
+
304
+ const derivedChannelId = Channel.computeId({
305
+ payer: transaction.from as `0x${string}`,
306
+ payee,
307
+ token,
308
+ salt,
309
+ authorizedSigner,
310
+ escrowContract,
311
+ chainId: client.chain!.id,
312
+ })
313
+ if (derivedChannelId.toLowerCase() !== channelId.toLowerCase())
314
+ throw new VerificationFailedError({
315
+ reason: 'open transaction does not match claimed channelId',
316
+ })
317
+
279
318
  const resolvedFeeToken =
280
319
  transaction.feeToken ?? defaults.currency[client.chain?.id as keyof typeof defaults.currency]
281
320
 
@@ -364,13 +403,21 @@ export async function broadcastTopUpTransaction(parameters: {
364
403
  feePayer,
365
404
  } = parameters
366
405
 
406
+ if (feePayer && !isTempoTransaction(serializedTransaction))
407
+ throw new BadRequestError({
408
+ reason: 'Only Tempo (0x76/0x78) transactions are supported',
409
+ })
410
+
367
411
  const transaction = Transaction.deserialize(
368
412
  serializedTransaction as Transaction.TransactionSerializedTempo,
369
413
  )
414
+
415
+ if (feePayer) assertSenderSigned(transaction)
416
+
370
417
  const calls = transaction.calls ?? []
371
418
 
372
419
  const topUpCall = calls.find((call) => {
373
- if (!call.to || !isAddressEqual(call.to, escrowContract)) return false
420
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
374
421
  if (!call.data) return false
375
422
  return call.data.slice(0, 10) === escrowTopUpSelector
376
423
  })
@@ -389,8 +436,9 @@ export async function broadcastTopUpTransaction(parameters: {
389
436
  }
390
437
  const selector = call.data.slice(0, 10)
391
438
  const isEscrowTopUp =
392
- isAddressEqual(call.to, escrowContract) && selector === escrowTopUpSelector
393
- const isTokenApprove = isAddressEqual(call.to, currency) && selector === erc20ApproveSelector
439
+ TempoAddress.isEqual(call.to, escrowContract) && selector === escrowTopUpSelector
440
+ const isTokenApprove =
441
+ TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
394
442
  if (!isEscrowTopUp && !isTokenApprove) {
395
443
  throw new BadRequestError({
396
444
  reason: 'fee-sponsored topUp transaction contains an unauthorized call',
@@ -22,6 +22,7 @@ function makeChannel(overrides?: Partial<ChannelStore.State>): ChannelStore.Stat
22
22
  highestVoucher: null,
23
23
  spent: 0n,
24
24
  units: 0,
25
+ closeRequestedAt: 0n,
25
26
  finalized: false,
26
27
  createdAt: '2025-01-01T00:00:00.000Z',
27
28
  ...overrides,
@@ -239,6 +240,15 @@ describe('ChannelStore.deductFromChannel', () => {
239
240
  )
240
241
  })
241
242
 
243
+ test('rejects deduction when channel is finalized', async () => {
244
+ const cs = ChannelStore.fromStore(Store.memory())
245
+ await seedChannel(cs, { highestVoucherAmount: 10_000_000n, spent: 0n, finalized: true })
246
+
247
+ const result = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n)
248
+ expect(result.ok).toBe(false)
249
+ expect(result.channel.spent).toBe(0n)
250
+ })
251
+
242
252
  test('exact balance succeeds', async () => {
243
253
  const cs = ChannelStore.fromStore(Store.memory())
244
254
  await seedChannel(cs, { highestVoucherAmount: 1_000_000n, spent: 0n })
@@ -27,6 +27,8 @@ export interface State {
27
27
  escrowContract: Address
28
28
  /** Unique identifier for this payment channel. */
29
29
  channelId: Hex
30
+ /** On-chain timestamp when a force-close was requested (0n if not requested). */
31
+ closeRequestedAt: bigint
30
32
  /** ISO 8601 timestamp when the channel was created. */
31
33
  createdAt: string
32
34
  /** Current on-chain deposit in the escrow contract. */
@@ -107,6 +109,7 @@ export async function deductFromChannel(
107
109
  const channel = await store.updateChannel(channelId, (current) => {
108
110
  deducted = false
109
111
  if (!current) return null
112
+ if (current.finalized) return current
110
113
  if (current.highestVoucherAmount - current.spent >= amount) {
111
114
  deducted = true
112
115
  return { ...current, spent: current.spent + amount, units: current.units + 1 }
@@ -165,9 +168,9 @@ export function fromStore(store: Store.Store): ChannelStore {
165
168
  )
166
169
 
167
170
  try {
168
- const current = await store.get<State | null>(channelId)
171
+ const current = (await store.get(channelId)) as State | null
169
172
  const next = fn(current)
170
- if (next) await store.put(channelId, next)
173
+ if (next) await store.put(channelId, next as never)
171
174
  else await store.delete(channelId)
172
175
  return next
173
176
  } finally {
@@ -178,7 +181,7 @@ export function fromStore(store: Store.Store): ChannelStore {
178
181
 
179
182
  const cs: ChannelStore = {
180
183
  async getChannel(channelId) {
181
- return store.get<State | null>(channelId)
184
+ return (await store.get(channelId)) as State | null
182
185
  },
183
186
  async updateChannel(channelId, fn) {
184
187
  const result = await update(channelId, fn)
@@ -226,6 +226,7 @@ describe('serve', () => {
226
226
  highestVoucher: null,
227
227
  spent: 0n,
228
228
  units: 0,
229
+ closeRequestedAt: 0n,
229
230
  finalized: false,
230
231
  createdAt: new Date().toISOString(),
231
232
  }))