@@ -155,8 +155,30 @@ const markIntentAsConfirmed = async ({
155155 return err ( new ObjectNotFoundError ( 'Intent not found' ) )
156156 }
157157
158+ // Idempotency guard — do not overwrite an intent that is already in a
159+ // post-PENDING state. Duplicate calls arise from:
160+ // • chain reorgs causing the same event to be re-emitted
161+ // • the payment manager reconnecting and re-processing already-seen logs
162+ // • watchTransaction and the _checkConfirmedIntents polling loop racing
163+ //
164+ // We return ok() rather than an error so the caller does not treat a
165+ // duplicate as a failure and does not retry indefinitely.
166+ if (
167+ intent . status === IntentStatus . CONFIRMED ||
168+ intent . status === IntentStatus . COMPLETED ||
169+ intent . status === IntentStatus . OVER_CAP ||
170+ intent . status === IntentStatus . FAILED ||
171+ intent . status === IntentStatus . EXPIRED
172+ ) {
173+ logger . info ( 'markIntentAsConfirmed: intent already processed — skipping' , {
174+ intentId,
175+ currentStatus : intent . status ,
176+ } )
177+ return ok ( intent )
178+ }
179+
158180 return ok (
159- intentsRepository . updateIntent ( {
181+ await intentsRepository . updateIntent ( {
160182 ...intent ,
161183 status : IntentStatus . CONFIRMED ,
162184 paymentAmount,
@@ -189,9 +211,37 @@ const onConfirmedIntent = async (intentId: string) => {
189211 return err ( new Error ( 'Intent has no deposit amount' ) )
190212 }
191213
214+ // Guard: reject payments whose value is too small to purchase even a single
215+ // byte of storage. getIntentCredits divides paymentAmount by shannonsPerByte
216+ // using BigInt integer division, so a dust payment (paymentAmount <
217+ // shannonsPerByte) yields 0 credits. Granting 0 credits would mark the
218+ // intent COMPLETED while giving the user nothing — a misleading outcome that
219+ // wastes a DB row and silently discards the payment.
220+ //
221+ // Both paymentAmount and shannonsPerByte are immutable on a confirmed intent,
222+ // so this condition is permanent. We mark the intent FAILED (terminal) so
223+ // the polling loop stops retrying. The on-chain payment is irreversible;
224+ // resolution requires admin review (similar to OVER_CAP handling).
225+ const creditBytes = IntentsUseCases . getIntentCredits ( intent )
226+ if ( creditBytes === BigInt ( 0 ) ) {
227+ logger . warn (
228+ 'onConfirmedIntent: payment too small to yield any credits — marking FAILED' ,
229+ {
230+ intentId,
231+ paymentAmount : intent . paymentAmount . toString ( ) ,
232+ shannonsPerByte : intent . shannonsPerByte . toString ( ) ,
233+ } ,
234+ )
235+ await intentsRepository . updateIntent ( {
236+ ...intent ,
237+ status : IntentStatus . FAILED ,
238+ } )
239+ return ok ( )
240+ }
241+
192242 const addResult = await AccountsUseCases . addCreditsToAccount (
193243 intent . userPublicId ,
194- IntentsUseCases . getIntentCredits ( intent ) ,
244+ creditBytes ,
195245 intentId ,
196246 )
197247
@@ -326,6 +376,13 @@ const getPrice = async (): Promise<{ price: number; pricePerGB: number }> => {
326376 }
327377}
328378
379+ // Returns PENDING intents that already have a tx_hash — used by the payment
380+ // manager startup sweep to re-watch transactions that were submitted but never
381+ // confirmed due to a service restart or RPC outage.
382+ const getPendingWithTxHash = async ( ) : Promise < Intent [ ] > => {
383+ return intentsRepository . getPendingWithTxHash ( )
384+ }
385+
329386export const IntentsUseCases = {
330387 createIntent,
331388 getIntent,
@@ -335,6 +392,7 @@ export const IntentsUseCases = {
335392 markIntentAsConfirmed,
336393 getConfirmedIntents,
337394 getOverCapIntents,
395+ getPendingWithTxHash,
338396 reprocessOverCapIntent,
339397 getIntentCredits,
340398 getPrice,
0 commit comments