mppx 0.4.10 → 0.4.12

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 (74) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/internal/env.d.ts +1 -1
  3. package/dist/internal/env.d.ts.map +1 -1
  4. package/dist/internal/env.js +2 -6
  5. package/dist/internal/env.js.map +1 -1
  6. package/dist/internal/types.d.ts +23 -0
  7. package/dist/internal/types.d.ts.map +1 -1
  8. package/dist/server/Mppx.d.ts +1 -1
  9. package/dist/server/Mppx.d.ts.map +1 -1
  10. package/dist/server/Mppx.js +49 -2
  11. package/dist/server/Mppx.js.map +1 -1
  12. package/dist/stripe/internal/types.d.ts +3 -0
  13. package/dist/stripe/internal/types.d.ts.map +1 -1
  14. package/dist/stripe/server/Charge.d.ts.map +1 -1
  15. package/dist/stripe/server/Charge.js +9 -2
  16. package/dist/stripe/server/Charge.js.map +1 -1
  17. package/dist/tempo/Methods.d.ts +15 -0
  18. package/dist/tempo/Methods.d.ts.map +1 -1
  19. package/dist/tempo/Methods.js +27 -3
  20. package/dist/tempo/Methods.js.map +1 -1
  21. package/dist/tempo/client/Charge.d.ts +21 -0
  22. package/dist/tempo/client/Charge.d.ts.map +1 -1
  23. package/dist/tempo/client/Charge.js +33 -7
  24. package/dist/tempo/client/Charge.js.map +1 -1
  25. package/dist/tempo/client/Methods.d.ts +15 -0
  26. package/dist/tempo/client/Methods.d.ts.map +1 -1
  27. package/dist/tempo/internal/account.d.ts +5 -11
  28. package/dist/tempo/internal/account.d.ts.map +1 -1
  29. package/dist/tempo/internal/charge.d.ts +20 -0
  30. package/dist/tempo/internal/charge.d.ts.map +1 -0
  31. package/dist/tempo/internal/charge.js +23 -0
  32. package/dist/tempo/internal/charge.js.map +1 -0
  33. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.js +15 -3
  35. package/dist/tempo/internal/fee-payer.js.map +1 -1
  36. package/dist/tempo/server/Charge.d.ts +17 -2
  37. package/dist/tempo/server/Charge.d.ts.map +1 -1
  38. package/dist/tempo/server/Charge.js +148 -99
  39. package/dist/tempo/server/Charge.js.map +1 -1
  40. package/dist/tempo/server/Methods.d.ts +17 -2
  41. package/dist/tempo/server/Methods.d.ts.map +1 -1
  42. package/dist/tempo/server/Methods.js +4 -1
  43. package/dist/tempo/server/Methods.js.map +1 -1
  44. package/dist/tempo/server/Session.d.ts +9 -4
  45. package/dist/tempo/server/Session.d.ts.map +1 -1
  46. package/dist/tempo/server/Session.js +25 -6
  47. package/dist/tempo/server/Session.js.map +1 -1
  48. package/dist/tempo/session/Chain.d.ts +18 -2
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +18 -14
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/internal/env.test.ts +12 -12
  54. package/src/internal/env.ts +2 -6
  55. package/src/internal/types.ts +25 -0
  56. package/src/server/Mppx.test.ts +287 -0
  57. package/src/server/Mppx.ts +59 -5
  58. package/src/stripe/internal/types.ts +5 -1
  59. package/src/stripe/server/Charge.test.ts +52 -1
  60. package/src/stripe/server/Charge.ts +12 -4
  61. package/src/tempo/Methods.test.ts +79 -0
  62. package/src/tempo/Methods.ts +53 -17
  63. package/src/tempo/client/Charge.ts +41 -8
  64. package/src/tempo/internal/account.ts +7 -14
  65. package/src/tempo/internal/charge.ts +43 -0
  66. package/src/tempo/internal/fee-payer.test.ts +33 -14
  67. package/src/tempo/internal/fee-payer.ts +21 -6
  68. package/src/tempo/server/Charge.test.ts +231 -0
  69. package/src/tempo/server/Charge.ts +193 -124
  70. package/src/tempo/server/Methods.ts +4 -1
  71. package/src/tempo/server/Session.test.ts +57 -0
  72. package/src/tempo/server/Session.ts +33 -20
  73. package/src/tempo/session/Chain.test.ts +25 -5
  74. package/src/tempo/session/Chain.ts +30 -14
@@ -1,4 +1,3 @@
1
- import type { TempoAddress as TempoAddress_types } from 'ox/tempo'
2
1
  import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem'
3
2
  import {
4
3
  getTransactionReceipt,
@@ -11,12 +10,13 @@ import { tempo as tempo_chain } from 'viem/chains'
11
10
  import { Abis, Transaction } from 'viem/tempo'
12
11
 
13
12
  import { PaymentExpiredError } from '../../Errors.js'
14
- import type { LooseOmit } from '../../internal/types.js'
13
+ import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
15
14
  import * as Method from '../../Method.js'
16
15
  import * as Store from '../../Store.js'
17
16
  import * as Client from '../../viem/Client.js'
18
17
  import * as Account from '../internal/account.js'
19
18
  import * as TempoAddress from '../internal/address.js'
19
+ import * as Charge_internal from '../internal/charge.js'
20
20
  import * as defaults from '../internal/defaults.js'
21
21
  import * as FeePayer from '../internal/fee-payer.js'
22
22
  import * as Selectors from '../internal/selectors.js'
@@ -34,7 +34,10 @@ import * as Methods from '../Methods.js'
34
34
  * ```
35
35
  */
36
36
  export function charge<const parameters extends charge.Parameters>(
37
- parameters: parameters = {} as parameters,
37
+ parameters: NoExtraKeys<parameters, charge.Parameters> = {} as NoExtraKeys<
38
+ parameters,
39
+ charge.Parameters
40
+ >,
38
41
  ) {
39
42
  const {
40
43
  amount,
@@ -126,16 +129,12 @@ export function charge<const parameters extends charge.Parameters>(
126
129
  const hash = payload.hash as `0x${string}`
127
130
  await assertHashUnused(store, hash)
128
131
 
129
- const receipt = await getTransactionReceipt(client, {
130
- hash,
131
- })
132
-
133
- assertTransferLog(receipt, {
134
- amount,
132
+ const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
133
+ const receipt = await getTransactionReceipt(client, { hash })
134
+ assertTransferLogs(receipt, {
135
135
  currency,
136
- from: receipt.from,
137
- memo,
138
- recipient,
136
+ sender: receipt.from,
137
+ transfers: expectedTransfers,
139
138
  })
140
139
 
141
140
  await markHashUsed(store, hash)
@@ -161,58 +160,15 @@ export function charge<const parameters extends charge.Parameters>(
161
160
  {},
162
161
  )
163
162
 
164
- const call = transaction.calls.find((call) => {
165
- if (!call.to || !TempoAddress.isEqual(call.to, currency)) return false
166
- if (!call.data) return false
167
-
168
- const selector = call.data.slice(0, 10)
169
-
170
- if (memo) {
171
- if (selector !== Selectors.transferWithMemo) return false
172
- try {
173
- const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
174
- const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`]
175
- return (
176
- TempoAddress.isEqual(to, recipient) &&
177
- amount_.toString() === amount &&
178
- memo_.toLowerCase() === memo.toLowerCase()
179
- )
180
- } catch {
181
- return false
182
- }
183
- }
184
-
185
- if (selector === Selectors.transfer) {
186
- try {
187
- const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
188
- const [to, amount_] = args as [`0x${string}`, bigint]
189
- return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
190
- } catch {
191
- return false
192
- }
193
- }
194
-
195
- if (selector === Selectors.transferWithMemo) {
196
- try {
197
- const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
198
- const [to, amount_] = args as [`0x${string}`, bigint, `0x${string}`]
199
- return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount
200
- } catch {
201
- return false
202
- }
203
- }
204
-
205
- return false
206
- })
163
+ const calls = (transaction.calls ?? []) as readonly {
164
+ data?: `0x${string}` | undefined
165
+ to?: `0x${string}` | undefined
166
+ }[]
167
+ const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
168
+ const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
169
+ assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers })
207
170
 
208
- if (!call)
209
- throw new MismatchError('Invalid transaction: no matching payment call found', {
210
- amount,
211
- currency,
212
- recipient,
213
- })
214
-
215
- if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false)
171
+ if (isFeePayerTx)
216
172
  FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
217
173
 
218
174
  const resolvedFeeToken =
@@ -234,12 +190,10 @@ export function charge<const parameters extends charge.Parameters>(
234
190
  const receipt = await sendRawTransactionSync(client, {
235
191
  serializedTransaction: serializedTransaction_final,
236
192
  })
237
- assertTransferLog(receipt, {
238
- amount,
193
+ assertTransferLogs(receipt, {
239
194
  currency,
240
- from: transaction.from,
241
- memo,
242
- recipient,
195
+ sender: transaction.from! as `0x${string}`,
196
+ transfers,
243
197
  })
244
198
  // Post-broadcast dedup: catch malleable input variants
245
199
  // (different serialized bytes, same underlying tx) that
@@ -323,72 +277,187 @@ export declare namespace charge {
323
277
  }
324
278
  }
325
279
 
326
- /** @internal */
327
- function assertTransferLog(
328
- receipt: TransactionReceipt,
280
+ type ExpectedTransfer = {
281
+ amount: string
282
+ allowAnyMemo?: boolean | undefined
283
+ memo?: `0x${string}` | undefined
284
+ recipient: `0x${string}`
285
+ }
286
+
287
+ function getExpectedTransfers(parameters: {
288
+ amount: string
289
+ memo: `0x${string}` | undefined
290
+ methodDetails: { splits?: readonly Charge_internal.Split[] | undefined } | undefined
291
+ recipient: `0x${string}`
292
+ }): ExpectedTransfer[] {
293
+ return Charge_internal.getTransfers({
294
+ amount: parameters.amount,
295
+ methodDetails: {
296
+ memo: parameters.memo,
297
+ splits: parameters.methodDetails?.splits,
298
+ },
299
+ recipient: parameters.recipient,
300
+ }).map((transfer) => ({
301
+ ...transfer,
302
+ ...(!transfer.memo ? { allowAnyMemo: true } : {}),
303
+ })) as ExpectedTransfer[]
304
+ }
305
+
306
+ function assertTransferCalls(
307
+ calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
329
308
  parameters: {
330
- amount: string
331
- currency: TempoAddress_types.Address
332
- from: TempoAddress_types.Address
333
- memo: `0x${string}` | undefined
334
- recipient: TempoAddress_types.Address
309
+ currency: `0x${string}`
310
+ exactCount?: boolean | undefined
311
+ transfers: readonly ExpectedTransfer[]
335
312
  },
336
- ): void {
337
- const { amount, currency, from, memo, recipient } = parameters
338
-
339
- if (memo) {
340
- const memoLogs = parseEventLogs({
341
- abi: Abis.tip20,
342
- eventName: 'TransferWithMemo',
343
- logs: receipt.logs,
344
- })
313
+ ) {
314
+ const transferCalls = getTransferCalls(calls)
345
315
 
346
- const match = memoLogs.find(
347
- (log) =>
348
- TempoAddress.isEqual(log.address, currency) &&
349
- TempoAddress.isEqual(log.args.from, from) &&
350
- TempoAddress.isEqual(log.args.to, recipient) &&
351
- log.args.amount.toString() === amount &&
352
- log.args.memo.toLowerCase() === memo.toLowerCase(),
353
- )
354
-
355
- if (!match)
356
- throw new MismatchError(
357
- 'Payment verification failed: no matching transfer with memo found.',
358
- {
359
- amount,
360
- currency,
361
- memo,
362
- recipient,
363
- },
364
- )
365
- } else {
366
- const transferLogs = parseEventLogs({
367
- abi: Abis.tip20,
368
- eventName: 'Transfer',
369
- logs: receipt.logs,
316
+ if (parameters.exactCount && transferCalls.length !== parameters.transfers.length)
317
+ throw new MismatchError('Invalid transaction: no matching payment call found', {
318
+ expectedCalls: String(parameters.transfers.length),
319
+ actualCalls: String(transferCalls.length),
370
320
  })
371
321
 
372
- const memoLogs = parseEventLogs({
373
- abi: Abis.tip20,
374
- eventName: 'TransferWithMemo',
375
- logs: receipt.logs,
322
+ const used = new Set<number>()
323
+
324
+ // Match memo-specific transfers before wildcards to avoid greedy
325
+ // consumption of memo-bearing calls by allowAnyMemo entries.
326
+ const sorted = [...parameters.transfers].sort((a, b) => {
327
+ if (a.memo && !b.memo) return -1
328
+ if (!a.memo && b.memo) return 1
329
+ return 0
330
+ })
331
+
332
+ for (const expected of sorted) {
333
+ const matchIndex = transferCalls.findIndex((call, index) => {
334
+ if (used.has(index)) return false
335
+ const decoded = decodeTransferCall(call, parameters.currency)
336
+ if (!decoded) return false
337
+
338
+ if (!TempoAddress.isEqual(decoded.recipient, expected.recipient)) return false
339
+ if (decoded.amount !== expected.amount) return false
340
+ if (expected.memo) {
341
+ return decoded.memo?.toLowerCase() === expected.memo.toLowerCase()
342
+ }
343
+ if (expected.allowAnyMemo) return true
344
+ return decoded.memo === undefined
376
345
  })
377
346
 
378
- const match = [...transferLogs, ...memoLogs].find(
379
- (log) =>
380
- TempoAddress.isEqual(log.address, currency) &&
381
- TempoAddress.isEqual(log.args.from, from) &&
382
- TempoAddress.isEqual(log.args.to, recipient) &&
383
- log.args.amount.toString() === amount,
384
- )
347
+ if (matchIndex === -1) {
348
+ throw new MismatchError('Invalid transaction: no matching payment call found', {
349
+ amount: expected.amount,
350
+ currency: parameters.currency,
351
+ recipient: expected.recipient,
352
+ })
353
+ }
354
+
355
+ used.add(matchIndex)
356
+ }
357
+ }
358
+
359
+ function getTransferCalls(
360
+ calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
361
+ ) {
362
+ const selectors = calls.map((call) => call.data?.slice(0, 10))
363
+ const offset =
364
+ selectors[0] === Selectors.approve && selectors[1] === Selectors.swapExactAmountOut ? 2 : 0
365
+ const transferCalls = calls.slice(offset)
366
+
367
+ if (
368
+ transferCalls.length === 0 ||
369
+ selectors
370
+ .slice(offset)
371
+ .some(
372
+ (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo,
373
+ )
374
+ ) {
375
+ throw new MismatchError('Invalid transaction: no matching payment call found', {})
376
+ }
377
+
378
+ return transferCalls
379
+ }
380
+
381
+ function decodeTransferCall(
382
+ call: { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined },
383
+ currency: `0x${string}`,
384
+ ) {
385
+ if (!call.to || !TempoAddress.isEqual(call.to, currency) || !call.data) return null
386
+
387
+ try {
388
+ const selector = call.data.slice(0, 10)
389
+ if (selector === Selectors.transfer) {
390
+ const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
391
+ const [recipient, amount] = args as [`0x${string}`, bigint]
392
+ return { amount: amount.toString(), recipient }
393
+ }
394
+
395
+ if (selector === Selectors.transferWithMemo) {
396
+ const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data })
397
+ const [recipient, amount, memo] = args as [`0x${string}`, bigint, `0x${string}`]
398
+ return { amount: amount.toString(), memo, recipient }
399
+ }
400
+ } catch {
401
+ return null
402
+ }
403
+
404
+ return null
405
+ }
385
406
 
386
- if (!match)
407
+ function assertTransferLogs(
408
+ receipt: TransactionReceipt,
409
+ parameters: {
410
+ currency: `0x${string}`
411
+ sender: `0x${string}`
412
+ transfers: readonly ExpectedTransfer[]
413
+ },
414
+ ) {
415
+ const transferLogs = parseEventLogs({
416
+ abi: Abis.tip20,
417
+ eventName: 'Transfer',
418
+ logs: receipt.logs,
419
+ }).map((log) => ({ ...log, kind: 'transfer' as const }))
420
+
421
+ const memoLogs = parseEventLogs({
422
+ abi: Abis.tip20,
423
+ eventName: 'TransferWithMemo',
424
+ logs: receipt.logs,
425
+ }).map((log) => ({ ...log, kind: 'memo' as const }))
426
+
427
+ const logs = [...transferLogs, ...memoLogs]
428
+ const used = new Set<number>()
429
+
430
+ // Match memo-specific transfers before wildcards to avoid greedy
431
+ // consumption of memo-bearing logs by allowAnyMemo entries.
432
+ const sorted = [...parameters.transfers].sort((a, b) => {
433
+ if (a.memo && !b.memo) return -1
434
+ if (!a.memo && b.memo) return 1
435
+ return 0
436
+ })
437
+
438
+ for (const transfer of sorted) {
439
+ const matchIndex = logs.findIndex((log, index) => {
440
+ if (used.has(index)) return false
441
+ if (!TempoAddress.isEqual(log.address, parameters.currency)) return false
442
+ if (!TempoAddress.isEqual(log.args.from, parameters.sender)) return false
443
+ if (!TempoAddress.isEqual(log.args.to, transfer.recipient)) return false
444
+ if (log.args.amount.toString() !== transfer.amount) return false
445
+ if (transfer.memo) {
446
+ return log.kind === 'memo' && log.args.memo.toLowerCase() === transfer.memo.toLowerCase()
447
+ }
448
+ if (transfer.allowAnyMemo) return log.kind === 'transfer' || log.kind === 'memo'
449
+ return log.kind === 'transfer'
450
+ })
451
+
452
+ if (matchIndex === -1) {
387
453
  throw new MismatchError('Payment verification failed: no matching transfer found.', {
388
- amount,
389
- currency,
390
- recipient,
454
+ amount: transfer.amount,
455
+ currency: parameters.currency,
456
+ recipient: transfer.recipient,
391
457
  })
458
+ }
459
+
460
+ used.add(matchIndex)
392
461
  }
393
462
  }
394
463
 
@@ -14,7 +14,10 @@ import { session as session_, settle as settle_ } from './Session.js'
14
14
  * ```
15
15
  */
16
16
  export function tempo<const parameters extends tempo.Parameters>(parameters?: parameters) {
17
- return [tempo.charge(parameters), tempo.session(parameters)] as const
17
+ return [
18
+ tempo.charge(parameters as charge_.Parameters as never),
19
+ tempo.session(parameters as session_.Parameters as never),
20
+ ] as const
18
21
  }
19
22
 
20
23
  export namespace tempo {
@@ -885,6 +885,34 @@ describe.runIf(isLocalnet)('session', () => {
885
885
  }),
886
886
  ).rejects.toThrow(ChannelClosedError)
887
887
  })
888
+
889
+ test('rejects voucher when deposit is zero (settled race window)', async () => {
890
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
891
+ // Use a large TTL so the voucher path uses the cached store state
892
+ // instead of reading on-chain. This lets us simulate the settlement
893
+ // race where deposit=0 but finalized=false by manipulating the store.
894
+ const server = createServer({ channelStateTtl: 60_000 })
895
+ await openServerChannel(server, channelId, serializedTransaction)
896
+
897
+ // Simulate the escrow contract zeroing the deposit before setting
898
+ // finalized (the race window this PR guards against).
899
+ await store.updateChannel(channelId, (ch) => (ch ? { ...ch, deposit: 0n } : null))
900
+
901
+ await expect(
902
+ server.verify({
903
+ credential: {
904
+ challenge: makeChallenge({ id: 'challenge-after-settle', channelId }),
905
+ payload: {
906
+ action: 'voucher' as const,
907
+ channelId,
908
+ cumulativeAmount: '2000000',
909
+ signature: await signTestVoucher(channelId, 2000000n),
910
+ },
911
+ },
912
+ request: makeRequest(),
913
+ }),
914
+ ).rejects.toThrow(ChannelClosedError)
915
+ })
888
916
  })
889
917
 
890
918
  describe('topUp', () => {
@@ -1143,6 +1171,35 @@ describe.runIf(isLocalnet)('session', () => {
1143
1171
  ).rejects.toThrow('close voucher amount must be >=')
1144
1172
  })
1145
1173
 
1174
+ test('rejects close equal to on-chain settled amount', async () => {
1175
+ const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1176
+ const server = createServer()
1177
+
1178
+ // Open with 1M voucher (matches openServerChannel default)
1179
+ await openServerChannel(server, channelId, serializedTransaction)
1180
+
1181
+ // Settle on-chain so settled becomes 1000000
1182
+ const settleTxHash = await settle(store, client, channelId, { escrowContract })
1183
+ await waitForTransactionReceipt(client, { hash: settleTxHash })
1184
+
1185
+ // Try to close with voucher == on-chain settled — should be rejected
1186
+ // because replaying the settled amount doesn't commit new funds
1187
+ await expect(
1188
+ server.verify({
1189
+ credential: {
1190
+ challenge: makeChallenge({ id: 'challenge-2', channelId }),
1191
+ payload: {
1192
+ action: 'close' as const,
1193
+ channelId,
1194
+ cumulativeAmount: '1000000',
1195
+ signature: await signTestVoucher(channelId, 1000000n),
1196
+ },
1197
+ },
1198
+ request: makeRequest(),
1199
+ }),
1200
+ ).rejects.toThrow('close voucher amount must be >')
1201
+ })
1202
+
1146
1203
  test('rejects close exceeding on-chain deposit', async () => {
1147
1204
  const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
1148
1205
  const server = createServer()
@@ -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({
@@ -815,10 +828,14 @@ async function handleClose(
815
828
  throw new ChannelClosedError({ reason: 'channel is finalized on-chain' })
816
829
  }
817
830
 
818
- const minCloseAmount = channel.spent > onChain.settled ? channel.spent : onChain.settled
819
- if (voucher.cumulativeAmount < minCloseAmount) {
831
+ if (voucher.cumulativeAmount < channel.spent) {
832
+ throw new VerificationFailedError({
833
+ reason: `close voucher amount must be >= ${channel.spent} (spent)`,
834
+ })
835
+ }
836
+ if (voucher.cumulativeAmount <= onChain.settled) {
820
837
  throw new VerificationFailedError({
821
- reason: `close voucher amount must be >= ${minCloseAmount} (max of spent and on-chain settled)`,
838
+ reason: `close voucher amount must be > ${onChain.settled} (on-chain settled)`,
822
839
  })
823
840
  }
824
841
 
@@ -839,13 +856,9 @@ async function handleClose(
839
856
  throw new InvalidSignatureError({ reason: 'invalid voucher signature' })
840
857
  }
841
858
 
842
- const txHash = await closeOnChain(
843
- client,
844
- methodDetails.escrowContract,
845
- voucher,
846
- account,
847
- feePayer,
848
- )
859
+ const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
860
+ ...(feePayer && account ? { feePayer, account } : { account }),
861
+ })
849
862
 
850
863
  const updated = await store.updateChannel(payload.channelId, (current) => {
851
864
  if (!current) return null
@@ -730,7 +730,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
730
730
  expect(channel.finalized).toBe(false)
731
731
  })
732
732
 
733
- test('settles a channel with fee payer', async () => {
733
+ test.todo('settles with distinct feePayer != account (fee-sponsored settle)')
734
+
735
+ test('settles with explicit account (no fee payer)', async () => {
734
736
  const salt = nextSalt()
735
737
  const deposit = 10_000_000n
736
738
  const settleAmount = 5_000_000n
@@ -752,6 +754,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
752
754
  chain.id,
753
755
  )
754
756
 
757
+ // Pass account explicitly — should use it as sender instead of client.account
755
758
  const txHash = await settleOnChain(
756
759
  client,
757
760
  escrowContract,
@@ -760,7 +763,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
760
763
  cumulativeAmount: settleAmount,
761
764
  signature,
762
765
  },
763
- accounts[0],
766
+ { account: accounts[0] },
764
767
  )
765
768
 
766
769
  expect(txHash).toBeDefined()
@@ -769,6 +772,21 @@ describe.runIf(isLocalnet)('on-chain', () => {
769
772
  expect(channel.settled).toBe(settleAmount)
770
773
  expect(channel.finalized).toBe(false)
771
774
  })
775
+
776
+ test('throws when no account available', async () => {
777
+ const noAccountClient = { chain: { id: 42431 } } as any
778
+ const dummyEscrow = '0x0000000000000000000000000000000000000001' as Address
779
+ const dummyChannelId =
780
+ '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
781
+
782
+ await expect(
783
+ settleOnChain(noAccountClient, dummyEscrow, {
784
+ channelId: dummyChannelId,
785
+ cumulativeAmount: 1_000_000n,
786
+ signature: '0xsig' as Hex,
787
+ }),
788
+ ).rejects.toThrow('no account available')
789
+ })
772
790
  })
773
791
 
774
792
  describe('closeOnChain', () => {
@@ -806,7 +824,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
806
824
  expect(channel.finalized).toBe(true)
807
825
  })
808
826
 
809
- test('closes a channel with fee payer', async () => {
827
+ test.todo('closes with distinct feePayer != account (fee-sponsored close)')
828
+
829
+ test('closes with explicit account (no fee payer)', async () => {
810
830
  const salt = nextSalt()
811
831
  const deposit = 10_000_000n
812
832
  const closeAmount = 5_000_000n
@@ -828,6 +848,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
828
848
  chain.id,
829
849
  )
830
850
 
851
+ // Pass account explicitly — should use it as sender instead of client.account
831
852
  const txHash = await closeOnChain(
832
853
  client,
833
854
  escrowContract,
@@ -836,8 +857,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
836
857
  cumulativeAmount: closeAmount,
837
858
  signature,
838
859
  },
839
- undefined,
840
- accounts[0],
860
+ { account: accounts[0] },
841
861
  )
842
862
 
843
863
  expect(txHash).toBeDefined()