22 Injectable ,
33 Logger ,
44 BadRequestException ,
5+ ConflictException ,
56 InternalServerErrorException ,
67} from "@nestjs/common" ;
78import { createHash } from "crypto" ;
@@ -18,20 +19,18 @@ import { buildScVal } from "./utils/param-builder";
1819import { SorobanRpcService } from "./soroban-rpc.service" ;
1920import { mapSorobanError } from "../common/soroban-errors" ;
2021import { SorobanErrorCode } from "../common/soroban-errors" ;
22+ import { InvocationReplayService } from "./invocation-replay.service" ;
2123
2224const STROOPS_PER_XLM = 10_000_000 ;
2325const BASE_FEE = 100 ; // stroops
2426
2527@Injectable ( )
2628export class TransactionsService {
2729 private readonly logger = new Logger ( TransactionsService . name ) ;
28- private readonly idempotencyResponses = new Map <
29- string ,
30- ComposeTransactionResponse | ComposeTransactionError
31- > ( ) ;
32- private readonly idempotencyFingerprints = new Map < string , string > ( ) ;
33-
34- constructor ( private readonly sorobanRpcService : SorobanRpcService ) { }
30+ constructor (
31+ private readonly sorobanRpcService : SorobanRpcService ,
32+ private readonly invocationReplayService : InvocationReplayService ,
33+ ) { }
3534
3635 async composeTransaction (
3736 dto : ComposeTransactionDto ,
@@ -40,16 +39,21 @@ export class TransactionsService {
4039
4140 const payloadFingerprint = this . buildFingerprint ( dto ) ;
4241 const idempotencyKey = dto . idempotencyKey ?? payloadFingerprint ;
43- const fingerprintForKey = this . idempotencyFingerprints . get ( idempotencyKey ) ;
44- if ( fingerprintForKey && fingerprintForKey !== payloadFingerprint ) {
45- throw new BadRequestException (
42+ const replay = await this . invocationReplayService . claim (
43+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
44+ idempotencyKey ,
45+ payloadFingerprint ,
46+ ) ;
47+ if ( replay . kind === "conflict" ) {
48+ throw new ConflictException (
4649 "This idempotency key was already used with a different payload." ,
4750 ) ;
4851 }
49-
50- const cached = this . idempotencyResponses . get ( idempotencyKey ) ;
51- if ( cached ) {
52- return cached ;
52+ if ( replay . kind === "in_flight" ) {
53+ throw new ConflictException ( "This invocation is already in progress." ) ;
54+ }
55+ if ( replay . kind === "cached" ) {
56+ return replay . response as ComposeTransactionResponse | ComposeTransactionError ;
5357 }
5458
5559 const startTime = Date . now ( ) ;
@@ -64,6 +68,10 @@ export class TransactionsService {
6468 try {
6569 account = await this . sorobanRpcService . getAccount ( dto . sourceAccount ) ;
6670 } catch ( err ) {
71+ await this . invocationReplayService . release (
72+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
73+ idempotencyKey ,
74+ ) ;
6775 return {
6876 success : false ,
6977 error : err . message ,
@@ -76,6 +84,10 @@ export class TransactionsService {
7684 try {
7785 scParams = dto . params . map ( buildScVal ) ;
7886 } catch ( err ) {
87+ await this . invocationReplayService . release (
88+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
89+ idempotencyKey ,
90+ ) ;
7991 throw new BadRequestException ( `Invalid parameter: ${ err . message } ` ) ;
8092 }
8193
@@ -102,6 +114,10 @@ export class TransactionsService {
102114 simulationResult = await this . sorobanRpcService . simulateTransaction ( tx ) ;
103115 } catch ( err ) {
104116 this . logger . error ( "RPC simulation request failed" , err ) ;
117+ await this . invocationReplayService . release (
118+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
119+ idempotencyKey ,
120+ ) ;
105121 throw new InternalServerErrorException (
106122 "Failed to reach Soroban RPC provider." ,
107123 ) ;
@@ -119,7 +135,10 @@ export class TransactionsService {
119135 userMessage : mapped . message ,
120136 details : mapped . details ,
121137 } ;
122- this . rememberResponse ( idempotencyKey , payloadFingerprint , failedResponse ) ;
138+ await this . invocationReplayService . release (
139+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
140+ idempotencyKey ,
141+ ) ;
123142 return failedResponse ;
124143 }
125144
@@ -134,7 +153,10 @@ export class TransactionsService {
134153 restorePreamble : simulationResult . restorePreamble ,
135154 } ,
136155 } as ComposeTransactionError ;
137- this . rememberResponse ( idempotencyKey , payloadFingerprint , restoreResponse ) ;
156+ await this . invocationReplayService . release (
157+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
158+ idempotencyKey ,
159+ ) ;
138160 return restoreResponse ;
139161 }
140162
@@ -203,7 +225,11 @@ export class TransactionsService {
203225 } ,
204226 } ,
205227 } ;
206- this . rememberResponse ( idempotencyKey , payloadFingerprint , response ) ;
228+ await this . invocationReplayService . complete (
229+ `${ dto . sourceAccount } :${ dto . networkPassphrase ?? "__default__" } ` ,
230+ idempotencyKey ,
231+ response ,
232+ ) ;
207233 return response ;
208234 }
209235
@@ -230,12 +256,4 @@ export class TransactionsService {
230256 return createHash ( "sha256" ) . update ( normalized ) . digest ( "hex" ) ;
231257 }
232258
233- private rememberResponse (
234- idempotencyKey : string ,
235- fingerprint : string ,
236- response : ComposeTransactionResponse | ComposeTransactionError ,
237- ) : void {
238- this . idempotencyFingerprints . set ( idempotencyKey , fingerprint ) ;
239- this . idempotencyResponses . set ( idempotencyKey , response ) ;
240- }
241259}
0 commit comments