From a28c2174988176dc1dd98813e7c588bd457e165a Mon Sep 17 00:00:00 2001 From: timi Date: Mon, 31 Aug 2026 08:52:22 +0100 Subject: [PATCH] feat(admin): add DLQ for failed Soroban transactions with retry endpoint --- src/controllers/dlqController.ts | 55 +++++++++++++++++++++++ src/models/DlqEntry.ts | 43 ++++++++++++++++++ src/routes/adminRoutes.ts | 37 ++++++++++++++++ src/services/dlqService.ts | 75 ++++++++++++++++++++++++++++++++ src/services/stellarService.ts | 15 ++++--- 5 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 src/controllers/dlqController.ts create mode 100644 src/models/DlqEntry.ts create mode 100644 src/services/dlqService.ts diff --git a/src/controllers/dlqController.ts b/src/controllers/dlqController.ts new file mode 100644 index 0000000..1f1f734 --- /dev/null +++ b/src/controllers/dlqController.ts @@ -0,0 +1,55 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { dlqService } from '../services/dlqService'; +import { sendSuccess } from '../utils/responseWrapper'; + +export class DlqController { + /** + * GET /api/v1/dlq + * List DLQ entries + */ + public async getDlqEntries( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const page = parseInt(req.query.page as string, 10) || 1; + const limit = parseInt(req.query.limit as string, 10) || 10; + + const { data, total } = await dlqService.listEntries(page, limit); + + const totalPages = Math.ceil(total / limit); + + // Construct a response matching the existing patterns + sendSuccess( + res, + { entries: data, pagination: { total, page, limit, totalPages } }, + 'DLQ entries retrieved successfully', + ); + } catch (error) { + next(error); + } + } + + /** + * POST /api/v1/dlq/:id/retry + * Retry a specific DLQ entry + */ + public async retryDlqEntry( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { id } = req.params; + const result = await dlqService.retryEntry(id); + + sendSuccess(res, result, 'DLQ entry retried successfully', StatusCodes.OK); + } catch (error) { + next(error); + } + } +} + +export const dlqController = new DlqController(); diff --git a/src/models/DlqEntry.ts b/src/models/DlqEntry.ts new file mode 100644 index 0000000..0d1dc05 --- /dev/null +++ b/src/models/DlqEntry.ts @@ -0,0 +1,43 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export enum DlqStatus { + PENDING = 'pending', + RETRIED = 'retried', + RESOLVED = 'resolved', +} + +export interface IDlqEntry extends Document { + payload: any; + errorReason: string; + retryCount: number; + status: DlqStatus; + createdAt: Date; + updatedAt: Date; +} + +const DlqEntrySchema: Schema = new Schema( + { + payload: { + type: Schema.Types.Mixed, + required: true, + }, + errorReason: { + type: String, + required: true, + }, + retryCount: { + type: Number, + default: 0, + }, + status: { + type: String, + enum: Object.values(DlqStatus), + default: DlqStatus.PENDING, + }, + }, + { + timestamps: true, + } +); + +export const DlqEntry = mongoose.model('DlqEntry', DlqEntrySchema); diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 6f631da..6b682cf 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import authenticate from '../middleware/authenticate'; import requireRole from '../middleware/requireRole'; import { suspendUser, getDisputes } from '../controllers/adminController'; +import { dlqController } from '../controllers/dlqController'; import { UserRole } from '../interfaces/IUser'; const router = Router(); @@ -148,4 +149,40 @@ router.get('/disputes', getDisputes); */ router.put('/users/:id/suspend', suspendUser); +/** + * @openapi + * /v1/admin/dlq: + * get: + * tags: [Admin] + * summary: Fetch Dead Letter Queue (DLQ) entries + * description: Admin-only. Returns a paginated list of failed transaction DLQ entries. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successfully retrieved DLQ entries + */ +router.get('/dlq', dlqController.getDlqEntries); + +/** + * @openapi + * /v1/admin/dlq/{id}/retry: + * post: + * tags: [Admin] + * summary: Retry a specific DLQ entry + * description: Admin-only. Retries a previously failed transaction. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Successfully retried the DLQ entry + */ +router.post('/dlq/:id/retry', dlqController.retryDlqEntry); + export default router; diff --git a/src/services/dlqService.ts b/src/services/dlqService.ts new file mode 100644 index 0000000..395ec94 --- /dev/null +++ b/src/services/dlqService.ts @@ -0,0 +1,75 @@ +import { DlqEntry, IDlqEntry, DlqStatus } from '../models/DlqEntry'; +import AppError from '../utils/AppError'; +import { StatusCodes } from 'http-status-codes'; + +// We import stellarService inside the function to avoid circular dependencies +// since stellarService will import dlqService. +export class DlqService { + /** + * Add a failed transaction to the Dead Letter Queue. + */ + public async addEntry(payload: any, errorReason: string): Promise { + const entry = new DlqEntry({ + payload, + errorReason, + status: DlqStatus.PENDING, + retryCount: 0, + }); + return await entry.save(); + } + + /** + * List DLQ entries with pagination. + */ + public async listEntries(page: number = 1, limit: number = 10): Promise<{ data: IDlqEntry[]; total: number }> { + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + DlqEntry.find().sort({ createdAt: -1 }).skip(skip).limit(limit).exec(), + DlqEntry.countDocuments().exec(), + ]); + + return { data, total }; + } + + /** + * Retry a specific DLQ entry. + */ + public async retryEntry(id: string): Promise { + const entry = await DlqEntry.findById(id); + if (!entry) { + throw new AppError('DLQ entry not found', StatusCodes.NOT_FOUND); + } + + if (entry.status === DlqStatus.RESOLVED) { + throw new AppError('DLQ entry is already resolved', StatusCodes.BAD_REQUEST); + } + + // Increment retry count + entry.retryCount += 1; + entry.status = DlqStatus.RETRIED; + await entry.save(); + + try { + // Dynamic import to break circular dependency with stellarService + const { stellarService } = await import('./stellarService'); + + // Currently, we assume the payload is a SubmitEscrowLockInput + // since that's the main transaction failure we are catching. + // If there are other types, we might need a type field in the DLQ entry. + // For now, we attempt to retry it via stellarService.submitEscrowLock + const result = await stellarService.submitEscrowLock(entry.payload); + + entry.status = DlqStatus.RESOLVED; + await entry.save(); + return result; + } catch (error: any) { + // If it fails again, we log the new error but keep the status as RETRIED (or revert to PENDING) + entry.errorReason = error.message || String(error); + entry.status = DlqStatus.PENDING; // mark it pending again so it can be retried later + await entry.save(); + throw new AppError(`Retry failed: ${entry.errorReason}`, StatusCodes.INTERNAL_SERVER_ERROR); + } + } +} + +export const dlqService = new DlqService(); diff --git a/src/services/stellarService.ts b/src/services/stellarService.ts index 12a3b1f..6be425b 100644 --- a/src/services/stellarService.ts +++ b/src/services/stellarService.ts @@ -248,11 +248,16 @@ export class StellarService { `[StellarService] Submission failed — status=${response.status} ` + `errorResultXdr=${errXdr} payer=${payerAddress}`, ); - throw new AppError( - `Transaction submission failed with status '${response.status}'. ` + - `Error result XDR: ${errXdr}`, - StatusCodes.BAD_GATEWAY, - ); + + const errorMessage = `Transaction submission failed with status '${response.status}'. Error result XDR: ${errXdr}`; + + // Store in Dead Letter Queue (DLQ) + const { dlqService } = await import('./dlqService'); + await dlqService.addEntry(input, errorMessage).catch((dlqErr) => { + logger.error(`[StellarService] Failed to save to DLQ: ${extractMessage(dlqErr)}`); + }); + + throw new AppError(errorMessage, StatusCodes.BAD_GATEWAY); } // Unreachable — loop always returns or throws.