mppx 0.5.13 → 0.5.16

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 (83) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/Method.d.ts +5 -2
  3. package/dist/Method.d.ts.map +1 -1
  4. package/dist/Method.js.map +1 -1
  5. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  6. package/dist/mcp-sdk/server/Transport.js +8 -2
  7. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  8. package/dist/server/Mppx.d.ts.map +1 -1
  9. package/dist/server/Mppx.js +17 -10
  10. package/dist/server/Mppx.js.map +1 -1
  11. package/dist/server/Request.js +5 -1
  12. package/dist/server/Request.js.map +1 -1
  13. package/dist/server/Transport.d.ts.map +1 -1
  14. package/dist/server/Transport.js +4 -0
  15. package/dist/server/Transport.js.map +1 -1
  16. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  17. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/stripe/server/internal/html.gen.js +1 -1
  19. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +4 -2
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  24. package/dist/tempo/client/SessionManager.js +20 -10
  25. package/dist/tempo/client/SessionManager.js.map +1 -1
  26. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  27. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  28. package/dist/tempo/internal/fee-payer.js +99 -23
  29. package/dist/tempo/internal/fee-payer.js.map +1 -1
  30. package/dist/tempo/server/Charge.d.ts.map +1 -1
  31. package/dist/tempo/server/Charge.js +6 -0
  32. package/dist/tempo/server/Charge.js.map +1 -1
  33. package/dist/tempo/server/Session.d.ts +4 -0
  34. package/dist/tempo/server/Session.d.ts.map +1 -1
  35. package/dist/tempo/server/Session.js +79 -48
  36. package/dist/tempo/server/Session.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/dist/tempo/server/internal/transport.d.ts +0 -7
  42. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  43. package/dist/tempo/server/internal/transport.js +84 -13
  44. package/dist/tempo/server/internal/transport.js.map +1 -1
  45. package/dist/tempo/session/Chain.d.ts +5 -0
  46. package/dist/tempo/session/Chain.d.ts.map +1 -1
  47. package/dist/tempo/session/Chain.js +202 -63
  48. package/dist/tempo/session/Chain.js.map +1 -1
  49. package/dist/tempo/session/ChannelStore.d.ts +1 -0
  50. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  51. package/dist/tempo/session/ChannelStore.js +38 -15
  52. package/dist/tempo/session/ChannelStore.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/Method.ts +5 -2
  55. package/src/internal/changeset.test.ts +106 -0
  56. package/src/mcp-sdk/client/McpClient.integration.test.ts +634 -0
  57. package/src/mcp-sdk/server/Transport.test.ts +1 -0
  58. package/src/mcp-sdk/server/Transport.ts +10 -2
  59. package/src/proxy/Proxy.test.ts +149 -1
  60. package/src/server/Mppx.test.ts +120 -0
  61. package/src/server/Mppx.ts +27 -11
  62. package/src/server/Request.test.ts +46 -1
  63. package/src/server/Request.ts +6 -1
  64. package/src/server/Transport.test.ts +2 -0
  65. package/src/server/Transport.ts +4 -0
  66. package/src/stripe/server/internal/html.gen.ts +1 -1
  67. package/src/tempo/Methods.test.ts +13 -0
  68. package/src/tempo/Methods.ts +23 -16
  69. package/src/tempo/client/SessionManager.ts +32 -9
  70. package/src/tempo/internal/fee-payer.test.ts +88 -16
  71. package/src/tempo/internal/fee-payer.ts +118 -23
  72. package/src/tempo/server/Charge.test.ts +73 -0
  73. package/src/tempo/server/Charge.ts +6 -0
  74. package/src/tempo/server/Session.test.ts +934 -47
  75. package/src/tempo/server/Session.ts +100 -52
  76. package/src/tempo/server/internal/html.gen.ts +1 -1
  77. package/src/tempo/server/internal/transport.test.ts +321 -10
  78. package/src/tempo/server/internal/transport.ts +101 -14
  79. package/src/tempo/session/Chain.test.ts +225 -2
  80. package/src/tempo/session/Chain.ts +250 -65
  81. package/src/tempo/session/ChannelStore.test.ts +23 -0
  82. package/src/tempo/session/ChannelStore.ts +46 -13
  83. package/src/viem/Client.test.ts +52 -1
@@ -1,5 +1,5 @@
1
- import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem'
2
- import { waitForTransactionReceipt } from 'viem/actions'
1
+ import { type Address, encodeFunctionData, erc20Abi, type Hex, zeroAddress } from 'viem'
2
+ import { prepareTransactionRequest, signTransaction, waitForTransactionReceipt } from 'viem/actions'
3
3
  import { Addresses, Transaction } from 'viem/tempo'
4
4
  import { beforeAll, describe, expect, test } from 'vp/test'
5
5
  import { nodeEnv } from '~test/config.js'
@@ -17,10 +17,12 @@ import {
17
17
  broadcastOpenTransaction,
18
18
  broadcastTopUpTransaction,
19
19
  closeOnChain,
20
+ escrowAbi,
20
21
  getOnChainChannel,
21
22
  settleOnChain,
22
23
  verifyTopUpTransaction,
23
24
  } from './Chain.js'
25
+ import * as Channel from './Channel.js'
24
26
  import { signVoucher } from './Voucher.js'
25
27
 
26
28
  const isLocalnet = nodeEnv === 'localnet'
@@ -397,6 +399,116 @@ describe.runIf(isLocalnet)('on-chain', () => {
397
399
  ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
398
400
  })
399
401
 
402
+ test('fee-payer: rejects transactions whose gas budget exceeds sponsor policy', async () => {
403
+ const salt = nextSalt()
404
+ const deposit = 5_000_000n
405
+
406
+ const approveData = encodeFunctionData({
407
+ abi: erc20Abi,
408
+ functionName: 'approve',
409
+ args: [escrowContract, deposit],
410
+ })
411
+ const openData = encodeFunctionData({
412
+ abi: escrowAbi,
413
+ functionName: 'open',
414
+ args: [recipient, currency, deposit, salt, zeroAddress],
415
+ })
416
+
417
+ const channelId = Channel.computeId({
418
+ authorizedSigner: zeroAddress,
419
+ chainId: chain.id,
420
+ escrowContract,
421
+ payee: recipient,
422
+ payer: payer.address,
423
+ salt,
424
+ token: currency,
425
+ }) as Hex
426
+
427
+ const prepared = await prepareTransactionRequest(client, {
428
+ account: payer,
429
+ calls: [
430
+ { to: currency, data: approveData },
431
+ { to: escrowContract, data: openData },
432
+ ],
433
+ feePayer: true,
434
+ feeToken: currency,
435
+ } as never)
436
+ prepared.gas = 2_000_001n
437
+
438
+ const serializedTransaction = await signTransaction(client, prepared as never)
439
+
440
+ await expect(
441
+ broadcastOpenTransaction({
442
+ client,
443
+ serializedTransaction: serializedTransaction as Hex,
444
+ escrowContract,
445
+ channelId,
446
+ recipient,
447
+ currency,
448
+ feePayer: accounts[0],
449
+ }),
450
+ ).rejects.toThrow('gas exceeds sponsor policy')
451
+ })
452
+
453
+ test('fee-payer: rejects smuggled second open call', async () => {
454
+ const deposit = 5_000_000n
455
+ const smuggledDeposit = 7_000_000n
456
+ const salt = nextSalt()
457
+ const smuggledSalt = nextSalt()
458
+
459
+ const approveData = encodeFunctionData({
460
+ abi: erc20Abi,
461
+ functionName: 'approve',
462
+ args: [escrowContract, deposit + smuggledDeposit],
463
+ })
464
+ const openData = encodeFunctionData({
465
+ abi: escrowAbi,
466
+ functionName: 'open',
467
+ args: [recipient, currency, deposit, salt, zeroAddress],
468
+ })
469
+ const smuggledOpenData = encodeFunctionData({
470
+ abi: escrowAbi,
471
+ functionName: 'open',
472
+ args: [accounts[3].address, currency, smuggledDeposit, smuggledSalt, zeroAddress],
473
+ })
474
+
475
+ const channelId = Channel.computeId({
476
+ authorizedSigner: zeroAddress,
477
+ chainId: chain.id,
478
+ escrowContract,
479
+ payee: recipient,
480
+ payer: payer.address,
481
+ salt,
482
+ token: currency,
483
+ }) as Hex
484
+
485
+ const prepared = await prepareTransactionRequest(client, {
486
+ account: payer,
487
+ calls: [
488
+ { to: currency, data: approveData },
489
+ { to: escrowContract, data: openData },
490
+ { to: escrowContract, data: smuggledOpenData },
491
+ ],
492
+ feePayer: true,
493
+ feeToken: currency,
494
+ } as never)
495
+ prepared.gas = prepared.gas! + 5_000n
496
+
497
+ const serializedTransaction = await signTransaction(client, prepared as never)
498
+
499
+ await expect(
500
+ broadcastOpenTransaction({
501
+ client,
502
+ serializedTransaction: serializedTransaction as Hex,
503
+ escrowContract,
504
+ channelId,
505
+ recipient,
506
+ currency,
507
+ feePayer: accounts[0],
508
+ }),
509
+ ).rejects.toThrow('fee-sponsored open transaction contains a smuggled call')
510
+ })
511
+
400
512
  test('duplicate broadcast returns fallback with txHash undefined', async () => {
401
513
  const salt = nextSalt()
402
514
  const deposit = 5_000_000n
@@ -727,6 +839,117 @@ describe.runIf(isLocalnet)('on-chain', () => {
727
839
  }),
728
840
  ).rejects.toThrow('Only Tempo (0x76/0x78) transactions are supported')
729
841
  })
842
+
843
+ test('fee-payer: rejects topUp transactions whose gas budget exceeds sponsor policy', async () => {
844
+ const salt = nextSalt()
845
+ const deposit = 5_000_000n
846
+ const topUpAmount = 3_000_000n
847
+
848
+ const { channelId } = await openChannel({
849
+ escrow: escrowContract,
850
+ payer,
851
+ payee: recipient,
852
+ token: currency,
853
+ deposit,
854
+ salt,
855
+ })
856
+
857
+ const approveData = encodeFunctionData({
858
+ abi: erc20Abi,
859
+ functionName: 'approve',
860
+ args: [escrowContract, topUpAmount],
861
+ })
862
+ const topUpData = encodeFunctionData({
863
+ abi: escrowAbi,
864
+ functionName: 'topUp',
865
+ args: [channelId, topUpAmount],
866
+ })
867
+
868
+ const prepared = await prepareTransactionRequest(client, {
869
+ account: payer,
870
+ calls: [
871
+ { to: currency, data: approveData },
872
+ { to: escrowContract, data: topUpData },
873
+ ],
874
+ feePayer: true,
875
+ feeToken: currency,
876
+ } as never)
877
+ prepared.gas = 2_000_001n
878
+
879
+ const serializedTransaction = await signTransaction(client, prepared as never)
880
+
881
+ await expect(
882
+ broadcastTopUpTransaction({
883
+ client,
884
+ serializedTransaction: serializedTransaction as Hex,
885
+ escrowContract,
886
+ channelId,
887
+ currency: asset,
888
+ declaredDeposit: topUpAmount,
889
+ previousDeposit: deposit,
890
+ feePayer: accounts[0],
891
+ }),
892
+ ).rejects.toThrow('gas exceeds sponsor policy')
893
+ })
894
+
895
+ test('fee-payer: rejects smuggled second topUp call', async () => {
896
+ const salt = nextSalt()
897
+ const deposit = 5_000_000n
898
+ const topUpAmount = 3_000_000n
899
+ const smuggledAmount = 4_000_000n
900
+
901
+ const { channelId } = await openChannel({
902
+ escrow: escrowContract,
903
+ payer,
904
+ payee: recipient,
905
+ token: currency,
906
+ deposit,
907
+ salt,
908
+ })
909
+
910
+ const approveData = encodeFunctionData({
911
+ abi: erc20Abi,
912
+ functionName: 'approve',
913
+ args: [escrowContract, topUpAmount + smuggledAmount],
914
+ })
915
+ const topUpData = encodeFunctionData({
916
+ abi: escrowAbi,
917
+ functionName: 'topUp',
918
+ args: [channelId, topUpAmount],
919
+ })
920
+ const smuggledTopUpData = encodeFunctionData({
921
+ abi: escrowAbi,
922
+ functionName: 'topUp',
923
+ args: [channelId, smuggledAmount],
924
+ })
925
+
926
+ const prepared = await prepareTransactionRequest(client, {
927
+ account: payer,
928
+ calls: [
929
+ { to: currency, data: approveData },
930
+ { to: escrowContract, data: topUpData },
931
+ { to: escrowContract, data: smuggledTopUpData },
932
+ ],
933
+ feePayer: true,
934
+ feeToken: currency,
935
+ } as never)
936
+ prepared.gas = prepared.gas! + 5_000n
937
+
938
+ const serializedTransaction = await signTransaction(client, prepared as never)
939
+
940
+ await expect(
941
+ broadcastTopUpTransaction({
942
+ client,
943
+ serializedTransaction: serializedTransaction as Hex,
944
+ escrowContract,
945
+ channelId,
946
+ currency: asset,
947
+ declaredDeposit: topUpAmount,
948
+ previousDeposit: deposit,
949
+ feePayer: accounts[0],
950
+ }),
951
+ ).rejects.toThrow('fee-sponsored topUp transaction contains a smuggled call')
952
+ })
730
953
  })
731
954
 
732
955
  describe('settleOnChain', () => {
@@ -4,6 +4,7 @@ import {
4
4
  type Client,
5
5
  decodeFunctionData,
6
6
  encodeFunctionData,
7
+ erc20Abi,
7
8
  getAbiItem,
8
9
  type Hex,
9
10
  type ReadContractReturnType,
@@ -23,7 +24,7 @@ import { Transaction } from 'viem/tempo'
23
24
  import { BadRequestError, ChannelClosedError, VerificationFailedError } from '../../Errors.js'
24
25
  import * as TempoAddress from '../internal/address.js'
25
26
  import * as defaults from '../internal/defaults.js'
26
- import { isTempoTransaction } from '../internal/fee-payer.js'
27
+ import * as FeePayer from '../internal/fee-payer.js'
27
28
  import * as Channel from './Channel.js'
28
29
  import { escrowAbi } from './escrow.abi.js'
29
30
  import type { SignedVoucher } from './Types.js'
@@ -230,6 +231,159 @@ export type BroadcastResult = {
230
231
  onChain: OnChainChannel
231
232
  }
232
233
 
234
+ type TempoCall = NonNullable<ReturnType<(typeof Transaction)['deserialize']>['calls']>[number]
235
+
236
+ function assertCallHasTargetAndData(call: TempoCall): { to: Address; data: Hex } {
237
+ if (!call.to || !call.data) {
238
+ throw new BadRequestError({
239
+ reason: 'fee-sponsored transactions must not contain calls without target or data',
240
+ })
241
+ }
242
+ return { to: call.to, data: call.data }
243
+ }
244
+
245
+ function validateSponsoredApproveCall(parameters: {
246
+ action: 'open' | 'topUp'
247
+ call: TempoCall
248
+ currency: Address
249
+ escrowContract: Address
250
+ expectedAmount: bigint
251
+ }) {
252
+ const { action, call, currency, escrowContract, expectedAmount } = parameters
253
+ const { to, data } = assertCallHasTargetAndData(call)
254
+
255
+ if (!TempoAddress.isEqual(to, currency) || data.slice(0, 10) !== erc20ApproveSelector) {
256
+ throw new BadRequestError({
257
+ reason: `fee-sponsored ${action} transaction contains an unauthorized call`,
258
+ })
259
+ }
260
+
261
+ const { args } = decodeFunctionData({ abi: erc20Abi, data })
262
+ const [spender, amount] = args as readonly [Address, bigint]
263
+
264
+ if (!TempoAddress.isEqual(spender, escrowContract)) {
265
+ throw new BadRequestError({
266
+ reason: `fee-sponsored ${action} transaction approve spender does not match escrow contract`,
267
+ })
268
+ }
269
+
270
+ if (amount !== expectedAmount) {
271
+ throw new BadRequestError({
272
+ reason: `fee-sponsored ${action} transaction approve amount does not match requested amount`,
273
+ })
274
+ }
275
+ }
276
+
277
+ function validateSponsoredOpenCalls(parameters: {
278
+ calls: readonly TempoCall[]
279
+ currency: Address
280
+ escrowContract: Address
281
+ deposit: bigint
282
+ }) {
283
+ const { calls, currency, escrowContract, deposit } = parameters
284
+
285
+ let openCall: TempoCall | undefined
286
+ let approveCall: TempoCall | undefined
287
+
288
+ for (const call of calls) {
289
+ const { to, data } = assertCallHasTargetAndData(call)
290
+ const selector = data.slice(0, 10)
291
+ const isOpen = TempoAddress.isEqual(to, escrowContract) && selector === escrowOpenSelector
292
+ const isApprove = TempoAddress.isEqual(to, currency) && selector === erc20ApproveSelector
293
+
294
+ if (isApprove) {
295
+ if (approveCall || openCall) {
296
+ throw new BadRequestError({
297
+ reason: 'fee-sponsored open transaction contains a smuggled call',
298
+ })
299
+ }
300
+ approveCall = call
301
+ continue
302
+ }
303
+
304
+ if (isOpen) {
305
+ if (openCall) {
306
+ throw new BadRequestError({
307
+ reason: 'fee-sponsored open transaction contains a smuggled call',
308
+ })
309
+ }
310
+ openCall = call
311
+ continue
312
+ }
313
+
314
+ throw new BadRequestError({
315
+ reason: 'fee-sponsored open transaction contains an unauthorized call',
316
+ })
317
+ }
318
+
319
+ if (approveCall) {
320
+ validateSponsoredApproveCall({
321
+ action: 'open',
322
+ call: approveCall,
323
+ currency,
324
+ escrowContract,
325
+ expectedAmount: deposit,
326
+ })
327
+ }
328
+
329
+ return openCall
330
+ }
331
+
332
+ function validateSponsoredTopUpCalls(parameters: {
333
+ calls: readonly TempoCall[]
334
+ currency: Address
335
+ escrowContract: Address
336
+ topUpAmount: bigint
337
+ }) {
338
+ const { calls, currency, escrowContract, topUpAmount } = parameters
339
+
340
+ let topUpCall: TempoCall | undefined
341
+ let approveCall: TempoCall | undefined
342
+
343
+ for (const call of calls) {
344
+ const { to, data } = assertCallHasTargetAndData(call)
345
+ const selector = data.slice(0, 10)
346
+ const isTopUp = TempoAddress.isEqual(to, escrowContract) && selector === escrowTopUpSelector
347
+ const isApprove = TempoAddress.isEqual(to, currency) && selector === erc20ApproveSelector
348
+
349
+ if (isApprove) {
350
+ if (approveCall || topUpCall) {
351
+ throw new BadRequestError({
352
+ reason: 'fee-sponsored topUp transaction contains a smuggled call',
353
+ })
354
+ }
355
+ approveCall = call
356
+ continue
357
+ }
358
+
359
+ if (isTopUp) {
360
+ if (topUpCall) {
361
+ throw new BadRequestError({
362
+ reason: 'fee-sponsored topUp transaction contains a smuggled call',
363
+ })
364
+ }
365
+ topUpCall = call
366
+ continue
367
+ }
368
+
369
+ throw new BadRequestError({
370
+ reason: 'fee-sponsored topUp transaction contains an unauthorized call',
371
+ })
372
+ }
373
+
374
+ if (approveCall) {
375
+ validateSponsoredApproveCall({
376
+ action: 'topUp',
377
+ call: approveCall,
378
+ currency,
379
+ escrowContract,
380
+ expectedAmount: topUpAmount,
381
+ })
382
+ }
383
+
384
+ return topUpCall
385
+ }
386
+
233
387
  export async function broadcastOpenTransaction(parameters: {
234
388
  client: Client
235
389
  serializedTransaction: Hex
@@ -237,6 +391,8 @@ export async function broadcastOpenTransaction(parameters: {
237
391
  channelId: Hex
238
392
  recipient: Address
239
393
  currency: Address
394
+ challengeExpires?: string | undefined
395
+ feePayerPolicy?: Partial<FeePayer.Policy> | undefined
240
396
  feePayer?: Account | undefined
241
397
  /** When false, simulates instead of waiting for confirmation and returns derived on-chain state. @default true */
242
398
  waitForConfirmation?: boolean | undefined
@@ -248,11 +404,13 @@ export async function broadcastOpenTransaction(parameters: {
248
404
  channelId,
249
405
  recipient,
250
406
  currency,
407
+ challengeExpires,
408
+ feePayerPolicy,
251
409
  feePayer,
252
410
  waitForConfirmation = true,
253
411
  } = parameters
254
412
 
255
- if (feePayer && !isTempoTransaction(serializedTransaction))
413
+ if (feePayer && !FeePayer.isTempoTransaction(serializedTransaction))
256
414
  throw new BadRequestError({
257
415
  reason: 'Only Tempo (0x76/0x78) transactions are supported',
258
416
  })
@@ -265,37 +423,40 @@ export async function broadcastOpenTransaction(parameters: {
265
423
 
266
424
  const calls = transaction.calls ?? []
267
425
 
268
- const openCall = calls.find((call) => {
269
- if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
270
- if (!call.data) return false
271
- return call.data.slice(0, 10) === escrowOpenSelector
272
- })
426
+ const sponsoredOpenCall = feePayer
427
+ ? validateSponsoredOpenCalls({
428
+ calls,
429
+ currency,
430
+ escrowContract,
431
+ deposit: (() => {
432
+ const candidate = calls.find((call) => {
433
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
434
+ if (!call.data) return false
435
+ return call.data.slice(0, 10) === escrowOpenSelector
436
+ })
437
+ if (!candidate?.data)
438
+ throw new BadRequestError({
439
+ reason: 'transaction does not contain a valid escrow open call',
440
+ })
441
+ const { args } = decodeFunctionData({ abi: escrowAbi, data: candidate.data })
442
+ return (args as readonly [Address, Address, bigint, Hex, Address])[2]
443
+ })(),
444
+ })
445
+ : undefined
446
+
447
+ const openCall =
448
+ sponsoredOpenCall ??
449
+ calls.find((call) => {
450
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
451
+ if (!call.data) return false
452
+ return call.data.slice(0, 10) === escrowOpenSelector
453
+ })
273
454
 
274
455
  if (!openCall)
275
456
  throw new BadRequestError({
276
457
  reason: 'transaction does not contain a valid escrow open call',
277
458
  })
278
459
 
279
- if (feePayer) {
280
- for (const call of calls) {
281
- if (!call.to || !call.data) {
282
- throw new BadRequestError({
283
- reason: 'fee-sponsored transactions must not contain calls without target or data',
284
- })
285
- }
286
- const selector = call.data.slice(0, 10)
287
- const isEscrowOpen =
288
- TempoAddress.isEqual(call.to, escrowContract) && selector === escrowOpenSelector
289
- const isTokenApprove =
290
- TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
291
- if (!isEscrowOpen && !isTokenApprove) {
292
- throw new BadRequestError({
293
- reason: 'fee-sponsored open transaction contains an unauthorized call',
294
- })
295
- }
296
- }
297
- }
298
-
299
460
  const { args: openArgs } = decodeFunctionData({ abi: escrowAbi, data: openCall.data! })
300
461
  const [payee, token, deposit, salt, authorizedSigner] = openArgs as readonly [
301
462
  Address,
@@ -337,12 +498,24 @@ export async function broadcastOpenTransaction(parameters: {
337
498
 
338
499
  const serializedTransaction_final = await (async () => {
339
500
  if (feePayer) {
340
- return signTransaction(client, {
341
- ...transaction,
501
+ if (!sponsoredOpenCall)
502
+ throw new BadRequestError({
503
+ reason: 'transaction does not contain a valid escrow open call',
504
+ })
505
+
506
+ const sponsored = FeePayer.prepareSponsoredTransaction({
342
507
  account: feePayer,
343
- feePayer,
344
- feeToken: resolvedFeeToken,
345
- } as never)
508
+ challengeExpires,
509
+ chainId: client.chain!.id,
510
+ details: { channelId, currency, recipient },
511
+ expectedFeeToken: defaults.currency[client.chain?.id as keyof typeof defaults.currency],
512
+ policy: feePayerPolicy,
513
+ transaction: {
514
+ ...transaction,
515
+ ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
516
+ },
517
+ })
518
+ return signTransaction(client, sponsored as never)
346
519
  }
347
520
  return serializedTransaction
348
521
  })()
@@ -407,6 +580,8 @@ export async function broadcastTopUpTransaction(parameters: {
407
580
  currency: Address
408
581
  declaredDeposit: bigint
409
582
  previousDeposit: bigint
583
+ challengeExpires?: string | undefined
584
+ feePayerPolicy?: Partial<FeePayer.Policy> | undefined
410
585
  feePayer?: Account | undefined
411
586
  }): Promise<{ txHash: Hex; newDeposit: bigint }> {
412
587
  const {
@@ -417,10 +592,12 @@ export async function broadcastTopUpTransaction(parameters: {
417
592
  currency,
418
593
  declaredDeposit,
419
594
  previousDeposit,
595
+ challengeExpires,
596
+ feePayerPolicy,
420
597
  feePayer,
421
598
  } = parameters
422
599
 
423
- if (feePayer && !isTempoTransaction(serializedTransaction))
600
+ if (feePayer && !FeePayer.isTempoTransaction(serializedTransaction))
424
601
  throw new BadRequestError({
425
602
  reason: 'Only Tempo (0x76/0x78) transactions are supported',
426
603
  })
@@ -433,37 +610,28 @@ export async function broadcastTopUpTransaction(parameters: {
433
610
 
434
611
  const calls = transaction.calls ?? []
435
612
 
436
- const topUpCall = calls.find((call) => {
437
- if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
438
- if (!call.data) return false
439
- return call.data.slice(0, 10) === escrowTopUpSelector
440
- })
613
+ const sponsoredTopUpCall = feePayer
614
+ ? validateSponsoredTopUpCalls({
615
+ calls,
616
+ currency,
617
+ escrowContract,
618
+ topUpAmount: declaredDeposit,
619
+ })
620
+ : undefined
621
+
622
+ const topUpCall =
623
+ sponsoredTopUpCall ??
624
+ calls.find((call) => {
625
+ if (!call.to || !TempoAddress.isEqual(call.to, escrowContract)) return false
626
+ if (!call.data) return false
627
+ return call.data.slice(0, 10) === escrowTopUpSelector
628
+ })
441
629
 
442
630
  if (!topUpCall)
443
631
  throw new BadRequestError({
444
632
  reason: 'transaction does not contain a valid escrow topUp call',
445
633
  })
446
634
 
447
- if (feePayer) {
448
- for (const call of calls) {
449
- if (!call.to || !call.data) {
450
- throw new BadRequestError({
451
- reason: 'fee-sponsored transactions must not contain calls without target or data',
452
- })
453
- }
454
- const selector = call.data.slice(0, 10)
455
- const isEscrowTopUp =
456
- TempoAddress.isEqual(call.to, escrowContract) && selector === escrowTopUpSelector
457
- const isTokenApprove =
458
- TempoAddress.isEqual(call.to, currency) && selector === erc20ApproveSelector
459
- if (!isEscrowTopUp && !isTokenApprove) {
460
- throw new BadRequestError({
461
- reason: 'fee-sponsored topUp transaction contains an unauthorized call',
462
- })
463
- }
464
- }
465
- }
466
-
467
635
  const { args: topUpArgs } = decodeFunctionData({ abi: escrowAbi, data: topUpCall.data! })
468
636
  const [txChannelId, txAmount] = topUpArgs as [Hex, bigint]
469
637
 
@@ -480,14 +648,31 @@ export async function broadcastTopUpTransaction(parameters: {
480
648
 
481
649
  const serializedTransaction_final = await (async () => {
482
650
  if (feePayer) {
483
- return signTransaction(client, {
484
- ...transaction,
651
+ if (!sponsoredTopUpCall)
652
+ throw new BadRequestError({
653
+ reason: 'transaction does not contain a valid escrow topUp call',
654
+ })
655
+
656
+ const expectedFeeToken = defaults.currency[client.chain?.id as keyof typeof defaults.currency]
657
+ const sponsored = FeePayer.prepareSponsoredTransaction({
485
658
  account: feePayer,
486
- feePayer,
487
- feeToken:
488
- transaction.feeToken ??
489
- defaults.currency[client.chain?.id as keyof typeof defaults.currency],
490
- } as never)
659
+ challengeExpires,
660
+ chainId: client.chain!.id,
661
+ details: {
662
+ additionalDeposit: declaredDeposit.toString(),
663
+ channelId,
664
+ currency,
665
+ },
666
+ expectedFeeToken,
667
+ policy: feePayerPolicy,
668
+ transaction: {
669
+ ...transaction,
670
+ ...((transaction.feeToken ?? expectedFeeToken)
671
+ ? { feeToken: transaction.feeToken ?? expectedFeeToken }
672
+ : {}),
673
+ },
674
+ })
675
+ return signTransaction(client, sponsored as never)
491
676
  }
492
677
  return serializedTransaction
493
678
  })()
@@ -7,6 +7,10 @@ import * as ChannelStore from './ChannelStore.js'
7
7
 
8
8
  const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex
9
9
  const channelId2 = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex
10
+ const lowerCaseAliasChannelId = `0x${'ab'.repeat(31)}cd` as Hex
11
+ const mixedCaseAliasChannelId = lowerCaseAliasChannelId.replace(/[a-f]/g, (character, index) =>
12
+ index % 2 === 0 ? character.toUpperCase() : character,
13
+ ) as Hex
10
14
 
11
15
  function makeChannel(overrides?: Partial<ChannelStore.State>): ChannelStore.State {
12
16
  return {
@@ -112,6 +116,25 @@ describe('channelStore', () => {
112
116
  expect(typeof loaded!.deposit).toBe('bigint')
113
117
  expect(typeof loaded!.createdAt).toBe('string')
114
118
  })
119
+
120
+ test('treats case-variant channelIds as the same record', async () => {
121
+ const cs = ChannelStore.fromStore(Store.memory())
122
+ await cs.updateChannel(mixedCaseAliasChannelId, () =>
123
+ makeChannel({ channelId: mixedCaseAliasChannelId }),
124
+ )
125
+
126
+ const loaded = await cs.getChannel(lowerCaseAliasChannelId)
127
+ expect(loaded).not.toBeNull()
128
+ expect(loaded!.channelId).toBe(lowerCaseAliasChannelId)
129
+
130
+ await cs.updateChannel(lowerCaseAliasChannelId, (current) =>
131
+ current ? { ...current, spent: 1_000_000n } : null,
132
+ )
133
+
134
+ const aliased = await cs.getChannel(mixedCaseAliasChannelId)
135
+ expect(aliased!.channelId).toBe(lowerCaseAliasChannelId)
136
+ expect(aliased!.spent).toBe(1_000_000n)
137
+ })
115
138
  })
116
139
 
117
140
  describe('updateChannel', () => {