@@ -9,6 +9,7 @@ import { Abis, Actions, Addresses, Tick } from 'viem/tempo'
99import { beforeAll , describe , expect , test } from 'vitest'
1010import * as Http from '~test/Http.js'
1111import { accounts , asset , chain , client , fundAccount } from '~test/tempo/viem.js'
12+ import * as Store from '../../Store.js'
1213import * as Attribution from '../Attribution.js'
1314
1415const 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