Skip to content

Commit 32b3dde

Browse files
committed
fix: prevent tempo charge hash replay across push/pull
1 parent 3d60db4 commit 32b3dde

2 files changed

Lines changed: 321 additions & 0 deletions

File tree

src/tempo/server/Charge.test.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Abis, Actions, Addresses, Tick } from 'viem/tempo'
99
import { beforeAll, describe, expect, test } from 'vitest'
1010
import * as Http from '~test/Http.js'
1111
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
12+
import * as Store from '../../Store.js'
1213
import * as Attribution from '../Attribution.js'
1314

1415
const realm = 'api.example.com'
@@ -149,6 +150,220 @@ describe('tempo', () => {
149150
httpServer.close()
150151
})
151152

153+
test('behavior: rejects replayed transaction hash', async () => {
154+
const dedupServer = Mppx_server.create({
155+
methods: [
156+
tempo_server.charge({
157+
getClient() {
158+
return client
159+
},
160+
currency: asset,
161+
account: accounts[0],
162+
store: Store.memory(),
163+
}),
164+
],
165+
realm,
166+
secretKey,
167+
})
168+
169+
const httpServer = await Http.createServer(async (req, res) => {
170+
const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
171+
req,
172+
res,
173+
)
174+
if (result.status === 402) return
175+
res.end('OK')
176+
})
177+
178+
const response1 = await fetch(httpServer.url)
179+
expect(response1.status).toBe(402)
180+
181+
const challenge1 = Challenge.fromResponse(response1, {
182+
methods: [tempo_client.charge()],
183+
})
184+
185+
const { receipt } = await Actions.token.transferSync(client, {
186+
account: accounts[1],
187+
amount: BigInt(challenge1.request.amount),
188+
to: challenge1.request.recipient as Hex.Hex,
189+
token: challenge1.request.currency as Hex.Hex,
190+
})
191+
192+
const credential1 = Credential.from({
193+
challenge: challenge1,
194+
payload: { hash: receipt.transactionHash, type: 'hash' as const },
195+
})
196+
197+
{
198+
const response = await fetch(httpServer.url, {
199+
headers: { Authorization: Credential.serialize(credential1) },
200+
})
201+
expect(response.status).toBe(200)
202+
}
203+
204+
const response2 = await fetch(httpServer.url)
205+
expect(response2.status).toBe(402)
206+
207+
const challenge2 = Challenge.fromResponse(response2, {
208+
methods: [tempo_client.charge()],
209+
})
210+
211+
const mixedCaseHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
212+
213+
const credential2 = Credential.from({
214+
challenge: challenge2,
215+
payload: { hash: mixedCaseHash, type: 'hash' as const },
216+
})
217+
218+
{
219+
const response = await fetch(httpServer.url, {
220+
headers: { Authorization: Credential.serialize(credential2) },
221+
})
222+
expect(response.status).toBe(402)
223+
const body = (await response.json()) as { detail: string }
224+
expect(body.detail).toContain('Transaction hash has already been used.')
225+
}
226+
227+
httpServer.close()
228+
})
229+
230+
test('behavior: rejects replayed hash with alternating case', async () => {
231+
const dedupServer = Mppx_server.create({
232+
methods: [
233+
tempo_server.charge({
234+
getClient() {
235+
return client
236+
},
237+
currency: asset,
238+
account: accounts[0],
239+
store: Store.memory(),
240+
}),
241+
],
242+
realm,
243+
secretKey,
244+
})
245+
246+
const httpServer = await Http.createServer(async (req, res) => {
247+
const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
248+
req,
249+
res,
250+
)
251+
if (result.status === 402) return
252+
res.end('OK')
253+
})
254+
255+
const response1 = await fetch(httpServer.url)
256+
expect(response1.status).toBe(402)
257+
258+
const challenge1 = Challenge.fromResponse(response1, {
259+
methods: [tempo_client.charge()],
260+
})
261+
262+
const { receipt } = await Actions.token.transferSync(client, {
263+
account: accounts[1],
264+
amount: BigInt(challenge1.request.amount),
265+
to: challenge1.request.recipient as Hex.Hex,
266+
token: challenge1.request.currency as Hex.Hex,
267+
})
268+
269+
const hex = receipt.transactionHash.slice(2)
270+
const alternating = `0x${hex
271+
.split('')
272+
.map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
273+
.join('')}` as Hex.Hex
274+
275+
const credential1 = Credential.from({
276+
challenge: challenge1,
277+
payload: { hash: alternating, type: 'hash' as const },
278+
})
279+
280+
{
281+
const response = await fetch(httpServer.url, {
282+
headers: { Authorization: Credential.serialize(credential1) },
283+
})
284+
expect(response.status).toBe(200)
285+
}
286+
287+
const response2 = await fetch(httpServer.url)
288+
expect(response2.status).toBe(402)
289+
290+
const challenge2 = Challenge.fromResponse(response2, {
291+
methods: [tempo_client.charge()],
292+
})
293+
294+
const credential2 = Credential.from({
295+
challenge: challenge2,
296+
payload: { hash: receipt.transactionHash.toLowerCase() as Hex.Hex, type: 'hash' as const },
297+
})
298+
299+
{
300+
const response = await fetch(httpServer.url, {
301+
headers: { Authorization: Credential.serialize(credential2) },
302+
})
303+
expect(response.status).toBe(402)
304+
const body = (await response.json()) as { detail: string }
305+
expect(body.detail).toContain('Transaction hash has already been used.')
306+
}
307+
308+
httpServer.close()
309+
})
310+
311+
test('behavior: accepts uppercase hash on first use', async () => {
312+
const dedupServer = Mppx_server.create({
313+
methods: [
314+
tempo_server.charge({
315+
getClient() {
316+
return client
317+
},
318+
currency: asset,
319+
account: accounts[0],
320+
store: Store.memory(),
321+
}),
322+
],
323+
realm,
324+
secretKey,
325+
})
326+
327+
const httpServer = await Http.createServer(async (req, res) => {
328+
const result = await Mppx_server.toNodeListener(dedupServer.charge({ amount: '1' }))(
329+
req,
330+
res,
331+
)
332+
if (result.status === 402) return
333+
res.end('OK')
334+
})
335+
336+
const response = await fetch(httpServer.url)
337+
expect(response.status).toBe(402)
338+
339+
const challenge = Challenge.fromResponse(response, {
340+
methods: [tempo_client.charge()],
341+
})
342+
343+
const { receipt } = await Actions.token.transferSync(client, {
344+
account: accounts[1],
345+
amount: BigInt(challenge.request.amount),
346+
to: challenge.request.recipient as Hex.Hex,
347+
token: challenge.request.currency as Hex.Hex,
348+
})
349+
350+
const upperHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
351+
352+
const credential = Credential.from({
353+
challenge,
354+
payload: { hash: upperHash, type: 'hash' as const },
355+
})
356+
357+
{
358+
const response = await fetch(httpServer.url, {
359+
headers: { Authorization: Credential.serialize(credential) },
360+
})
361+
expect(response.status).toBe(200)
362+
}
363+
364+
httpServer.close()
365+
})
366+
152367
test('behavior: rejects expired request', async () => {
153368
const httpServer = await Http.createServer(async (req, res) => {
154369
const result = await Mppx_server.toNodeListener(
@@ -262,6 +477,77 @@ describe('tempo', () => {
262477
})
263478

264479
describe('intent: charge; type: transaction; via Mppx', () => {
480+
test('behavior: rejects pull then push replay of the same transaction hash', async () => {
481+
const dedupServer = Mppx_server.create({
482+
methods: [
483+
tempo_server.charge({
484+
getClient() {
485+
return client
486+
},
487+
currency: asset,
488+
account: accounts[0],
489+
store: Store.memory(),
490+
}),
491+
],
492+
realm,
493+
secretKey,
494+
})
495+
496+
const pullClient = Mppx_client.create({
497+
polyfill: false,
498+
methods: [
499+
tempo_client({
500+
account: accounts[1],
501+
mode: 'pull',
502+
getClient() {
503+
return client
504+
},
505+
}),
506+
],
507+
})
508+
509+
const httpServer = await Http.createServer(async (req, res) => {
510+
const result = await Mppx_server.toNodeListener(
511+
dedupServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
512+
)(req, res)
513+
if (result.status === 402) return
514+
res.end('OK')
515+
})
516+
517+
const challengeResponse = await fetch(httpServer.url)
518+
expect(challengeResponse.status).toBe(402)
519+
520+
const pullCredentialSerialized = await pullClient.createCredential(challengeResponse)
521+
522+
const pullAuthResponse = await fetch(httpServer.url, {
523+
headers: { Authorization: pullCredentialSerialized },
524+
})
525+
expect(pullAuthResponse.status).toBe(200)
526+
527+
const pullReceipt = Receipt.fromResponse(pullAuthResponse)
528+
529+
const replayChallengeResponse = await fetch(httpServer.url)
530+
expect(replayChallengeResponse.status).toBe(402)
531+
532+
const replayChallenge = Challenge.fromResponse(replayChallengeResponse, {
533+
methods: [tempo_client.charge()],
534+
})
535+
536+
const replayCredential = Credential.from({
537+
challenge: replayChallenge,
538+
payload: { hash: pullReceipt.reference as Hex.Hex, type: 'hash' as const },
539+
})
540+
541+
const replayResponse = await fetch(httpServer.url, {
542+
headers: { Authorization: Credential.serialize(replayCredential) },
543+
})
544+
expect(replayResponse.status).toBe(402)
545+
const replayBody = (await replayResponse.json()) as { detail: string }
546+
expect(replayBody.detail).toContain('Transaction hash has already been used.')
547+
548+
httpServer.close()
549+
})
550+
265551
test('default', async () => {
266552
const mppx = Mppx_client.create({
267553
polyfill: false,

0 commit comments

Comments
 (0)