mppx 0.4.7 → 0.4.9

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