@@ -77,6 +77,70 @@ async function handleBadSeqError(submissionId: string, stellarAddress: string):
7777 return "pending_indexer_confirmation" ;
7878}
7979
80+ interface RewardClaimData {
81+ submissionId : string ;
82+ userId : string ;
83+ score : number ;
84+ stellarAddress : string ;
85+ quizId : string ;
86+ }
87+
88+ // Phase 2: invoke Stellar, observe metrics, handle bad_seq. Throws on all other errors.
89+ async function _executeStellarRewardClaim ( claimData : RewardClaimData ) : Promise < string > {
90+ const proof = createQuizProof ( claimData . userId , claimData . quizId , claimData . score ) ;
91+ const txStart = process . hrtime . bigint ( ) ;
92+ try {
93+ const txHash = await invokeContract (
94+ config . STELLAR_REWARD_CONTRACT_ID ,
95+ "claim_reward" ,
96+ [
97+ StellarSdk . Address . fromString ( claimData . stellarAddress ) . toScVal ( ) ,
98+ StellarSdk . nativeToScVal ( claimData . score , { type : "u32" } ) ,
99+ StellarSdk . nativeToScVal ( Buffer . from ( proof . signature , "base64" ) ) ,
100+ ] ,
101+ ) ;
102+ stellarTxDurationSeconds . observe (
103+ { method : "claim_reward" , status : "success" } ,
104+ Number ( process . hrtime . bigint ( ) - txStart ) / 1e9 ,
105+ ) ;
106+ return txHash ;
107+ } catch ( err : unknown ) {
108+ stellarTxDurationSeconds . observe (
109+ { method : "claim_reward" , status : "error" } ,
110+ Number ( process . hrtime . bigint ( ) - txStart ) / 1e9 ,
111+ ) ;
112+ if (
113+ err instanceof StellarError &&
114+ ( err . message . includes ( "bad_seq" ) || err . message . includes ( "tx_bad_seq" ) )
115+ ) {
116+ return handleBadSeqError ( claimData . submissionId , claimData . stellarAddress ) ;
117+ }
118+ throw err ;
119+ }
120+ }
121+
122+ // Phase 3: write the confirmed or pending-confirmation outcome to the database.
123+ async function _applyRewardToDb ( submissionId : string , userId : string , txHash : string ) : Promise < void > {
124+ const isPending = txHash === "pending_indexer_confirmation" ;
125+ await db . transaction ( async ( tx ) => {
126+ if ( isPending ) {
127+ await tx
128+ . update ( quizSubmissions )
129+ . set ( { rewardClaimed : false , rewardPending : true , txHash } )
130+ . where ( eq ( quizSubmissions . id , submissionId ) ) ;
131+ } else {
132+ await tx
133+ . update ( quizSubmissions )
134+ . set ( { rewardClaimed : true , rewardPending : false , txHash, rewardAmount : REWARD_AMOUNT } )
135+ . where ( eq ( quizSubmissions . id , submissionId ) ) ;
136+ await tx
137+ . update ( users )
138+ . set ( { credits : sql `${ users . credits } + ${ REWARD_AMOUNT } ` } )
139+ . where ( eq ( users . id , userId ) ) ;
140+ }
141+ } ) ;
142+ }
143+
80144/**
81145 * Shared reward claim execution logic.
82146 * Used by both the direct claim path and the background retry processor.
@@ -140,70 +204,18 @@ export async function processRewardClaim(
140204 return true ;
141205 }
142206
143- // Phase 2: Execute Stellar transaction outside DB (no connection held)
144- const proof = createQuizProof ( userId , claimData . quizId , claimData . score ) ;
145- const txStart = process . hrtime . bigint ( ) ;
207+ // Phase 2 & 3: Stellar invocation then DB update — shared with claimReward.
146208 let txHash : string ;
147209 try {
148- txHash = await invokeContract (
149- config . STELLAR_REWARD_CONTRACT_ID ,
150- "claim_reward" ,
151- [
152- StellarSdk . Address . fromString ( claimData . stellarAddress ) . toScVal ( ) ,
153- StellarSdk . nativeToScVal ( claimData . score , { type : "u32" } ) ,
154- StellarSdk . nativeToScVal ( Buffer . from ( proof . signature , "base64" ) ) ,
155- ] ,
156- ) ;
157- stellarTxDurationSeconds . observe (
158- { method : "claim_reward" , status : "success" } ,
159- Number ( process . hrtime . bigint ( ) - txStart ) / 1e9 ,
160- ) ;
210+ txHash = await _executeStellarRewardClaim ( claimData ) ;
161211 } catch ( err : unknown ) {
162- stellarTxDurationSeconds . observe (
163- { method : "claim_reward" , status : "error" } ,
164- Number ( process . hrtime . bigint ( ) - txStart ) / 1e9 ,
165- ) ;
166- if ( err instanceof StellarError && ( err . message . includes ( "bad_seq" ) || err . message . includes ( "tx_bad_seq" ) ) ) {
167- txHash = await handleBadSeqError ( submissionId , claimData . stellarAddress ) ;
168- } else {
169- // Mark as failed and release pending status
170- await db
171- . update ( quizSubmissions )
172- . set ( { rewardPending : false , rewardFailed : true } )
173- . where ( eq ( quizSubmissions . id , submissionId ) ) ;
174- throw err ;
175- }
212+ await db
213+ . update ( quizSubmissions )
214+ . set ( { rewardPending : false , rewardFailed : true } )
215+ . where ( eq ( quizSubmissions . id , submissionId ) ) ;
216+ throw err ;
176217 }
177-
178- // Phase 3: Update DB with result in a quick transaction.
179- // isPending gates credit-granting on a confirmed on-chain result rather
180- // than the uncertain bad_seq outcome above — see #104.
181- const isPending = txHash === "pending_indexer_confirmation" ;
182- await db . transaction ( async ( tx ) => {
183- if ( isPending ) {
184- await tx
185- . update ( quizSubmissions )
186- . set ( { rewardClaimed : false , rewardPending : true , txHash } )
187- . where ( eq ( quizSubmissions . id , submissionId ) ) ;
188- } else {
189- await tx
190- . update ( quizSubmissions )
191- . set ( {
192- rewardClaimed : true ,
193- rewardPending : false ,
194- txHash,
195- rewardAmount : REWARD_AMOUNT ,
196- } )
197- . where ( eq ( quizSubmissions . id , submissionId ) ) ;
198-
199- await tx
200- . update ( users )
201- . set ( {
202- credits : sql `${ users . credits } + ${ REWARD_AMOUNT } ` ,
203- } )
204- . where ( eq ( users . id , userId ) ) ;
205- }
206- } ) ;
218+ await _applyRewardToDb ( submissionId , userId , txHash ) ;
207219
208220 return true ;
209221 } ) . then ( async ( result ) => {
@@ -295,24 +307,10 @@ export class RewardService {
295307 } ;
296308 } ) ;
297309
298- // Phase 2: Execute Stellar transaction outside DB (no connection held)
299- const proof = createQuizProof (
300- userId ,
301- claimData . quizId ,
302- claimData . score ,
303- ) ;
304-
305- let txHash : string | null = null ;
310+ // Phase 2 & 3: Stellar invocation then DB update — shared with processRewardClaim.
311+ let txHash : string ;
306312 try {
307- txHash = await invokeContract (
308- config . STELLAR_REWARD_CONTRACT_ID ,
309- "claim_reward" ,
310- [
311- StellarSdk . Address . fromString ( claimData . stellarAddress ) . toScVal ( ) ,
312- StellarSdk . nativeToScVal ( claimData . score , { type : "u32" } ) ,
313- StellarSdk . nativeToScVal ( Buffer . from ( proof . signature , "base64" ) ) ,
314- ] ,
315- ) ;
313+ txHash = await _executeStellarRewardClaim ( claimData ) ;
316314 } catch ( err : unknown ) {
317315 if ( err instanceof NotFoundError ) throw err ;
318316
@@ -321,15 +319,11 @@ export class RewardService {
321319 { submissionId } ,
322320 "Stellar circuit breaker open — queuing reward for later" ,
323321 ) ;
324- // Release pending status before queuing
325322 await db
326323 . update ( quizSubmissions )
327324 . set ( { rewardPending : false } )
328325 . where ( eq ( quizSubmissions . id , submissionId ) ) ;
329- await enqueueReward ( {
330- submissionId,
331- userId,
332- } ) ;
326+ await enqueueReward ( { submissionId, userId } ) ;
333327 rewardClaimsTotal . inc ( { status : "queued" } ) ;
334328 auditLog ( "reward.queued" , {
335329 userId,
@@ -342,54 +336,21 @@ export class RewardService {
342336 amount : REWARD_AMOUNT ,
343337 txHash : null ,
344338 queued : true ,
345- message :
346- "Reward claim queued — Stellar is temporarily unavailable" ,
339+ message : "Reward claim queued — Stellar is temporarily unavailable" ,
347340 } ;
348341 }
349342
350- if ( err instanceof StellarError && ( err . message . includes ( "bad_seq" ) || err . message . includes ( "tx_bad_seq" ) ) ) {
351- txHash = await handleBadSeqError ( submissionId , claimData . stellarAddress ) ;
352- } else {
353- // Mark as failed and release pending status
354- await db
355- . update ( quizSubmissions )
356- . set ( { rewardPending : false , rewardFailed : true } )
357- . where ( eq ( quizSubmissions . id , submissionId ) ) ;
358- logger . error ( { err, submissionId } , "On-chain reward claim failed" ) ;
359- throw new Error ( "Failed to process on-chain reward" ) ;
360- }
343+ await db
344+ . update ( quizSubmissions )
345+ . set ( { rewardPending : false , rewardFailed : true } )
346+ . where ( eq ( quizSubmissions . id , submissionId ) ) ;
347+ logger . error ( { err, submissionId } , "On-chain reward claim failed" ) ;
348+ throw new Error ( "Failed to process on-chain reward" ) ;
361349 }
362350
363- // Phase 3: Update DB with result in a quick transaction.
364- // isPending gates credit-granting on a confirmed on-chain result
365- // rather than the uncertain bad_seq outcome above — see #104.
366- const isPending = txHash === "pending_indexer_confirmation" ;
367- await db . transaction ( async ( tx ) => {
368- if ( isPending ) {
369- await tx
370- . update ( quizSubmissions )
371- . set ( { rewardClaimed : false , rewardPending : true , txHash } )
372- . where ( eq ( quizSubmissions . id , submissionId ) ) ;
373- } else {
374- await tx
375- . update ( quizSubmissions )
376- . set ( {
377- rewardClaimed : true ,
378- rewardPending : false ,
379- txHash,
380- rewardAmount : REWARD_AMOUNT ,
381- } )
382- . where ( eq ( quizSubmissions . id , submissionId ) ) ;
383-
384- await tx
385- . update ( users )
386- . set ( {
387- credits : sql `${ users . credits } + ${ REWARD_AMOUNT } ` ,
388- } )
389- . where ( eq ( users . id , userId ) ) ;
390- }
391- } ) ;
351+ await _applyRewardToDb ( submissionId , userId , txHash ) ;
392352
353+ const isPending = txHash === "pending_indexer_confirmation" ;
393354 if ( isPending ) {
394355 rewardClaimsTotal . inc ( { status : "pending" } ) ;
395356 auditLog ( "reward.pending_confirmation" , {
0 commit comments