mppx 0.6.13 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  3. package/dist/cli/plugins/tempo.js +24 -1
  4. package/dist/cli/plugins/tempo.js.map +1 -1
  5. package/dist/middlewares/express.d.ts.map +1 -1
  6. package/dist/middlewares/express.js +22 -0
  7. package/dist/middlewares/express.js.map +1 -1
  8. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  9. package/dist/tempo/client/SessionManager.js +26 -1
  10. package/dist/tempo/client/SessionManager.js.map +1 -1
  11. package/dist/tempo/internal/fee-payer.d.ts +11 -1
  12. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  13. package/dist/tempo/internal/fee-payer.js +71 -6
  14. package/dist/tempo/internal/fee-payer.js.map +1 -1
  15. package/dist/tempo/server/Charge.d.ts.map +1 -1
  16. package/dist/tempo/server/Charge.js +53 -10
  17. package/dist/tempo/server/Charge.js.map +1 -1
  18. package/dist/tempo/server/Session.d.ts.map +1 -1
  19. package/dist/tempo/server/Session.js +76 -29
  20. package/dist/tempo/server/Session.js.map +1 -1
  21. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  22. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  25. package/dist/tempo/server/internal/request-body.d.ts +1 -1
  26. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  27. package/dist/tempo/server/internal/request-body.js +3 -0
  28. package/dist/tempo/server/internal/request-body.js.map +1 -1
  29. package/dist/tempo/server/internal/transport.d.ts +7 -0
  30. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  31. package/dist/tempo/server/internal/transport.js +16 -0
  32. package/dist/tempo/server/internal/transport.js.map +1 -1
  33. package/dist/tempo/session/Chain.d.ts +1 -0
  34. package/dist/tempo/session/Chain.d.ts.map +1 -1
  35. package/dist/tempo/session/Chain.js +28 -11
  36. package/dist/tempo/session/Chain.js.map +1 -1
  37. package/dist/tempo/session/Sse.d.ts +1 -0
  38. package/dist/tempo/session/Sse.d.ts.map +1 -1
  39. package/dist/tempo/session/Sse.js +19 -12
  40. package/dist/tempo/session/Sse.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/cli/plugins/tempo.ts +28 -1
  43. package/src/middlewares/express.test.ts +27 -0
  44. package/src/middlewares/express.ts +24 -0
  45. package/src/tempo/client/SessionManager.ts +26 -1
  46. package/src/tempo/internal/fee-payer.test.ts +139 -0
  47. package/src/tempo/internal/fee-payer.ts +85 -6
  48. package/src/tempo/server/Charge.test.ts +119 -0
  49. package/src/tempo/server/Charge.ts +70 -10
  50. package/src/tempo/server/Session.test.ts +327 -0
  51. package/src/tempo/server/Session.ts +88 -39
  52. package/src/tempo/server/internal/html.gen.ts +1 -1
  53. package/src/tempo/server/internal/request-body.test.ts +26 -0
  54. package/src/tempo/server/internal/request-body.ts +4 -1
  55. package/src/tempo/server/internal/transport.test.ts +28 -2
  56. package/src/tempo/server/internal/transport.ts +23 -0
  57. package/src/tempo/session/Chain.test.ts +140 -1
  58. package/src/tempo/session/Chain.ts +34 -10
  59. package/src/tempo/session/Sse.test.ts +25 -0
  60. package/src/tempo/session/Sse.ts +9 -2
@@ -11,6 +11,7 @@ import { Base64, Secp256k1 } from 'ox'
11
11
  import {
12
12
  type Address,
13
13
  createClient,
14
+ custom,
14
15
  type Hex,
15
16
  parseSignature,
16
17
  serializeCompactSignature,
@@ -258,6 +259,54 @@ describe.runIf(isLocalnet)('session', () => {
258
259
  ).rejects.toThrow('invalid voucher signature')
259
260
  })
260
261
 
262
+ test('rejects sponsored open with invalid voucher before broadcasting', async () => {
263
+ const rpcMethods: string[] = []
264
+ const interceptingClient = createClient({
265
+ account: recipientAccount,
266
+ chain,
267
+ transport: custom({
268
+ async request(args: any) {
269
+ rpcMethods.push(args.method)
270
+ return client.transport.request(args)
271
+ },
272
+ }),
273
+ })
274
+
275
+ const salt = nextSalt()
276
+ const { channelId, serializedTransaction } = await signOpenChannel({
277
+ escrow: escrowContract,
278
+ payer,
279
+ payee: recipient,
280
+ token: currency,
281
+ deposit: 10000000n,
282
+ salt,
283
+ feePayer: true,
284
+ })
285
+ const server = createServer({
286
+ feePayer: recipientAccount,
287
+ getClient: () => interceptingClient,
288
+ })
289
+
290
+ await expect(
291
+ server.verify({
292
+ credential: {
293
+ challenge: makeChallenge({ channelId }),
294
+ payload: {
295
+ action: 'open' as const,
296
+ type: 'transaction' as const,
297
+ channelId,
298
+ transaction: serializedTransaction,
299
+ cumulativeAmount: '1000000',
300
+ signature: `0x${'ab'.repeat(65)}` as Hex,
301
+ },
302
+ },
303
+ request: makeRequest({ feePayer: true }),
304
+ }),
305
+ ).rejects.toThrow('invalid voucher signature')
306
+
307
+ expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
308
+ })
309
+
261
310
  test('reopen existing channel with higher voucher updates state', async () => {
262
311
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
263
312
  const server = createServer()
@@ -2083,6 +2132,10 @@ describe.runIf(isLocalnet)('session', () => {
2083
2132
  ).rejects.toThrow(
2084
2133
  `Cannot close channel ${channelId}: tx sender ${rawAccessKey.address} is not the channel payee ${recipientAccount.address}. If using an access key, pass a Tempo access-key account whose address is the payee wallet, not the raw delegated key address.`,
2085
2134
  )
2135
+
2136
+ const persisted = await store.getChannel(channelId)
2137
+ expect(persisted?.closeRequestedAt).toBe(0n)
2138
+ expect(persisted?.finalized).toBe(false)
2086
2139
  })
2087
2140
 
2088
2141
  test('sessionManager.close surfaces problem details from HTTP close failures', async () => {
@@ -2646,6 +2699,77 @@ describe.runIf(isLocalnet)('session', () => {
2646
2699
  expect(channel?.highestVoucherAmount).toBe(5000000n)
2647
2700
  expect(channel?.spent).toBe(0n)
2648
2701
  })
2702
+
2703
+ test('close marks the channel pending before on-chain close so concurrent charges fail', async () => {
2704
+ const baseStore = Store.memory()
2705
+ let pendingChargeError: unknown
2706
+ let probedPendingClose = false
2707
+
2708
+ const probingStore = Store.from<Store.AtomicStore>({
2709
+ get: (key) => baseStore.get(key),
2710
+ put: (key, value) => baseStore.put(key, value),
2711
+ delete: (key) => baseStore.delete(key),
2712
+ async update(key, fn) {
2713
+ const result = await baseStore.update(key, fn)
2714
+ const current = await baseStore.get(key)
2715
+ if (
2716
+ current &&
2717
+ typeof current === 'object' &&
2718
+ 'closeRequestedAt' in current &&
2719
+ (current as ChannelStore.State).closeRequestedAt !== 0n &&
2720
+ !(current as ChannelStore.State).finalized &&
2721
+ !probedPendingClose
2722
+ ) {
2723
+ probedPendingClose = true
2724
+ try {
2725
+ await charge(ChannelStore.fromStore(probingStore), channelId, 1000000n)
2726
+ } catch (error) {
2727
+ pendingChargeError = error
2728
+ }
2729
+ }
2730
+ return result
2731
+ },
2732
+ })
2733
+
2734
+ const probedStore = ChannelStore.fromStore(baseStore)
2735
+ const server = createServerWithStore(probingStore)
2736
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
2737
+
2738
+ await server.verify({
2739
+ credential: {
2740
+ challenge: makeChallenge({ id: 'open-before-pending-close', channelId }),
2741
+ payload: {
2742
+ action: 'open' as const,
2743
+ type: 'transaction' as const,
2744
+ channelId,
2745
+ transaction: serializedTransaction,
2746
+ cumulativeAmount: '3000000',
2747
+ signature: await signTestVoucher(channelId, 3000000n),
2748
+ },
2749
+ },
2750
+ request: makeRequest(),
2751
+ })
2752
+
2753
+ await charge(probedStore, channelId, 1000000n)
2754
+
2755
+ const receipt = await server.verify({
2756
+ credential: {
2757
+ challenge: makeChallenge({ id: 'pending-close', channelId }),
2758
+ payload: {
2759
+ action: 'close' as const,
2760
+ channelId,
2761
+ cumulativeAmount: '3000000',
2762
+ signature: await signTestVoucher(channelId, 3000000n),
2763
+ },
2764
+ },
2765
+ request: makeRequest(),
2766
+ })
2767
+
2768
+ expect(receipt.status).toBe('success')
2769
+ expect(probedPendingClose).toBe(true)
2770
+ expect(pendingChargeError).toBeInstanceOf(ChannelClosedError)
2771
+ expect((pendingChargeError as Error).message).toContain('pending close request')
2772
+ })
2649
2773
  })
2650
2774
 
2651
2775
  describe('fault tolerance', () => {
@@ -3220,6 +3344,47 @@ describe.runIf(isLocalnet)('session', () => {
3220
3344
  expect(persisted?.finalized).toBe(true)
3221
3345
  })
3222
3346
 
3347
+ test('sessionManager rejects receipts that exceed the locally signed voucher', async () => {
3348
+ const handler = createHandler()
3349
+ const route = handler.session({
3350
+ amount: '1',
3351
+ decimals: 6,
3352
+ unitType: 'token',
3353
+ })
3354
+
3355
+ const fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
3356
+ const request = new Request(input, init)
3357
+ const result = await route(request)
3358
+ if (result.status === 402) return result.challenge
3359
+
3360
+ const response = result.withReceipt(new Response('ok'))
3361
+ const receipt = deserializeSessionReceipt(response.headers.get('Payment-Receipt')!)
3362
+ return new Response(response.body, {
3363
+ status: response.status,
3364
+ statusText: response.statusText,
3365
+ headers: {
3366
+ 'Payment-Receipt': serializeSessionReceipt({
3367
+ ...receipt,
3368
+ acceptedCumulative: '3000000',
3369
+ spent: '3000000',
3370
+ }),
3371
+ },
3372
+ })
3373
+ }
3374
+
3375
+ const manager = sessionManager({
3376
+ account: payer,
3377
+ client,
3378
+ escrowContract,
3379
+ fetch,
3380
+ maxDeposit: '3',
3381
+ })
3382
+
3383
+ await expect(manager.fetch('https://api.example.com/resource')).rejects.toThrow(
3384
+ 'receipt accepted cumulative exceeds local voucher state',
3385
+ )
3386
+ })
3387
+
3223
3388
  test('does not return Payment-Receipt on verification errors', async () => {
3224
3389
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3225
3390
  const handler = createHandler()
@@ -3469,6 +3634,79 @@ describe.runIf(isLocalnet)('session', () => {
3469
3634
  expect(replay.headers.get('Payment-Receipt')).toBeNull()
3470
3635
  })
3471
3636
 
3637
+ test('default HTTP bodyless POST query flow charges instead of treating voucher as management', async () => {
3638
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
3639
+ const signature = await signTestVoucher(channelId, 1000000n)
3640
+ const handler = createHandler()
3641
+ const route = handler.session({
3642
+ amount: '1',
3643
+ decimals: 6,
3644
+ unitType: 'token',
3645
+ })
3646
+
3647
+ const first = await route(
3648
+ new Request('https://api.example.com/search?q=paid', {
3649
+ method: 'POST',
3650
+ }),
3651
+ )
3652
+ expect(first.status).toBe(402)
3653
+ if (first.status !== 402) throw new Error('expected challenge')
3654
+
3655
+ const open = await route(
3656
+ new Request('https://api.example.com/search?q=paid', {
3657
+ method: 'POST',
3658
+ headers: {
3659
+ Authorization: Credential.serialize({
3660
+ challenge: Challenge.fromResponse(first.challenge),
3661
+ payload: {
3662
+ action: 'open',
3663
+ type: 'transaction',
3664
+ channelId,
3665
+ transaction: serializedTransaction,
3666
+ cumulativeAmount: '1000000',
3667
+ signature,
3668
+ },
3669
+ }),
3670
+ },
3671
+ }),
3672
+ )
3673
+ expect(open.status).toBe(200)
3674
+ if (open.status !== 200) throw new Error('expected paid response')
3675
+ const paid = open.withReceipt(new Response('query-result'))
3676
+ expect(await paid.text()).toBe('query-result')
3677
+ const receipt = deserializeSessionReceipt(paid.headers.get('Payment-Receipt') as string)
3678
+ expect(receipt.spent).toBe('1000000')
3679
+ expect(receipt.units).toBe(1)
3680
+
3681
+ const replayChallenge = await route(
3682
+ new Request('https://api.example.com/search?q=paid', {
3683
+ method: 'POST',
3684
+ }),
3685
+ )
3686
+ expect(replayChallenge.status).toBe(402)
3687
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
3688
+
3689
+ const replay = await route(
3690
+ new Request('https://api.example.com/search?q=paid', {
3691
+ method: 'POST',
3692
+ headers: {
3693
+ Authorization: Credential.serialize({
3694
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
3695
+ payload: {
3696
+ action: 'voucher',
3697
+ channelId,
3698
+ cumulativeAmount: '1000000',
3699
+ signature,
3700
+ },
3701
+ }),
3702
+ },
3703
+ }),
3704
+ )
3705
+ expect(replay.status).toBe(402)
3706
+ if (replay.status !== 402) throw new Error('expected challenge')
3707
+ expect(replay.challenge.headers.get('Payment-Receipt')).toBeNull()
3708
+ })
3709
+
3472
3710
  test('converts amount/suggestedDeposit/minVoucherDelta with decimals=18', async () => {
3473
3711
  const handler = createHandler()
3474
3712
  const route = handler.session({
@@ -4245,6 +4483,89 @@ describe.runIf(isLocalnet)('session', () => {
4245
4483
  expect(persisted?.finalized).toBe(true)
4246
4484
  })
4247
4485
 
4486
+ test('plain-response SSE fallback precharges before running concurrent paid handlers', async () => {
4487
+ const backingStore = Store.memory()
4488
+ const route = Mppx_server.create({
4489
+ methods: [
4490
+ tempo_server.session({
4491
+ store: backingStore,
4492
+ getClient: () => client,
4493
+ account: recipientAccount,
4494
+ currency,
4495
+ escrowContract,
4496
+ chainId: chain.id,
4497
+ sse: true,
4498
+ }),
4499
+ ],
4500
+ realm: 'api.example.com',
4501
+ secretKey: 'secret',
4502
+ }).session({ amount: '1', decimals: 6, unitType: 'token' })
4503
+
4504
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
4505
+
4506
+ const openChallenge = await route(new Request('https://api.example.com/fallback'))
4507
+ expect(openChallenge.status).toBe(402)
4508
+ if (openChallenge.status !== 402) throw new Error('expected challenge')
4509
+
4510
+ const openResult = await route(
4511
+ new Request('https://api.example.com/fallback', {
4512
+ method: 'POST',
4513
+ headers: {
4514
+ Authorization: Credential.serialize({
4515
+ challenge: Challenge.fromResponse(openChallenge.challenge),
4516
+ payload: {
4517
+ action: 'open',
4518
+ type: 'transaction',
4519
+ channelId,
4520
+ transaction: serializedTransaction,
4521
+ cumulativeAmount: '1000000',
4522
+ signature: await signTestVoucher(channelId, 1000000n),
4523
+ },
4524
+ }),
4525
+ },
4526
+ }),
4527
+ )
4528
+ expect(openResult.status).toBe(200)
4529
+ if (openResult.status !== 200) throw new Error('expected open response')
4530
+ expect(openResult.withReceipt().status).toBe(204)
4531
+
4532
+ const replayChallenge = await route(new Request('https://api.example.com/fallback'))
4533
+ expect(replayChallenge.status).toBe(402)
4534
+ if (replayChallenge.status !== 402) throw new Error('expected challenge')
4535
+
4536
+ const authorization = Credential.serialize({
4537
+ challenge: Challenge.fromResponse(replayChallenge.challenge),
4538
+ payload: {
4539
+ action: 'voucher',
4540
+ channelId,
4541
+ cumulativeAmount: '1000000',
4542
+ signature: await signTestVoucher(channelId, 1000000n),
4543
+ },
4544
+ })
4545
+
4546
+ let handlerRuns = 0
4547
+ const serve = async () => {
4548
+ const result = await route(
4549
+ new Request('https://api.example.com/fallback', {
4550
+ headers: { Authorization: authorization },
4551
+ }),
4552
+ )
4553
+ if (result.status === 402) return result.challenge
4554
+ handlerRuns++
4555
+ return result.withReceipt(new Response('paid-fallback'))
4556
+ }
4557
+
4558
+ const [first, second] = await Promise.all([serve(), serve()])
4559
+ const statuses = [first.status, second.status].sort()
4560
+
4561
+ expect(statuses).toEqual([200, 402])
4562
+ expect(handlerRuns).toBe(1)
4563
+
4564
+ const persisted = await ChannelStore.fromStore(backingStore).getChannel(channelId)
4565
+ expect(persisted?.spent).toBe(1000000n)
4566
+ expect(persisted?.units).toBe(1)
4567
+ })
4568
+
4248
4569
  test('handles repeated exhaustion/resume cycles within one stream', async () => {
4249
4570
  const backingStore = Store.memory()
4250
4571
  const routeHandler = Mppx_server.create({
@@ -5437,6 +5758,12 @@ describe('session request and verify guardrails', () => {
5437
5758
  request: makeRequest({ feePayer: false }),
5438
5759
  } as never)
5439
5760
  expect(normalized.feePayer).toBeUndefined()
5761
+
5762
+ const verificationRequest = await server.request!({
5763
+ credential: { challenge: {}, payload: {} } as never,
5764
+ request: makeRequest({ feePayer: false }),
5765
+ } as never)
5766
+ expect(verificationRequest.feePayer).toBe(false)
5440
5767
  })
5441
5768
 
5442
5769
  test('request leaves escrowContract undefined when chain has no configured default', async () => {
@@ -168,9 +168,10 @@ export function session<const parameters extends session.Parameters>(
168
168
 
169
169
  // Extract feePayer.
170
170
  const resolvedFeePayer = (() => {
171
+ if (request.feePayer === false) return credential ? false : undefined
171
172
  const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer
172
- const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl)
173
- if (credential) return account
173
+ const requested = account ?? feePayer ?? feePayerUrl
174
+ if (credential) return account ?? (feePayerUrl ? true : undefined)
174
175
  if (requested) return true
175
176
  return undefined
176
177
  })()
@@ -196,7 +197,17 @@ export function session<const parameters extends session.Parameters>(
196
197
  const methodDetails = resolvedRequest.methodDetails as SessionMethodDetails
197
198
  const client = await getClient({ chainId: methodDetails.chainId })
198
199
 
199
- const resolvedFeePayer = methodDetails.feePayer === true ? feePayer : undefined
200
+ const requestAllowsFeePayer =
201
+ request.feePayer !== false &&
202
+ (request.feePayer === undefined ||
203
+ request.feePayer === true ||
204
+ typeof request.feePayer === 'object')
205
+ const resolvedFeePayer =
206
+ methodDetails.feePayer === true && requestAllowsFeePayer
207
+ ? typeof request.feePayer === 'object'
208
+ ? request.feePayer
209
+ : feePayer
210
+ : undefined
200
211
  const minVoucherDelta = parseUnits(parameters.minVoucherDelta ?? '0', decimals)
201
212
  const effectiveMinVoucherDelta = methodDetails.minVoucherDelta
202
213
  ? BigInt(methodDetails.minVoucherDelta)
@@ -268,7 +279,6 @@ export function session<const parameters extends session.Parameters>(
268
279
  // This keeps equal-voucher replays bounded by the voucher's remaining
269
280
  // balance instead of serving repeated responses for free.
270
281
  if (
271
- !parameters.sse &&
272
282
  envelope &&
273
283
  isSessionContentRequest(envelope.capturedRequest) &&
274
284
  (payload.action === 'open' || payload.action === 'voucher')
@@ -283,6 +293,7 @@ export function session<const parameters extends session.Parameters>(
283
293
  spent: charged.spent.toString(),
284
294
  units: charged.units,
285
295
  }
296
+ if (parameters.sse) sessionReceipt = Transport.markPrepaidSessionTick(sessionReceipt)
286
297
  }
287
298
 
288
299
  return sessionReceipt
@@ -625,6 +636,38 @@ async function handleOpen(
625
636
  const currency = challenge.request.currency as Address
626
637
  const amount = challenge.request.amount ? BigInt(challenge.request.amount as string) : undefined
627
638
 
639
+ const validateOpenVoucher = async (onChain: OnChainChannel) => {
640
+ validateOnChainChannel(onChain, recipient, currency, amount)
641
+
642
+ const authorizedSigner =
643
+ onChain.authorizedSigner === '0x0000000000000000000000000000000000000000'
644
+ ? onChain.payer
645
+ : onChain.authorizedSigner
646
+
647
+ if (voucher.cumulativeAmount > onChain.deposit) {
648
+ throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
649
+ }
650
+
651
+ if (voucher.cumulativeAmount <= onChain.settled) {
652
+ throw new VerificationFailedError({
653
+ reason: 'voucher cumulativeAmount is below on-chain settled amount',
654
+ })
655
+ }
656
+
657
+ const isValid = await verifyVoucher(
658
+ methodDetails.escrowContract,
659
+ methodDetails.chainId,
660
+ voucher,
661
+ authorizedSigner,
662
+ )
663
+
664
+ if (!isValid) {
665
+ throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
666
+ }
667
+
668
+ return authorizedSigner
669
+ }
670
+
628
671
  const { onChain, txHash } = await broadcastOpenTransaction({
629
672
  client,
630
673
  serializedTransaction: payload.transaction,
@@ -635,36 +678,13 @@ async function handleOpen(
635
678
  challengeExpires: challenge.expires,
636
679
  feePayerPolicy,
637
680
  feePayer,
681
+ beforeBroadcast: async (pendingOnChain) => {
682
+ await validateOpenVoucher(pendingOnChain)
683
+ },
638
684
  waitForConfirmation,
639
685
  })
640
686
 
641
- validateOnChainChannel(onChain, recipient, currency, amount)
642
-
643
- const authorizedSigner =
644
- onChain.authorizedSigner === '0x0000000000000000000000000000000000000000'
645
- ? onChain.payer
646
- : onChain.authorizedSigner
647
-
648
- if (voucher.cumulativeAmount > onChain.deposit) {
649
- throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' })
650
- }
651
-
652
- if (voucher.cumulativeAmount <= onChain.settled) {
653
- throw new VerificationFailedError({
654
- reason: 'voucher cumulativeAmount is below on-chain settled amount',
655
- })
656
- }
657
-
658
- const isValid = await verifyVoucher(
659
- methodDetails.escrowContract,
660
- methodDetails.chainId,
661
- voucher,
662
- authorizedSigner,
663
- )
664
-
665
- if (!isValid) {
666
- throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
667
- }
687
+ const authorizedSigner = await validateOpenVoucher(onChain)
668
688
 
669
689
  const updated = await store.updateChannel(channelId, (existing) => {
670
690
  if (existing) {
@@ -917,16 +937,45 @@ async function handleClose(
917
937
  throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
918
938
  }
919
939
 
920
- assertSettlementSender({
921
- operation: 'close',
922
- channelId: payload.channelId,
923
- payee: onChain.payee,
924
- sender: account?.address ?? client.account?.address,
940
+ const pendingCloseStartedAt = BigInt(Math.floor(Date.now() / 1000) || 1)
941
+ const previousCloseRequestedAt = channel.closeRequestedAt
942
+ let pendingCloseMarked = false
943
+ await store.updateChannel(channelId, (current) => {
944
+ if (!current) return null
945
+ if (current.finalized) throw new ChannelClosedError({ reason: 'channel is already finalized' })
946
+ if (current.closeRequestedAt !== 0n)
947
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' })
948
+ if (voucher.cumulativeAmount < current.spent) {
949
+ throw new VerificationFailedError({
950
+ reason: `close voucher amount must be >= ${current.spent} (spent)`,
951
+ })
952
+ }
953
+ pendingCloseMarked = true
954
+ return { ...current, closeRequestedAt: pendingCloseStartedAt }
925
955
  })
926
956
 
927
- const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
928
- ...(feePayer && account ? { feePayer, account } : { account }),
929
- })
957
+ let txHash: Hex | undefined
958
+ try {
959
+ assertSettlementSender({
960
+ operation: 'close',
961
+ channelId: payload.channelId,
962
+ payee: onChain.payee,
963
+ sender: account?.address ?? client.account?.address,
964
+ })
965
+
966
+ txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
967
+ ...(feePayer && account ? { feePayer, account } : { account }),
968
+ })
969
+ } catch (error) {
970
+ if (pendingCloseMarked) {
971
+ await store.updateChannel(channelId, (current) =>
972
+ current && current.closeRequestedAt === pendingCloseStartedAt
973
+ ? { ...current, closeRequestedAt: previousCloseRequestedAt }
974
+ : current,
975
+ )
976
+ }
977
+ throw error
978
+ }
930
979
 
931
980
  const updated = await store.updateChannel(channelId, (current) => {
932
981
  if (!current) return null