mppx 0.4.7 → 0.4.9

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