mppx 0.4.6 → 0.4.8

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 +12 -0
  2. package/dist/Store.d.ts +5 -4
  3. package/dist/Store.d.ts.map +1 -1
  4. package/dist/Store.js.map +1 -1
  5. package/dist/cli/cli.d.ts.map +1 -1
  6. package/dist/cli/cli.js +22 -7
  7. package/dist/cli/cli.js.map +1 -1
  8. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  9. package/dist/cli/plugins/tempo.js +9 -22
  10. package/dist/cli/plugins/tempo.js.map +1 -1
  11. package/dist/middlewares/elysia.d.ts.map +1 -1
  12. package/dist/middlewares/elysia.js +5 -1
  13. package/dist/middlewares/elysia.js.map +1 -1
  14. package/dist/proxy/Proxy.d.ts.map +1 -1
  15. package/dist/proxy/Proxy.js +3 -1
  16. package/dist/proxy/Proxy.js.map +1 -1
  17. package/dist/proxy/internal/Route.d.ts +2 -2
  18. package/dist/proxy/internal/Route.d.ts.map +1 -1
  19. package/dist/proxy/internal/Route.js +4 -2
  20. package/dist/proxy/internal/Route.js.map +1 -1
  21. package/dist/server/Mppx.d.ts.map +1 -1
  22. package/dist/server/Mppx.js +26 -8
  23. package/dist/server/Mppx.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  25. package/dist/tempo/client/SessionManager.js +12 -1
  26. package/dist/tempo/client/SessionManager.js.map +1 -1
  27. package/dist/tempo/internal/address.d.ts +3 -0
  28. package/dist/tempo/internal/address.d.ts.map +1 -0
  29. package/dist/tempo/internal/address.js +4 -0
  30. package/dist/tempo/internal/address.js.map +1 -0
  31. package/dist/tempo/internal/auto-swap.js +3 -3
  32. package/dist/tempo/internal/auto-swap.js.map +1 -1
  33. package/dist/tempo/internal/fee-payer.d.ts +4 -1
  34. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  35. package/dist/tempo/internal/fee-payer.js +11 -3
  36. package/dist/tempo/internal/fee-payer.js.map +1 -1
  37. package/dist/tempo/server/Charge.d.ts +11 -0
  38. package/dist/tempo/server/Charge.d.ts.map +1 -1
  39. package/dist/tempo/server/Charge.js +109 -50
  40. package/dist/tempo/server/Charge.js.map +1 -1
  41. package/dist/tempo/server/Session.d.ts +1 -1
  42. package/dist/tempo/server/Session.d.ts.map +1 -1
  43. package/dist/tempo/server/Session.js +39 -32
  44. package/dist/tempo/server/Session.js.map +1 -1
  45. package/dist/tempo/server/internal/transport.d.ts +1 -1
  46. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  47. package/dist/tempo/server/internal/transport.js +41 -1
  48. package/dist/tempo/server/internal/transport.js.map +1 -1
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +51 -10
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/dist/tempo/session/ChannelStore.d.ts +2 -0
  53. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  54. package/dist/tempo/session/ChannelStore.js +4 -2
  55. package/dist/tempo/session/ChannelStore.js.map +1 -1
  56. package/dist/tempo/session/Voucher.d.ts.map +1 -1
  57. package/dist/tempo/session/Voucher.js +3 -2
  58. package/dist/tempo/session/Voucher.js.map +1 -1
  59. package/package.json +6 -2
  60. package/src/Store.test-d.ts +58 -0
  61. package/src/Store.ts +6 -4
  62. package/src/cli/cli.test.ts +124 -0
  63. package/src/cli/cli.ts +19 -7
  64. package/src/cli/plugins/tempo.ts +17 -23
  65. package/src/middlewares/elysia.test.ts +89 -0
  66. package/src/middlewares/elysia.ts +4 -1
  67. package/src/proxy/Proxy.test.ts +56 -0
  68. package/src/proxy/Proxy.ts +6 -1
  69. package/src/proxy/internal/Route.test.ts +57 -0
  70. package/src/proxy/internal/Route.ts +3 -1
  71. package/src/server/Mppx.test.ts +246 -0
  72. package/src/server/Mppx.ts +27 -8
  73. package/src/tempo/client/SessionManager.ts +11 -1
  74. package/src/tempo/internal/address.ts +6 -0
  75. package/src/tempo/internal/auto-swap.ts +3 -3
  76. package/src/tempo/internal/fee-payer.ts +18 -4
  77. package/src/tempo/server/Charge.test.ts +1080 -31
  78. package/src/tempo/server/Charge.ts +158 -63
  79. package/src/tempo/server/Session.test.ts +929 -111
  80. package/src/tempo/server/Session.ts +48 -33
  81. package/src/tempo/server/Sse.test.ts +1 -0
  82. package/src/tempo/server/internal/transport.test.ts +29 -0
  83. package/src/tempo/server/internal/transport.ts +41 -2
  84. package/src/tempo/session/Chain.test.ts +144 -0
  85. package/src/tempo/session/Chain.ts +58 -10
  86. package/src/tempo/session/ChannelStore.test.ts +10 -0
  87. package/src/tempo/session/ChannelStore.ts +6 -3
  88. package/src/tempo/session/Sse.test.ts +1 -0
  89. package/src/tempo/session/Voucher.ts +3 -2
@@ -2,14 +2,18 @@ import { Challenge, Credential, Receipt } from 'mppx'
2
2
  import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import type { Hex } from 'ox'
5
+ import { TxEnvelopeTempo } from 'ox/tempo'
5
6
  import { Handler } from 'tempo.ts/server'
6
- import { encodeFunctionData, parseUnits } from 'viem'
7
+ import { createClient, custom, encodeFunctionData, parseUnits } from 'viem'
7
8
  import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions'
8
- import { Abis, Actions, Addresses, Tick } from 'viem/tempo'
9
+ import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from 'viem/tempo'
9
10
  import { beforeAll, describe, expect, test } from 'vitest'
10
11
  import * as Http from '~test/Http.js'
12
+ import { closeChannelOnChain, deployEscrow, openChannel } from '~test/tempo/session.js'
11
13
  import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
14
+ import * as Store from '../../Store.js'
12
15
  import * as Attribution from '../Attribution.js'
16
+ import { signVoucher } from '../session/Voucher.js'
13
17
 
14
18
  const realm = 'api.example.com'
15
19
  const secretKey = 'test-secret-key'
@@ -108,6 +112,222 @@ describe('tempo', () => {
108
112
  httpServer.close()
109
113
  })
110
114
 
115
+ test('behavior: rejects replayed transaction hash', async () => {
116
+ const dedupServer = Mppx_server.create({
117
+ methods: [
118
+ tempo_server.charge({
119
+ getClient() {
120
+ return client
121
+ },
122
+ currency: asset,
123
+ account: accounts[0],
124
+ store: Store.memory(),
125
+ }),
126
+ ],
127
+ realm,
128
+ secretKey,
129
+ })
130
+
131
+ const httpServer = await Http.createServer(async (req, res) => {
132
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
133
+ req,
134
+ res,
135
+ )
136
+ if (result.status === 402) return
137
+ res.end('OK')
138
+ })
139
+
140
+ const response1 = await fetch(httpServer.url)
141
+ expect(response1.status).toBe(402)
142
+
143
+ const challenge1 = Challenge.fromResponse(response1, {
144
+ methods: [tempo_client.charge()],
145
+ })
146
+
147
+ const { receipt } = await Actions.token.transferSync(client, {
148
+ account: accounts[1],
149
+ amount: BigInt(challenge1.request.amount),
150
+ to: challenge1.request.recipient as Hex.Hex,
151
+ token: challenge1.request.currency as Hex.Hex,
152
+ })
153
+
154
+ const credential1 = Credential.from({
155
+ challenge: challenge1,
156
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
157
+ })
158
+
159
+ {
160
+ const response = await fetch(httpServer.url, {
161
+ headers: { Authorization: Credential.serialize(credential1) },
162
+ })
163
+ expect(response.status).toBe(200)
164
+ }
165
+
166
+ const response2 = await fetch(httpServer.url)
167
+ expect(response2.status).toBe(402)
168
+
169
+ const challenge2 = Challenge.fromResponse(response2, {
170
+ methods: [tempo_client.charge()],
171
+ })
172
+
173
+ const mixedCaseHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
174
+
175
+ const credential2 = Credential.from({
176
+ challenge: challenge2,
177
+ payload: { hash: mixedCaseHash, type: 'hash' as const },
178
+ })
179
+
180
+ {
181
+ const response = await fetch(httpServer.url, {
182
+ headers: { Authorization: Credential.serialize(credential2) },
183
+ })
184
+ expect(response.status).toBe(402)
185
+ const body = (await response.json()) as { detail: string }
186
+ expect(body.detail).toContain('Transaction hash has already been used.')
187
+ }
188
+
189
+ httpServer.close()
190
+ })
191
+
192
+ test('behavior: rejects replayed hash with alternating case', async () => {
193
+ const dedupServer = Mppx_server.create({
194
+ methods: [
195
+ tempo_server.charge({
196
+ getClient() {
197
+ return client
198
+ },
199
+ currency: asset,
200
+ account: accounts[0],
201
+ store: Store.memory(),
202
+ }),
203
+ ],
204
+ realm,
205
+ secretKey,
206
+ })
207
+
208
+ const httpServer = await Http.createServer(async (req, res) => {
209
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
210
+ req,
211
+ res,
212
+ )
213
+ if (result.status === 402) return
214
+ res.end('OK')
215
+ })
216
+
217
+ const response1 = await fetch(httpServer.url)
218
+ expect(response1.status).toBe(402)
219
+
220
+ const challenge1 = Challenge.fromResponse(response1, {
221
+ methods: [tempo_client.charge()],
222
+ })
223
+
224
+ const { receipt } = await Actions.token.transferSync(client, {
225
+ account: accounts[1],
226
+ amount: BigInt(challenge1.request.amount),
227
+ to: challenge1.request.recipient as Hex.Hex,
228
+ token: challenge1.request.currency as Hex.Hex,
229
+ })
230
+
231
+ // Submit original hash with alternating case (aB, not all upper or lower)
232
+ const hex = receipt.transactionHash.slice(2)
233
+ const alternating = `0x${hex
234
+ .split('')
235
+ .map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
236
+ .join('')}` as Hex.Hex
237
+
238
+ const credential1 = Credential.from({
239
+ challenge: challenge1,
240
+ payload: { hash: alternating, type: 'hash' as const },
241
+ })
242
+
243
+ {
244
+ const response = await fetch(httpServer.url, {
245
+ headers: { Authorization: Credential.serialize(credential1) },
246
+ })
247
+ expect(response.status).toBe(200)
248
+ }
249
+
250
+ // Replay with lowercase — should be rejected
251
+ const response2 = await fetch(httpServer.url)
252
+ expect(response2.status).toBe(402)
253
+
254
+ const challenge2 = Challenge.fromResponse(response2, {
255
+ methods: [tempo_client.charge()],
256
+ })
257
+
258
+ const credential2 = Credential.from({
259
+ challenge: challenge2,
260
+ payload: { hash: receipt.transactionHash.toLowerCase() as Hex.Hex, type: 'hash' as const },
261
+ })
262
+
263
+ {
264
+ const response = await fetch(httpServer.url, {
265
+ headers: { Authorization: Credential.serialize(credential2) },
266
+ })
267
+ expect(response.status).toBe(402)
268
+ const body = (await response.json()) as { detail: string }
269
+ expect(body.detail).toContain('Transaction hash has already been used.')
270
+ }
271
+
272
+ httpServer.close()
273
+ })
274
+
275
+ test('behavior: accepts uppercase hash on first use', async () => {
276
+ const dedupServer = Mppx_server.create({
277
+ methods: [
278
+ tempo_server.charge({
279
+ getClient() {
280
+ return client
281
+ },
282
+ currency: asset,
283
+ account: accounts[0],
284
+ store: Store.memory(),
285
+ }),
286
+ ],
287
+ realm,
288
+ secretKey,
289
+ })
290
+
291
+ const httpServer = await Http.createServer(async (req, res) => {
292
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
293
+ req,
294
+ res,
295
+ )
296
+ if (result.status === 402) return
297
+ res.end('OK')
298
+ })
299
+
300
+ const response1 = await fetch(httpServer.url)
301
+ expect(response1.status).toBe(402)
302
+
303
+ const challenge1 = Challenge.fromResponse(response1, {
304
+ methods: [tempo_client.charge()],
305
+ })
306
+
307
+ const { receipt } = await Actions.token.transferSync(client, {
308
+ account: accounts[1],
309
+ amount: BigInt(challenge1.request.amount),
310
+ to: challenge1.request.recipient as Hex.Hex,
311
+ token: challenge1.request.currency as Hex.Hex,
312
+ })
313
+
314
+ const upperHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
315
+
316
+ const credential1 = Credential.from({
317
+ challenge: challenge1,
318
+ payload: { hash: upperHash, type: 'hash' as const },
319
+ })
320
+
321
+ {
322
+ const response = await fetch(httpServer.url, {
323
+ headers: { Authorization: Credential.serialize(credential1) },
324
+ })
325
+ expect(response.status).toBe(200)
326
+ }
327
+
328
+ httpServer.close()
329
+ })
330
+
111
331
  test('behavior: rejects hash with non-matching Transfer log', async () => {
112
332
  const wrongRecipient = accounts[2].address
113
333
 
@@ -149,6 +369,286 @@ describe('tempo', () => {
149
369
  httpServer.close()
150
370
  })
151
371
 
372
+ test('behavior: rejects session settlement tx hash used as charge credential', async () => {
373
+ const chargeAmount = parseUnits('1', 6)
374
+ const recipient = accounts[0].address
375
+ const external = accounts[3]
376
+
377
+ const escrow = await deployEscrow()
378
+
379
+ await fundAccount({ address: external.address, token: Addresses.pathUsd })
380
+ await fundAccount({ address: external.address, token: asset })
381
+
382
+ const salt = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex.Hex
383
+ const { channelId } = await openChannel({
384
+ escrow,
385
+ payer: external,
386
+ payee: recipient,
387
+ token: asset,
388
+ deposit: chargeAmount,
389
+ salt,
390
+ })
391
+
392
+ const voucherSig = await signVoucher(
393
+ client,
394
+ external,
395
+ { channelId, cumulativeAmount: chargeAmount },
396
+ escrow,
397
+ chain.id,
398
+ )
399
+
400
+ const { txHash: settleTxHash } = await closeChannelOnChain({
401
+ escrow,
402
+ payee: accounts[0],
403
+ channelId,
404
+ cumulativeAmount: chargeAmount,
405
+ signature: voucherSig,
406
+ })
407
+
408
+ const httpServer = await Http.createServer(async (req, res) => {
409
+ const result = await Mppx_server.toNodeListener(server.charge({ amount: '1' }))(req, res)
410
+ if (result.status === 402) return
411
+ res.end('OK')
412
+ })
413
+
414
+ const response = await fetch(httpServer.url)
415
+ expect(response.status).toBe(402)
416
+
417
+ const challenge = Challenge.fromResponse(response, {
418
+ methods: [tempo_client.charge()],
419
+ })
420
+
421
+ const credential = Credential.from({
422
+ challenge,
423
+ payload: { hash: settleTxHash, type: 'hash' as const },
424
+ })
425
+
426
+ {
427
+ const response = await fetch(httpServer.url, {
428
+ headers: { Authorization: Credential.serialize(credential) },
429
+ })
430
+ expect(response.status).toBe(402)
431
+ const body = (await response.json()) as { detail: string }
432
+ expect(body.detail).toContain('Payment verification failed: no matching transfer found.')
433
+ }
434
+
435
+ httpServer.close()
436
+ })
437
+
438
+ test('behavior: rejects replayed transaction hash', async () => {
439
+ const dedupServer = Mppx_server.create({
440
+ methods: [
441
+ tempo_server.charge({
442
+ getClient() {
443
+ return client
444
+ },
445
+ currency: asset,
446
+ account: accounts[0],
447
+ store: Store.memory(),
448
+ }),
449
+ ],
450
+ realm,
451
+ secretKey,
452
+ })
453
+
454
+ const httpServer = await Http.createServer(async (req, res) => {
455
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
456
+ req,
457
+ res,
458
+ )
459
+ if (result.status === 402) return
460
+ res.end('OK')
461
+ })
462
+
463
+ const response1 = await fetch(httpServer.url)
464
+ expect(response1.status).toBe(402)
465
+
466
+ const challenge1 = Challenge.fromResponse(response1, {
467
+ methods: [tempo_client.charge()],
468
+ })
469
+
470
+ const { receipt } = await Actions.token.transferSync(client, {
471
+ account: accounts[1],
472
+ amount: BigInt(challenge1.request.amount),
473
+ to: challenge1.request.recipient as Hex.Hex,
474
+ token: challenge1.request.currency as Hex.Hex,
475
+ })
476
+
477
+ const credential1 = Credential.from({
478
+ challenge: challenge1,
479
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
480
+ })
481
+
482
+ {
483
+ const response = await fetch(httpServer.url, {
484
+ headers: { Authorization: Credential.serialize(credential1) },
485
+ })
486
+ expect(response.status).toBe(200)
487
+ }
488
+
489
+ const response2 = await fetch(httpServer.url)
490
+ expect(response2.status).toBe(402)
491
+
492
+ const challenge2 = Challenge.fromResponse(response2, {
493
+ methods: [tempo_client.charge()],
494
+ })
495
+
496
+ const mixedCaseHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
497
+
498
+ const credential2 = Credential.from({
499
+ challenge: challenge2,
500
+ payload: { hash: mixedCaseHash, type: 'hash' as const },
501
+ })
502
+
503
+ {
504
+ const response = await fetch(httpServer.url, {
505
+ headers: { Authorization: Credential.serialize(credential2) },
506
+ })
507
+ expect(response.status).toBe(402)
508
+ const body = (await response.json()) as { detail: string }
509
+ expect(body.detail).toContain('Transaction hash has already been used.')
510
+ }
511
+
512
+ httpServer.close()
513
+ })
514
+
515
+ test('behavior: rejects replayed hash with alternating case', async () => {
516
+ const dedupServer = Mppx_server.create({
517
+ methods: [
518
+ tempo_server.charge({
519
+ getClient() {
520
+ return client
521
+ },
522
+ currency: asset,
523
+ account: accounts[0],
524
+ store: Store.memory(),
525
+ }),
526
+ ],
527
+ realm,
528
+ secretKey,
529
+ })
530
+
531
+ const httpServer = await Http.createServer(async (req, res) => {
532
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
533
+ req,
534
+ res,
535
+ )
536
+ if (result.status === 402) return
537
+ res.end('OK')
538
+ })
539
+
540
+ const response1 = await fetch(httpServer.url)
541
+ expect(response1.status).toBe(402)
542
+
543
+ const challenge1 = Challenge.fromResponse(response1, {
544
+ methods: [tempo_client.charge()],
545
+ })
546
+
547
+ const { receipt } = await Actions.token.transferSync(client, {
548
+ account: accounts[1],
549
+ amount: BigInt(challenge1.request.amount),
550
+ to: challenge1.request.recipient as Hex.Hex,
551
+ token: challenge1.request.currency as Hex.Hex,
552
+ })
553
+
554
+ const hex = receipt.transactionHash.slice(2)
555
+ const alternating = `0x${hex
556
+ .split('')
557
+ .map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
558
+ .join('')}` as Hex.Hex
559
+
560
+ const credential1 = Credential.from({
561
+ challenge: challenge1,
562
+ payload: { hash: alternating, type: 'hash' as const },
563
+ })
564
+
565
+ {
566
+ const response = await fetch(httpServer.url, {
567
+ headers: { Authorization: Credential.serialize(credential1) },
568
+ })
569
+ expect(response.status).toBe(200)
570
+ }
571
+
572
+ const response2 = await fetch(httpServer.url)
573
+ expect(response2.status).toBe(402)
574
+
575
+ const challenge2 = Challenge.fromResponse(response2, {
576
+ methods: [tempo_client.charge()],
577
+ })
578
+
579
+ const credential2 = Credential.from({
580
+ challenge: challenge2,
581
+ payload: { hash: receipt.transactionHash.toLowerCase() as Hex.Hex, type: 'hash' as const },
582
+ })
583
+
584
+ {
585
+ const response = await fetch(httpServer.url, {
586
+ headers: { Authorization: Credential.serialize(credential2) },
587
+ })
588
+ expect(response.status).toBe(402)
589
+ const body = (await response.json()) as { detail: string }
590
+ expect(body.detail).toContain('Transaction hash has already been used.')
591
+ }
592
+
593
+ httpServer.close()
594
+ })
595
+
596
+ test('behavior: accepts uppercase hash on first use', async () => {
597
+ const dedupServer = Mppx_server.create({
598
+ methods: [
599
+ tempo_server.charge({
600
+ getClient() {
601
+ return client
602
+ },
603
+ currency: asset,
604
+ account: accounts[0],
605
+ store: Store.memory(),
606
+ }),
607
+ ],
608
+ realm,
609
+ secretKey,
610
+ })
611
+
612
+ const httpServer = await Http.createServer(async (req, res) => {
613
+ const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
614
+ req,
615
+ res,
616
+ )
617
+ if (result.status === 402) return
618
+ res.end('OK')
619
+ })
620
+
621
+ const response = await fetch(httpServer.url)
622
+ expect(response.status).toBe(402)
623
+
624
+ const challenge = Challenge.fromResponse(response, {
625
+ methods: [tempo_client.charge()],
626
+ })
627
+
628
+ const { receipt } = await Actions.token.transferSync(client, {
629
+ account: accounts[1],
630
+ amount: BigInt(challenge.request.amount),
631
+ to: challenge.request.recipient as Hex.Hex,
632
+ token: challenge.request.currency as Hex.Hex,
633
+ })
634
+
635
+ const upperHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
636
+
637
+ const credential = Credential.from({
638
+ challenge,
639
+ payload: { hash: upperHash, type: 'hash' as const },
640
+ })
641
+
642
+ {
643
+ const response = await fetch(httpServer.url, {
644
+ headers: { Authorization: Credential.serialize(credential) },
645
+ })
646
+ expect(response.status).toBe(200)
647
+ }
648
+
649
+ httpServer.close()
650
+ })
651
+
152
652
  test('behavior: rejects expired request', async () => {
153
653
  const httpServer = await Http.createServer(async (req, res) => {
154
654
  const result = await Mppx_server.toNodeListener(
@@ -225,43 +725,279 @@ describe('tempo', () => {
225
725
  }
226
726
  })
227
727
 
228
- const response = await fetch(httpServer.url)
229
- expect(response.status).toBe(500)
230
- expect(await response.text()).toMatchInlineSnapshot(
231
- `"No client configured with chainId 123456."`,
728
+ const response = await fetch(httpServer.url)
729
+ expect(response.status).toBe(500)
730
+ expect(await response.text()).toMatchInlineSnapshot(
731
+ `"No client configured with chainId 123456."`,
732
+ )
733
+
734
+ httpServer.close()
735
+ })
736
+
737
+ test('behavior: rejects when client not configured for chainId', async () => {
738
+ const httpServer = await Http.createServer(async (req, res) => {
739
+ try {
740
+ const result = await Mppx_server.toNodeListener(
741
+ server.charge({
742
+ amount: '1',
743
+ chainId: 999999,
744
+ }),
745
+ )(req, res)
746
+ if (result.status === 402) return
747
+ res.end('OK')
748
+ } catch (e) {
749
+ res.statusCode = 500
750
+ res.end((e as Error).message)
751
+ }
752
+ })
753
+
754
+ const response = await fetch(httpServer.url)
755
+ expect(response.status).toBe(500)
756
+ expect(await response.text()).toMatchInlineSnapshot(
757
+ `"Client not configured with chainId 999999."`,
758
+ )
759
+
760
+ httpServer.close()
761
+ })
762
+ })
763
+
764
+ describe('intent: charge; type: transaction; via Mppx', () => {
765
+ test('behavior: rejects pull then push replay of the same transaction hash', async () => {
766
+ const dedupServer = Mppx_server.create({
767
+ methods: [
768
+ tempo_server.charge({
769
+ getClient() {
770
+ return client
771
+ },
772
+ currency: asset,
773
+ account: accounts[0],
774
+ store: Store.memory(),
775
+ }),
776
+ ],
777
+ realm,
778
+ secretKey,
779
+ })
780
+
781
+ const pullClient = Mppx_client.create({
782
+ polyfill: false,
783
+ methods: [
784
+ tempo_client({
785
+ account: accounts[1],
786
+ mode: 'pull',
787
+ getClient() {
788
+ return client
789
+ },
790
+ }),
791
+ ],
792
+ })
793
+
794
+ const httpServer = await Http.createServer(async (req, res) => {
795
+ const result = await Mppx_server.toNodeListener(
796
+ dedupServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
797
+ )(req, res)
798
+ if (result.status === 402) return
799
+ res.end('OK')
800
+ })
801
+
802
+ const challengeResponse = await fetch(httpServer.url)
803
+ expect(challengeResponse.status).toBe(402)
804
+
805
+ const pullCredentialSerialized = await pullClient.createCredential(challengeResponse)
806
+
807
+ const pullAuthResponse = await fetch(httpServer.url, {
808
+ headers: { Authorization: pullCredentialSerialized },
809
+ })
810
+ expect(pullAuthResponse.status).toBe(200)
811
+
812
+ const pullReceipt = Receipt.fromResponse(pullAuthResponse)
813
+
814
+ const replayChallengeResponse = await fetch(httpServer.url)
815
+ expect(replayChallengeResponse.status).toBe(402)
816
+
817
+ const replayChallenge = Challenge.fromResponse(replayChallengeResponse, {
818
+ methods: [tempo_client.charge()],
819
+ })
820
+
821
+ const replayCredential = Credential.from({
822
+ challenge: replayChallenge,
823
+ payload: { hash: pullReceipt.reference as Hex.Hex, type: 'hash' as const },
824
+ })
825
+
826
+ const replayResponse = await fetch(httpServer.url, {
827
+ headers: { Authorization: Credential.serialize(replayCredential) },
828
+ })
829
+ expect(replayResponse.status).toBe(402)
830
+ const replayBody = (await replayResponse.json()) as { detail: string }
831
+ expect(replayBody.detail).toContain('Transaction hash has already been used.')
832
+
833
+ httpServer.close()
834
+ })
835
+
836
+ test('behavior: rejects concurrent replay of same serialized transaction', async () => {
837
+ const dedupServer = Mppx_server.create({
838
+ methods: [
839
+ tempo_server.charge({
840
+ getClient() {
841
+ return client
842
+ },
843
+ currency: asset,
844
+ account: accounts[0],
845
+ store: Store.memory(),
846
+ }),
847
+ ],
848
+ realm,
849
+ secretKey,
850
+ })
851
+
852
+ const mppx = Mppx_client.create({
853
+ polyfill: false,
854
+ methods: [
855
+ tempo_client({
856
+ account: accounts[1],
857
+ getClient() {
858
+ return client
859
+ },
860
+ }),
861
+ ],
862
+ })
863
+
864
+ const httpServer = await Http.createServer(async (req, res) => {
865
+ const result = await Mppx_server.toNodeListener(
866
+ dedupServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
867
+ )(req, res)
868
+ if (result.status === 402) return
869
+ res.end('OK')
870
+ })
871
+
872
+ // Get two challenges concurrently
873
+ const [challengeResponse1, challengeResponse2] = await Promise.all([
874
+ fetch(httpServer.url),
875
+ fetch(httpServer.url),
876
+ ])
877
+ expect(challengeResponse1.status).toBe(402)
878
+ expect(challengeResponse2.status).toBe(402)
879
+
880
+ // Create credential from first challenge (signs transaction)
881
+ const credential1 = await mppx.createCredential(challengeResponse1)
882
+
883
+ // Extract the serialized tx and re-wrap it with the second challenge
884
+ const decoded1 = Credential.deserialize(credential1)
885
+ const challenge2 = Challenge.fromResponse(challengeResponse2, {
886
+ methods: [tempo_client.charge()],
887
+ })
888
+ const credential2 = Credential.serialize(
889
+ Credential.from({
890
+ challenge: challenge2,
891
+ payload: decoded1.payload,
892
+ }),
893
+ )
894
+
895
+ // Submit SAME signed tx to both challenges concurrently
896
+ const [resA, resB] = await Promise.all([
897
+ fetch(httpServer.url, { headers: { Authorization: credential1 } }),
898
+ fetch(httpServer.url, { headers: { Authorization: credential2 } }),
899
+ ])
900
+
901
+ const statuses = [resA.status, resB.status].sort()
902
+ // One should succeed (200), the other should be rejected (402)
903
+ expect(statuses).toEqual([200, 402])
904
+
905
+ httpServer.close()
906
+ })
907
+
908
+ test('behavior: rejects malleable variants with different feePayerSignature', async () => {
909
+ const dedupStore = Store.memory()
910
+ const dedupServer = Mppx_server.create({
911
+ methods: [
912
+ tempo_server.charge({
913
+ getClient() {
914
+ return client
915
+ },
916
+ currency: asset,
917
+ account: accounts[0],
918
+ store: dedupStore,
919
+ }),
920
+ ],
921
+ realm,
922
+ secretKey,
923
+ })
924
+
925
+ const mppx = Mppx_client.create({
926
+ polyfill: false,
927
+ methods: [
928
+ tempo_client({
929
+ account: accounts[1],
930
+ getClient() {
931
+ return client
932
+ },
933
+ }),
934
+ ],
935
+ })
936
+
937
+ const httpServer = await Http.createServer(async (req, res) => {
938
+ const result = await Mppx_server.toNodeListener(
939
+ dedupServer.charge({
940
+ feePayer: accounts[0],
941
+ amount: '1',
942
+ currency: asset,
943
+ recipient: accounts[0].address,
944
+ }),
945
+ )(req, res)
946
+ if (result.status === 402) return
947
+ res.end('OK')
948
+ })
949
+
950
+ // Get two challenges
951
+ const challengeResponse1 = await fetch(httpServer.url)
952
+ const challengeResponse2 = await fetch(httpServer.url)
953
+ expect(challengeResponse1.status).toBe(402)
954
+ expect(challengeResponse2.status).toBe(402)
955
+
956
+ // Sign a transaction via the first challenge (produces 0x78 fee
957
+ // payer format with sender address in feePayerSignatureOrSender).
958
+ const credential1 = await mppx.createCredential(challengeResponse1)
959
+
960
+ // Submit the original transaction, should succeed.
961
+ const res1 = await fetch(httpServer.url, {
962
+ headers: { Authorization: credential1 },
963
+ })
964
+ expect(res1.status).toBe(200)
965
+
966
+ // Create a malleable variant of the SAME signed tx by
967
+ // re-serializing in 0x76 format with feePayerSignature=null
968
+ // (0x00 marker). Both deserialize to the same transaction
969
+ // (same calls, signature, from), but the raw bytes differ so
970
+ // keccak256 produces different hashes.
971
+ const decoded1 = Credential.deserialize(credential1)
972
+ const serializedTx = (decoded1.payload as { signature: string }).signature
973
+ const deserialized = TxEnvelopeTempo.deserialize(serializedTx as TxEnvelopeTempo.Serialized)
974
+ const malleableVariant = TxEnvelopeTempo.serialize(
975
+ TxEnvelopeTempo.from({ ...deserialized, feePayerSignature: null }),
232
976
  )
977
+ expect(malleableVariant).not.toEqual(serializedTx)
233
978
 
234
- httpServer.close()
235
- })
236
-
237
- test('behavior: rejects when client not configured for chainId', async () => {
238
- const httpServer = await Http.createServer(async (req, res) => {
239
- try {
240
- const result = await Mppx_server.toNodeListener(
241
- server.charge({
242
- amount: '1',
243
- chainId: 999999,
244
- }),
245
- )(req, res)
246
- if (result.status === 402) return
247
- res.end('OK')
248
- } catch (e) {
249
- res.statusCode = 500
250
- res.end((e as Error).message)
251
- }
979
+ // Wrap the malleable variant into the second challenge's credential
980
+ const challenge2 = Challenge.fromResponse(challengeResponse2, {
981
+ methods: [tempo_client.charge()],
252
982
  })
253
-
254
- const response = await fetch(httpServer.url)
255
- expect(response.status).toBe(500)
256
- expect(await response.text()).toMatchInlineSnapshot(
257
- `"Client not configured with chainId 999999."`,
983
+ const credential2 = Credential.serialize(
984
+ Credential.from({
985
+ challenge: challenge2,
986
+ payload: { signature: malleableVariant, type: 'transaction' as const },
987
+ }),
258
988
  )
259
989
 
990
+ // Submit the malleable variant. It bypasses the old
991
+ // keccak256(serializedTransaction) dedup (different raw bytes), but
992
+ // the post-broadcast dedup on the tx hash catches duplicates.
993
+ const res2 = await fetch(httpServer.url, {
994
+ headers: { Authorization: credential2 },
995
+ })
996
+ expect(res2.status).toBe(402)
997
+
260
998
  httpServer.close()
261
999
  })
262
- })
263
1000
 
264
- describe('intent: charge; type: transaction; via Mppx', () => {
265
1001
  test('default', async () => {
266
1002
  const mppx = Mppx_client.create({
267
1003
  polyfill: false,
@@ -544,6 +1280,133 @@ describe('tempo', () => {
544
1280
  feePayerServer.close()
545
1281
  })
546
1282
 
1283
+ test('behavior: access keys', async () => {
1284
+ const rootAccount = accounts[1]
1285
+ const accessKey = Account.fromSecp256k1(Secp256k1.randomPrivateKey(), {
1286
+ access: rootAccount,
1287
+ })
1288
+
1289
+ await Actions.accessKey.authorizeSync(client, {
1290
+ account: rootAccount,
1291
+ accessKey,
1292
+ feeToken: asset,
1293
+ })
1294
+
1295
+ const mppx = Mppx_client.create({
1296
+ polyfill: false,
1297
+ methods: [
1298
+ tempo_client({
1299
+ account: accessKey,
1300
+ getClient() {
1301
+ return client
1302
+ },
1303
+ }),
1304
+ ],
1305
+ })
1306
+
1307
+ const httpServer = await Http.createServer(async (req, res) => {
1308
+ const result = await Mppx_server.toNodeListener(
1309
+ server.charge({
1310
+ amount: '1',
1311
+ currency: asset,
1312
+ recipient: accounts[0].address,
1313
+ }),
1314
+ )(req, res)
1315
+ if (result.status === 402) return
1316
+ res.end('OK')
1317
+ })
1318
+
1319
+ const response = await mppx.fetch(httpServer.url)
1320
+ expect(response.status).toBe(200)
1321
+
1322
+ const receipt = Receipt.fromResponse(response)
1323
+ expect({
1324
+ ...receipt,
1325
+ reference: '[reference]',
1326
+ timestamp: '[timestamp]',
1327
+ }).toMatchInlineSnapshot(`
1328
+ {
1329
+ "method": "tempo",
1330
+ "reference": "[reference]",
1331
+ "status": "success",
1332
+ "timestamp": "[timestamp]",
1333
+ }
1334
+ `)
1335
+
1336
+ httpServer.close()
1337
+ })
1338
+
1339
+ test('behavior: access keys (fee payer)', async () => {
1340
+ const rootAccount = accounts[1]
1341
+ const accessKey = Account.fromSecp256k1(Secp256k1.randomPrivateKey(), {
1342
+ access: rootAccount,
1343
+ })
1344
+
1345
+ await Actions.accessKey.authorizeSync(client, {
1346
+ account: rootAccount,
1347
+ accessKey,
1348
+ feeToken: asset,
1349
+ })
1350
+
1351
+ const mppx = Mppx_client.create({
1352
+ polyfill: false,
1353
+ methods: [
1354
+ tempo_client({
1355
+ account: accessKey,
1356
+ getClient() {
1357
+ return client
1358
+ },
1359
+ }),
1360
+ ],
1361
+ })
1362
+
1363
+ const server = Mppx_server.create({
1364
+ methods: [
1365
+ tempo_server({
1366
+ getClient() {
1367
+ return client
1368
+ },
1369
+ currency: asset,
1370
+ account: accounts[0],
1371
+ feePayer: true,
1372
+ }),
1373
+ ],
1374
+ realm,
1375
+ secretKey,
1376
+ })
1377
+
1378
+ const httpServer = await Http.createServer(async (req, res) => {
1379
+ const result = await Mppx_server.toNodeListener(
1380
+ server.charge({
1381
+ amount: '1',
1382
+ currency: asset,
1383
+ recipient: accounts[0].address,
1384
+ }),
1385
+ )(req, res)
1386
+ if (result.status === 402) return
1387
+ res.end('OK')
1388
+ })
1389
+
1390
+ const response = await mppx.fetch(httpServer.url)
1391
+ expect(response.status).toBe(200)
1392
+
1393
+ const receipt = Receipt.fromResponse(response)
1394
+ expect({
1395
+ ...receipt,
1396
+ reference: '[reference]',
1397
+ timestamp: '[timestamp]',
1398
+ }).toMatchInlineSnapshot(`
1399
+ {
1400
+ "method": "tempo",
1401
+ "reference": "[reference]",
1402
+ "status": "success",
1403
+ "timestamp": "[timestamp]",
1404
+ }
1405
+ `)
1406
+
1407
+ httpServer.close()
1408
+ })
1409
+
547
1410
  test('error: rejects fee-payer transaction with unauthorized calls', async () => {
548
1411
  const httpServer = await Http.createServer(async (req, res) => {
549
1412
  const result = await Mppx_server.toNodeListener(
@@ -608,6 +1471,192 @@ describe('tempo', () => {
608
1471
 
609
1472
  httpServer.close()
610
1473
  })
1474
+
1475
+ test('error: rejects unsigned transaction (fee payer becomes sender)', async () => {
1476
+ const httpServer = await Http.createServer(async (req, res) => {
1477
+ const result = await Mppx_server.toNodeListener(
1478
+ server.charge({
1479
+ feePayer: accounts[0],
1480
+ amount: '1',
1481
+ currency: asset,
1482
+ recipient: accounts[0].address,
1483
+ }),
1484
+ )(req, res)
1485
+ if (result.status === 402) return
1486
+ res.end('OK')
1487
+ })
1488
+
1489
+ const response = await fetch(httpServer.url)
1490
+ expect(response.status).toBe(402)
1491
+
1492
+ const challenge = Challenge.fromResponse(response, {
1493
+ methods: [tempo_client.charge()],
1494
+ })
1495
+ const request = challenge.request
1496
+
1497
+ // Craft an unsigned 0x76 transaction — no user signature.
1498
+ // This is the exact attack vector from the fee payer POC: without a
1499
+ // signature check the fee payer signs as both sender AND fee payer,
1500
+ // letting the attacker control the tx content.
1501
+ const unsignedTx = (await Transaction.serialize({
1502
+ calls: [
1503
+ {
1504
+ to: request.currency as `0x${string}`,
1505
+ data: encodeFunctionData({
1506
+ abi: Abis.tip20,
1507
+ functionName: 'transfer',
1508
+ args: [request.recipient as `0x${string}`, BigInt(request.amount)],
1509
+ }),
1510
+ },
1511
+ ],
1512
+ chainId: chain.id,
1513
+ gas: 100_000n,
1514
+ maxFeePerGas: 1_000_000_000n,
1515
+ maxPriorityFeePerGas: 1_000_000_000n,
1516
+ nonce: 0,
1517
+ } as never)) as string
1518
+
1519
+ const credential = Credential.from({
1520
+ challenge,
1521
+ payload: { signature: unsignedTx, type: 'transaction' as const },
1522
+ })
1523
+
1524
+ {
1525
+ const response = await fetch(httpServer.url, {
1526
+ headers: { Authorization: Credential.serialize(credential) },
1527
+ })
1528
+ expect(response.status).toBe(402)
1529
+ const body = (await response.json()) as { detail: string }
1530
+ expect(body.detail).toContain(
1531
+ 'Transaction must be signed by the sender before fee payer co-signing.',
1532
+ )
1533
+ }
1534
+
1535
+ httpServer.close()
1536
+ })
1537
+
1538
+ test('error: rejects non-Tempo transaction type', async () => {
1539
+ const httpServer = await Http.createServer(async (req, res) => {
1540
+ const result = await Mppx_server.toNodeListener(
1541
+ server.charge({
1542
+ feePayer: accounts[0],
1543
+ amount: '1',
1544
+ currency: asset,
1545
+ recipient: accounts[0].address,
1546
+ }),
1547
+ )(req, res)
1548
+ if (result.status === 402) return
1549
+ res.end('OK')
1550
+ })
1551
+
1552
+ const response = await fetch(httpServer.url)
1553
+ expect(response.status).toBe(402)
1554
+
1555
+ const challenge = Challenge.fromResponse(response, {
1556
+ methods: [tempo_client.charge()],
1557
+ })
1558
+
1559
+ // Submit a non-0x76 serialized transaction (e.g. EIP-1559 0x02 prefix)
1560
+ const fakeTx =
1561
+ '0x02f8650182a5bf843b9aca00843b9aca008252089400000000000000000000000000000000000000008080c001a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000'
1562
+
1563
+ const credential = Credential.from({
1564
+ challenge,
1565
+ payload: { signature: fakeTx, type: 'transaction' as const },
1566
+ })
1567
+
1568
+ {
1569
+ const response = await fetch(httpServer.url, {
1570
+ headers: { Authorization: Credential.serialize(credential) },
1571
+ })
1572
+ expect(response.status).toBe(402)
1573
+ const body = (await response.json()) as { detail: string }
1574
+ expect(body.detail).toContain('Only Tempo (0x76/0x78) transactions are supported.')
1575
+ }
1576
+
1577
+ httpServer.close()
1578
+ })
1579
+ })
1580
+
1581
+ describe('intent: charge; type: transaction; defense-in-depth', () => {
1582
+ test('behavior: rejects pull transaction when receipt has no Transfer log', async () => {
1583
+ // Even when calldata looks correct, the server should verify that a Transfer
1584
+ // event actually appears in the on-chain receipt.
1585
+ // This guards against edge cases where calldata validation passes but the
1586
+ // transfer doesn't actually execute (e.g. contract upgrade, unexpected
1587
+ // silent no-op, or a bug in calldata matching).
1588
+ let interceptReceipt = false
1589
+ const interceptingClient = createClient({
1590
+ chain: client.chain,
1591
+ transport: custom({
1592
+ async request(args: any) {
1593
+ const result = await client.transport.request(args)
1594
+ if (interceptReceipt && args?.method === 'eth_sendRawTransactionSync') {
1595
+ return { ...(result as any), logs: [] }
1596
+ }
1597
+ return result
1598
+ },
1599
+ }),
1600
+ })
1601
+
1602
+ const serverProxy = Mppx_server.create({
1603
+ methods: [
1604
+ tempo_server.charge({
1605
+ getClient() {
1606
+ return interceptingClient
1607
+ },
1608
+ currency: asset,
1609
+ account: accounts[0],
1610
+ }),
1611
+ ],
1612
+ realm,
1613
+ secretKey,
1614
+ })
1615
+
1616
+ const mppx = Mppx_client.create({
1617
+ polyfill: false,
1618
+ methods: [
1619
+ tempo_client({
1620
+ account: accounts[1],
1621
+ mode: 'pull',
1622
+ getClient() {
1623
+ return client
1624
+ },
1625
+ }),
1626
+ ],
1627
+ })
1628
+
1629
+ const httpServer = await Http.createServer(async (req, res) => {
1630
+ const result = await Mppx_server.toNodeListener(
1631
+ serverProxy.charge({
1632
+ amount: '1',
1633
+ currency: asset,
1634
+ recipient: accounts[0].address,
1635
+ }),
1636
+ )(req, res)
1637
+ if (result.status === 402) return
1638
+ res.end('OK')
1639
+ })
1640
+
1641
+ const response = await fetch(httpServer.url)
1642
+ expect(response.status).toBe(402)
1643
+
1644
+ const credential = await mppx.createCredential(response)
1645
+
1646
+ // Enable interception so the receipt comes back with empty logs
1647
+ interceptReceipt = true
1648
+
1649
+ const authResponse = await fetch(httpServer.url, {
1650
+ headers: { Authorization: credential },
1651
+ })
1652
+
1653
+ // Should reject: receipt has no Transfer log proving the payment occurred
1654
+ expect(authResponse.status).toBe(402)
1655
+ const body = (await authResponse.json()) as { detail: string }
1656
+ expect(body.detail).toContain('no matching transfer found')
1657
+
1658
+ httpServer.close()
1659
+ })
611
1660
  })
612
1661
 
613
1662
  describe('intent: charge; type: transaction; waitForConfirmation: false', () => {