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.
- package/CHANGELOG.md +12 -0
- package/dist/Store.d.ts +5 -4
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +22 -7
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +9 -22
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +5 -1
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +3 -1
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/internal/Route.d.ts +2 -2
- package/dist/proxy/internal/Route.d.ts.map +1 -1
- package/dist/proxy/internal/Route.js +4 -2
- package/dist/proxy/internal/Route.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +26 -8
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +12 -1
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/address.d.ts +3 -0
- package/dist/tempo/internal/address.d.ts.map +1 -0
- package/dist/tempo/internal/address.js +4 -0
- package/dist/tempo/internal/address.js.map +1 -0
- package/dist/tempo/internal/auto-swap.js +3 -3
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +4 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +11 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +11 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +109 -50
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +1 -1
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +39 -32
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +41 -1
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +51 -10
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +4 -2
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Voucher.d.ts.map +1 -1
- package/dist/tempo/session/Voucher.js +3 -2
- package/dist/tempo/session/Voucher.js.map +1 -1
- package/package.json +6 -2
- package/src/Store.test-d.ts +58 -0
- package/src/Store.ts +6 -4
- package/src/cli/cli.test.ts +124 -0
- package/src/cli/cli.ts +19 -7
- package/src/cli/plugins/tempo.ts +17 -23
- package/src/middlewares/elysia.test.ts +89 -0
- package/src/middlewares/elysia.ts +4 -1
- package/src/proxy/Proxy.test.ts +56 -0
- package/src/proxy/Proxy.ts +6 -1
- package/src/proxy/internal/Route.test.ts +57 -0
- package/src/proxy/internal/Route.ts +3 -1
- package/src/server/Mppx.test.ts +246 -0
- package/src/server/Mppx.ts +27 -8
- package/src/tempo/client/SessionManager.ts +11 -1
- package/src/tempo/internal/address.ts +6 -0
- package/src/tempo/internal/auto-swap.ts +3 -3
- package/src/tempo/internal/fee-payer.ts +18 -4
- package/src/tempo/server/Charge.test.ts +1080 -31
- package/src/tempo/server/Charge.ts +158 -63
- package/src/tempo/server/Session.test.ts +929 -111
- package/src/tempo/server/Session.ts +48 -33
- package/src/tempo/server/Sse.test.ts +1 -0
- package/src/tempo/server/internal/transport.test.ts +29 -0
- package/src/tempo/server/internal/transport.ts +41 -2
- package/src/tempo/session/Chain.test.ts +144 -0
- package/src/tempo/session/Chain.ts +58 -10
- package/src/tempo/session/ChannelStore.test.ts +10 -0
- package/src/tempo/session/ChannelStore.ts +6 -3
- package/src/tempo/session/Sse.test.ts +1 -0
- 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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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', () => {
|