mppx 0.4.11 → 0.5.0

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 +21 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/internal/env.d.ts +1 -1
  7. package/dist/internal/env.d.ts.map +1 -1
  8. package/dist/internal/env.js +2 -6
  9. package/dist/internal/env.js.map +1 -1
  10. package/dist/internal/types.d.ts +23 -0
  11. package/dist/internal/types.d.ts.map +1 -1
  12. package/dist/server/Mppx.d.ts +1 -1
  13. package/dist/server/Mppx.d.ts.map +1 -1
  14. package/dist/server/Mppx.js +55 -7
  15. package/dist/server/Mppx.js.map +1 -1
  16. package/dist/stripe/server/Charge.d.ts.map +1 -1
  17. package/dist/stripe/server/Charge.js +3 -3
  18. package/dist/stripe/server/Charge.js.map +1 -1
  19. package/dist/tempo/Methods.d.ts +18 -0
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +28 -3
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/Charge.d.ts +24 -0
  24. package/dist/tempo/client/Charge.d.ts.map +1 -1
  25. package/dist/tempo/client/Charge.js +51 -9
  26. package/dist/tempo/client/Charge.js.map +1 -1
  27. package/dist/tempo/client/Methods.d.ts +18 -0
  28. package/dist/tempo/client/Methods.d.ts.map +1 -1
  29. package/dist/tempo/internal/account.d.ts +5 -11
  30. package/dist/tempo/internal/account.d.ts.map +1 -1
  31. package/dist/tempo/internal/charge.d.ts +20 -0
  32. package/dist/tempo/internal/charge.d.ts.map +1 -0
  33. package/dist/tempo/internal/charge.js +23 -0
  34. package/dist/tempo/internal/charge.js.map +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +15 -3
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/internal/proof.d.ts +23 -0
  39. package/dist/tempo/internal/proof.d.ts.map +1 -0
  40. package/dist/tempo/internal/proof.js +17 -0
  41. package/dist/tempo/internal/proof.js.map +1 -0
  42. package/dist/tempo/server/Charge.d.ts +20 -2
  43. package/dist/tempo/server/Charge.d.ts.map +1 -1
  44. package/dist/tempo/server/Charge.js +180 -103
  45. package/dist/tempo/server/Charge.js.map +1 -1
  46. package/dist/tempo/server/Methods.d.ts +20 -2
  47. package/dist/tempo/server/Methods.d.ts.map +1 -1
  48. package/dist/tempo/server/Methods.js +4 -1
  49. package/dist/tempo/server/Methods.js.map +1 -1
  50. package/dist/tempo/server/Session.d.ts +9 -4
  51. package/dist/tempo/server/Session.d.ts.map +1 -1
  52. package/dist/tempo/server/Session.js +18 -3
  53. package/dist/tempo/server/Session.js.map +1 -1
  54. package/dist/tempo/session/Chain.d.ts +18 -2
  55. package/dist/tempo/session/Chain.d.ts.map +1 -1
  56. package/dist/tempo/session/Chain.js +18 -14
  57. package/dist/tempo/session/Chain.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/Expires.ts +25 -0
  60. package/src/cli/cli.test.ts +230 -1
  61. package/src/internal/env.test.ts +12 -12
  62. package/src/internal/env.ts +2 -6
  63. package/src/internal/types.ts +25 -0
  64. package/src/middlewares/elysia.test.ts +127 -4
  65. package/src/middlewares/express.test.ts +120 -54
  66. package/src/middlewares/hono.test.ts +73 -34
  67. package/src/middlewares/nextjs.test.ts +159 -36
  68. package/src/server/Mppx.test.ts +373 -0
  69. package/src/server/Mppx.ts +64 -10
  70. package/src/stripe/server/Charge.ts +3 -7
  71. package/src/tempo/Methods.test.ts +105 -0
  72. package/src/tempo/Methods.ts +54 -17
  73. package/src/tempo/client/Charge.ts +67 -11
  74. package/src/tempo/internal/account.ts +7 -14
  75. package/src/tempo/internal/charge.test.ts +66 -0
  76. package/src/tempo/internal/charge.ts +43 -0
  77. package/src/tempo/internal/fee-payer.test.ts +33 -14
  78. package/src/tempo/internal/fee-payer.ts +21 -6
  79. package/src/tempo/internal/proof.test.ts +36 -0
  80. package/src/tempo/internal/proof.ts +19 -0
  81. package/src/tempo/server/Charge.test.ts +593 -1
  82. package/src/tempo/server/Charge.ts +233 -126
  83. package/src/tempo/server/Methods.ts +4 -1
  84. package/src/tempo/server/Session.test.ts +1152 -54
  85. package/src/tempo/server/Session.ts +26 -17
  86. package/src/tempo/server/internal/transport.test.ts +32 -0
  87. package/src/tempo/session/Chain.test.ts +60 -5
  88. package/src/tempo/session/Chain.ts +30 -14
  89. package/src/tempo/session/Sse.test.ts +31 -0
@@ -14,6 +14,7 @@ import {
14
14
  type Address,
15
15
  type Hex,
16
16
  parseUnits,
17
+ zeroAddress,
17
18
  type Account as viem_Account,
18
19
  type Client as viem_Client,
19
20
  } from 'viem'
@@ -30,7 +31,7 @@ import {
30
31
  VerificationFailedError,
31
32
  } from '../../Errors.js'
32
33
  import type { Challenge, Credential } from '../../index.js'
33
- import type { LooseOmit } from '../../internal/types.js'
34
+ import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
34
35
  import * as Method from '../../Method.js'
35
36
  import * as Store from '../../Store.js'
36
37
  import * as Client from '../../viem/Client.js'
@@ -82,7 +83,9 @@ type SessionMethodDetails = {
82
83
  * })
83
84
  * ```
84
85
  */
85
- export function session<const parameters extends session.Parameters>(p?: parameters) {
86
+ export function session<const parameters extends session.Parameters>(
87
+ p?: NoExtraKeys<parameters, session.Parameters>,
88
+ ) {
86
89
  const parameters = p as parameters
87
90
  const {
88
91
  amount,
@@ -340,8 +343,10 @@ export async function settle(
340
343
  channelId: Hex,
341
344
  options?: {
342
345
  escrowContract?: Address | undefined
343
- feePayer?: viem_Account | undefined
344
- },
346
+ } & (
347
+ | { feePayer: viem_Account; account: viem_Account }
348
+ | { feePayer?: undefined; account?: viem_Account | undefined }
349
+ ),
345
350
  ): Promise<Hex> {
346
351
  const channel = await store.getChannel(channelId)
347
352
  if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
@@ -354,12 +359,11 @@ export async function settle(
354
359
  if (!resolvedEscrow) throw new Error(`No escrow contract for chainId ${chainId}.`)
355
360
 
356
361
  const settledAmount = channel.highestVoucher.cumulativeAmount
357
- const txHash = await settleOnChain(
358
- client,
359
- resolvedEscrow,
360
- channel.highestVoucher,
361
- options?.feePayer,
362
- )
362
+ const txHash = await settleOnChain(client, resolvedEscrow, channel.highestVoucher, {
363
+ ...(options?.feePayer && options?.account
364
+ ? { feePayer: options.feePayer, account: options.account }
365
+ : { account: options?.account }),
366
+ })
363
367
 
364
368
  await store.updateChannel(channelId, (current) => {
365
369
  if (!current) return null
@@ -456,6 +460,15 @@ async function verifyAndAcceptVoucher(parameters: {
456
460
  if (onChain.closeRequestedAt !== 0n) {
457
461
  throw new ChannelClosedError({ reason: 'channel has a pending close request' })
458
462
  }
463
+ // Treat a zero deposit on an existing channel as settled/closed.
464
+ // During settlement the escrow contract may zero the deposit before
465
+ // setting the finalized flag, creating a brief window where
466
+ // finalized=false but deposit=0. Without this guard the voucher
467
+ // check below would return a 402 (AmountExceedsDepositError) instead
468
+ // of the correct 410 (ChannelClosedError).
469
+ if (onChain.deposit === 0n && onChain.payer !== zeroAddress) {
470
+ throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' })
471
+ }
459
472
 
460
473
  if (voucher.cumulativeAmount <= onChain.settled) {
461
474
  throw new VerificationFailedError({
@@ -843,13 +856,9 @@ async function handleClose(
843
856
  throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
844
857
  }
845
858
 
846
- const txHash = await closeOnChain(
847
- client,
848
- methodDetails.escrowContract,
849
- voucher,
850
- account,
851
- feePayer,
852
- )
859
+ const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
860
+ ...(feePayer && account ? { feePayer, account } : { account }),
861
+ })
853
862
 
854
863
  const updated = await store.updateChannel(payload.channelId, (current) => {
855
864
  if (!current) return null
@@ -82,6 +82,19 @@ function makeReceipt() {
82
82
  }
83
83
  }
84
84
 
85
+ async function readResponseText(response: Response): Promise<string> {
86
+ if (!response.body) return ''
87
+ const reader = response.body.getReader()
88
+ const decoder = new TextDecoder()
89
+ let result = ''
90
+ while (true) {
91
+ const { done, value } = await reader.read()
92
+ if (done) break
93
+ result += decoder.decode(value, { stream: true })
94
+ }
95
+ return result
96
+ }
97
+
85
98
  describe('sse transport', () => {
86
99
  test('getCredential returns null when no Authorization header', () => {
87
100
  const store = memoryStore()
@@ -152,6 +165,19 @@ describe('sse transport', () => {
152
165
  challengeId,
153
166
  })
154
167
  expect(response.headers.get('Content-Type')).toContain('text/event-stream')
168
+
169
+ const body = await readResponseText(response)
170
+ const receiptRaw = body.split('event: payment-receipt\ndata: ')[1]?.split('\n\n')[0]
171
+ const terminalReceipt = JSON.parse(receiptRaw!)
172
+
173
+ expect(response.headers.get('Payment-Receipt')).toBeNull()
174
+ expect(body).toContain('event: message\ndata: hello\n\n')
175
+ expect(body).toContain('event: message\ndata: world\n\n')
176
+ expect(body).toContain('event: payment-receipt\n')
177
+ expect(terminalReceipt.challengeId).toBe(challengeId)
178
+ expect(terminalReceipt.channelId).toBe(channelId)
179
+ expect(terminalReceipt.units).toBe(2)
180
+ expect(terminalReceipt.spent).toBe('2000000')
155
181
  })
156
182
 
157
183
  test('respondReceipt with AsyncGeneratorFunction passes stream controller', async () => {
@@ -197,6 +223,12 @@ describe('sse transport', () => {
197
223
  challengeId,
198
224
  })
199
225
  expect(response.headers.get('Content-Type')).toContain('text/event-stream')
226
+
227
+ const body = await readResponseText(response)
228
+ expect(response.headers.get('Payment-Receipt')).toBeNull()
229
+ expect(body).toContain('event: message\ndata: chunk1\n\n')
230
+ expect(body).toContain('event: message\ndata: chunk2\n\n')
231
+ expect(body).toContain('event: payment-receipt\n')
200
232
  })
201
233
 
202
234
  test('respondReceipt with plain Response delegates to base http transport', () => {
@@ -539,6 +539,41 @@ describe.runIf(isLocalnet)('on-chain', () => {
539
539
  ).rejects.toThrow('topUp transaction amount')
540
540
  })
541
541
 
542
+ test('rejects when post-broadcast deposit does not exceed declared previousDeposit', async () => {
543
+ const salt = nextSalt()
544
+ const deposit = 5_000_000n
545
+ const topUpAmount = 1_000_000n
546
+
547
+ const { channelId } = await openChannel({
548
+ escrow: escrowContract,
549
+ payer,
550
+ payee: recipient,
551
+ token: currency,
552
+ deposit,
553
+ salt,
554
+ })
555
+
556
+ const { serializedTransaction } = await signTopUpChannel({
557
+ escrow: escrowContract,
558
+ payer,
559
+ channelId,
560
+ token: currency,
561
+ amount: topUpAmount,
562
+ })
563
+
564
+ await expect(
565
+ broadcastTopUpTransaction({
566
+ client,
567
+ serializedTransaction,
568
+ escrowContract,
569
+ channelId,
570
+ currency: asset,
571
+ declaredDeposit: topUpAmount,
572
+ previousDeposit: deposit + topUpAmount,
573
+ }),
574
+ ).rejects.toThrow('channel deposit did not increase after topUp')
575
+ })
576
+
542
577
  test('successful broadcast returns txHash and newDeposit', async () => {
543
578
  const salt = nextSalt()
544
579
  const deposit = 5_000_000n
@@ -730,7 +765,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
730
765
  expect(channel.finalized).toBe(false)
731
766
  })
732
767
 
733
- test('settles a channel with fee payer', async () => {
768
+ test.todo('settles with distinct feePayer != account (fee-sponsored settle)')
769
+
770
+ test('settles with explicit account (no fee payer)', async () => {
734
771
  const salt = nextSalt()
735
772
  const deposit = 10_000_000n
736
773
  const settleAmount = 5_000_000n
@@ -752,6 +789,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
752
789
  chain.id,
753
790
  )
754
791
 
792
+ // Pass account explicitly — should use it as sender instead of client.account
755
793
  const txHash = await settleOnChain(
756
794
  client,
757
795
  escrowContract,
@@ -760,7 +798,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
760
798
  cumulativeAmount: settleAmount,
761
799
  signature,
762
800
  },
763
- accounts[0],
801
+ { account: accounts[0] },
764
802
  )
765
803
 
766
804
  expect(txHash).toBeDefined()
@@ -769,6 +807,21 @@ describe.runIf(isLocalnet)('on-chain', () => {
769
807
  expect(channel.settled).toBe(settleAmount)
770
808
  expect(channel.finalized).toBe(false)
771
809
  })
810
+
811
+ test('throws when no account available', async () => {
812
+ const noAccountClient = { chain: { id: 42431 } } as any
813
+ const dummyEscrow = '0x0000000000000000000000000000000000000001' as Address
814
+ const dummyChannelId =
815
+ '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
816
+
817
+ await expect(
818
+ settleOnChain(noAccountClient, dummyEscrow, {
819
+ channelId: dummyChannelId,
820
+ cumulativeAmount: 1_000_000n,
821
+ signature: '0xsig' as Hex,
822
+ }),
823
+ ).rejects.toThrow('no account available')
824
+ })
772
825
  })
773
826
 
774
827
  describe('closeOnChain', () => {
@@ -806,7 +859,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
806
859
  expect(channel.finalized).toBe(true)
807
860
  })
808
861
 
809
- test('closes a channel with fee payer', async () => {
862
+ test.todo('closes with distinct feePayer != account (fee-sponsored close)')
863
+
864
+ test('closes with explicit account (no fee payer)', async () => {
810
865
  const salt = nextSalt()
811
866
  const deposit = 10_000_000n
812
867
  const closeAmount = 5_000_000n
@@ -828,6 +883,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
828
883
  chain.id,
829
884
  )
830
885
 
886
+ // Pass account explicitly — should use it as sender instead of client.account
831
887
  const txHash = await closeOnChain(
832
888
  client,
833
889
  escrowContract,
@@ -836,8 +892,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
836
892
  cumulativeAmount: closeAmount,
837
893
  signature,
838
894
  },
839
- undefined,
840
- accounts[0],
895
+ { account: accounts[0] },
841
896
  )
842
897
 
843
898
  expect(txHash).toBeDefined()
@@ -93,6 +93,11 @@ function assertUint128(amount: bigint): void {
93
93
  }
94
94
  }
95
95
 
96
+ /** Options for {@link settleOnChain}. */
97
+ export type SettleOptions =
98
+ | { feePayer: Account; account: Account }
99
+ | { feePayer?: undefined; account?: Account | undefined }
100
+
96
101
  /**
97
102
  * Submit a settle transaction on-chain.
98
103
  */
@@ -100,16 +105,21 @@ export async function settleOnChain(
100
105
  client: Client,
101
106
  escrowContract: Address,
102
107
  voucher: SignedVoucher,
103
- feePayer?: Account | undefined,
108
+ options?: SettleOptions,
104
109
  ): Promise<Hex> {
105
110
  assertUint128(voucher.cumulativeAmount)
111
+ const resolved = options?.account ?? client.account
112
+ if (!resolved)
113
+ throw new Error(
114
+ 'Cannot settle channel: no account available. Pass an `account` to tempo.settle(), or provide a `getClient` that returns an account-bearing client.',
115
+ )
106
116
  const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const
107
- if (feePayer) {
117
+ if (options?.feePayer) {
108
118
  const data = encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args })
109
- return sendFeePayerTx(client, feePayer, escrowContract, data, 'settle')
119
+ return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'settle')
110
120
  }
111
121
  return writeContract(client, {
112
- account: client.account!,
122
+ account: resolved,
113
123
  chain: client.chain,
114
124
  address: escrowContract,
115
125
  abi: escrowAbi,
@@ -118,6 +128,11 @@ export async function settleOnChain(
118
128
  })
119
129
  }
120
130
 
131
+ /** Options for {@link closeOnChain}. */
132
+ export type CloseOptions =
133
+ | { feePayer: Account; account: Account }
134
+ | { feePayer?: undefined; account?: Account | undefined }
135
+
121
136
  /**
122
137
  * Submit a close transaction on-chain.
123
138
  */
@@ -125,19 +140,18 @@ export async function closeOnChain(
125
140
  client: Client,
126
141
  escrowContract: Address,
127
142
  voucher: SignedVoucher,
128
- account?: Account,
129
- feePayer?: Account | undefined,
143
+ options?: CloseOptions,
130
144
  ): Promise<Hex> {
131
145
  assertUint128(voucher.cumulativeAmount)
132
- const resolved = account ?? client.account
146
+ const resolved = options?.account ?? client.account
133
147
  if (!resolved)
134
148
  throw new Error(
135
149
  'Cannot close channel: no account available. Pass an `account` (viem Account, e.g. privateKeyToAccount("0x...")) to tempo.session(), or provide a `getClient` that returns an account-bearing client.',
136
150
  )
137
151
  const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const
138
- if (feePayer) {
152
+ if (options?.feePayer) {
139
153
  const data = encodeFunctionData({ abi: escrowAbi, functionName: 'close', args })
140
- return sendFeePayerTx(client, feePayer, escrowContract, data, 'close')
154
+ return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'close')
141
155
  }
142
156
  return writeContract(client, {
143
157
  account: resolved,
@@ -155,9 +169,13 @@ export async function closeOnChain(
155
169
  * Follows the same signTransaction + sendRawTransactionSync pattern used
156
170
  * by broadcastOpenTransaction / broadcastTopUpTransaction, but originates
157
171
  * the transaction server-side (estimating gas and fees first).
172
+ *
173
+ * @param account - The logical sender / msg.sender (e.g. the payee).
174
+ * @param feePayer - The gas sponsor — only co-signs to cover fees.
158
175
  */
159
176
  async function sendFeePayerTx(
160
177
  client: Client,
178
+ account: Account,
161
179
  feePayer: Account,
162
180
  to: Address,
163
181
  data: Hex,
@@ -167,12 +185,10 @@ async function sendFeePayerTx(
167
185
  // token. `feePayer: true` tells the prepare hook to use expiring nonces but
168
186
  // does NOT set feeToken automatically, so we must provide it explicitly.
169
187
  const chainId = client.chain?.id
170
- const feeToken = chainId
171
- ? defaults.currency[chainId as keyof typeof defaults.currency]
172
- : undefined
188
+ const feeToken = chainId ? defaults.resolveCurrency({ chainId }) : undefined
173
189
 
174
190
  const prepared = await prepareTransactionRequest(client, {
175
- account: feePayer,
191
+ account,
176
192
  calls: [{ to, data }],
177
193
  feePayer: true,
178
194
  ...(feeToken ? { feeToken } : {}),
@@ -180,7 +196,7 @@ async function sendFeePayerTx(
180
196
 
181
197
  const serialized = (await signTransaction(client, {
182
198
  ...prepared,
183
- account: feePayer,
199
+ account,
184
200
  feePayer,
185
201
  } as never)) as Hex
186
202
 
@@ -366,6 +366,37 @@ describe('serve', () => {
366
366
  expect(receipt.challengeId).toBe(challengeId)
367
367
  })
368
368
 
369
+ test('emits exactly one terminal payment-receipt event at stream end', async () => {
370
+ const storage = memoryStore()
371
+ await seedChannel(storage, 2000000n)
372
+
373
+ const stream = serve({
374
+ store: storage,
375
+ channelId,
376
+ challengeId,
377
+ tickCost: 1000000n,
378
+ generate: generate(['one', 'two']),
379
+ })
380
+
381
+ const output = await readStream(stream)
382
+ const events = output
383
+ .trim()
384
+ .split('\n\n')
385
+ .filter((chunk) => chunk.length > 0)
386
+ .map((chunk) => parseEvent(`${chunk}\n\n`))
387
+ .filter((event): event is NonNullable<typeof event> => event !== null)
388
+
389
+ const terminal = events.at(-1)
390
+ expect(terminal?.type).toBe('payment-receipt')
391
+ if (terminal?.type !== 'payment-receipt') throw new Error('expected terminal payment receipt')
392
+
393
+ expect(events.filter((event) => event.type === 'payment-receipt')).toHaveLength(1)
394
+ expect(terminal.data.challengeId).toBe(challengeId)
395
+ expect(terminal.data.channelId).toBe(channelId)
396
+ expect(terminal.data.units).toBe(2)
397
+ expect(terminal.data.spent).toBe('2000000')
398
+ })
399
+
369
400
  test('handles empty generator', async () => {
370
401
  const storage = memoryStore()
371
402
  await seedChannel(storage, 1000000n)