diff --git a/backend/package.json b/backend/package.json index 250e3971..b9cec3bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,16 +89,19 @@ "@types/validator": "^13.11.6", "@types/web-push": "^3.6.4", "@types/ws": "^8.5.12", + "autocannon": "^7.15.0", "eslint": "^9.0.0", + "openapi-fetch": "^0.13.5", + "openapi-typescript": "^7.4.4", + "pino-pretty": "^13.0.0", "prisma": "^5.22.0", "ts-morph": "^24.0.0", "tsx": "^4.19.0", "typescript": "^5.6.0", "typescript-eslint": "^8.0.0", - "vitest": "^4.1.1", - "autocannon": "^7.15.0", - "openapi-fetch": "^0.13.5", - "openapi-typescript": "^7.4.4", - "pino-pretty": "^13.0.0" + "vitest": "^4.1.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.62.2" } } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 84fc2e30..9f728d98 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,6 +27,21 @@ enum PaymentStatus { failed cancelled refunded + pending_review +} + +enum ReorgEventStatus { + detected + investigating + resolved + false_positive +} + +enum TransactionReorgStatus { + pending_review + re_verified + confirmed + rolled_back } enum PaymentType { @@ -119,6 +134,7 @@ model Payment { user User? @relation(fields: [userId], references: [id]) project Project? @relation(fields: [projectId], references: [id]) milestone Milestone? @relation(fields: [milestoneId], references: [id]) + transactionReorgs TransactionReorg[] @@index([tenantId, createdAt]) @@index([status], map: "payments_active_status_idx") @@ -648,3 +664,54 @@ model ApiVersionEndpoint { @@map("api_version_endpoints") } +// ─── Chain Reorganization Models ───────────────────────────────────────────── + +model ReorgEvent { + id String @id @default(uuid()) + network String + detectedAt DateTime @default(now()) @map("detected_at") + reorgDepth Int @map("reorg_depth") + safetyThreshold Int @map("safety_threshold") + canonicalBlockHash String @map("canonical_block_hash") + orphanedBlockHash String @map("orphaned_block_hash") + fromBlockNumber Int @map("from_block_number") + toBlockNumber Int @map("to_block_number") + status ReorgEventStatus @default(detected) + resolvedAt DateTime? @map("resolved_at") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + affectedTransactions TransactionReorg[] + + @@index([network, detectedAt]) + @@index([status]) + @@index([reorgDepth]) + @@map("reorg_events") +} + +model TransactionReorg { + id String @id @default(uuid()) + reorgEventId String @map("reorg_event_id") + txHash String @map("tx_hash") + paymentId String? @map("payment_id") + network String + status TransactionReorgStatus @default(pending_review) + originalBlock Int? @map("original_block") + reorgDetails Json? @map("reorg_details") + reVerifiedAt DateTime? @map("re_verified_at") + resolvedAt DateTime? @map("resolved_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + reorgEvent ReorgEvent @relation(fields: [reorgEventId], references: [id]) + payment Payment? @relation(fields: [paymentId], references: [id]) + + @@index([reorgEventId]) + @@index([txHash]) + @@index([paymentId]) + @@index([status]) + @@index([network, createdAt]) + @@map("transaction_reorgs") +} + diff --git a/backend/src/index.ts b/backend/src/index.ts index c55c8184..b9cf56a8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -108,6 +108,8 @@ import { coldStartMonitorRouter } from './routes/cold-start-monitor.js'; import { rateLimitAnalyticsRouter } from './routes/rate-limit-analytics.js'; import { startScheduledRotation, stopScheduledRotation } from './config/credential-rotation.js'; import devDevRouter from './routes/dev/reload.js'; +import { reorgRouter } from './routes/reorg.js'; +import { getReorgDetector } from './services/chain/reorg-detector.js'; // Validate environment variables at startup validateEnv(); @@ -268,6 +270,7 @@ apiV1Router.use('/escrow', escrowRouter); apiV1Router.use('/disputes', disputesRouter); apiV1Router.use('/withdrawals', withdrawalsRouter); apiV1Router.use('/swap', swapSimulationRouter); +apiV1Router.use('/chain/reorgs', reorgRouter); apiV1Router.get('/compression/metrics', (_req, res) => { res.json(getCompressionMetrics()); }); @@ -456,6 +459,19 @@ server.listen(config.server.port, () => { // Webhook worker startWebhookWorker(); + // Chain reorganization detector (Issue #514) + if ( + process.env.ETHEREUM_RPC_URL || + process.env.POLYGON_RPC_URL || + process.env.STELLAR_RPC_URL + ) { + getReorgDetector().start().then(() => { + console.log('[ReorgDetector] Chain reorg monitoring started'); + }).catch((err: Error) => { + console.error('[ReorgDetector] Startup error:', err.message); + }); + } + // Auto-escalation cron setInterval(async () => { const count = await disputeService.processEscalations(); @@ -525,6 +541,13 @@ const shutdown = (signal: string) => { console.error('Error stopping batch processor:', err); } + try { + getReorgDetector().stop(); + console.log('Reorg detector stopped.'); + } catch (err) { + console.error('Error stopping reorg detector:', err); + } + clearInterval(analyticsInterval); try { diff --git a/backend/src/routes/reorg.ts b/backend/src/routes/reorg.ts new file mode 100644 index 00000000..02e84c1c --- /dev/null +++ b/backend/src/routes/reorg.ts @@ -0,0 +1,255 @@ +/** + * reorg.ts — Issue #514 + * + * REST endpoints for chain reorganization monitoring dashboard. + * + * GET /api/v1/chain/reorgs — paginated list of reorg events + * GET /api/v1/chain/reorgs/dashboard — summary stats + * GET /api/v1/chain/reorgs/history — historical incidents with resolution + * GET /api/v1/chain/reorgs/:id — single event + affected transactions + * POST /api/v1/chain/reorgs/simulate — trigger a simulated reorg (test only) + * + * Fix #10: all routes require a valid internal HMAC signature via + * verifyInternalSignature so they are not open to unauthenticated callers. + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { getReorgDetector } from '../services/chain/reorg-detector.js'; +import { getConfirmationTracker } from '../services/chain/confirmation-tracker.js'; +import { verifyInternalSignature } from '../middleware/internalSignature.js'; + +const prisma = new PrismaClient(); + +export const reorgRouter = Router(); + +// Fix #10: apply auth to every route on this router +reorgRouter.use(verifyInternalSignature); + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function parseIntQuery(val: unknown, fallback: number): number { + const n = parseInt(String(val), 10); + return isNaN(n) ? fallback : n; +} + +// Fix #6: validate a ?since= query param; returns null on invalid input +function parseSinceDate(val: unknown, defaultMs: number): Date | null { + if (typeof val !== 'string') return new Date(Date.now() - defaultMs); + const d = new Date(val); + if (isNaN(d.getTime())) return null; + return d; +} + +// ── GET /api/v1/chain/reorgs ────────────────────────────────────────────────── + +reorgRouter.get('/', async (req: Request, res: Response, next: NextFunction) => { + try { + const page = parseIntQuery(req.query.page, 1); + const limit = Math.min(parseIntQuery(req.query.limit, 20), 100); + const network = typeof req.query.network === 'string' ? req.query.network : undefined; + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + + const where: Record = {}; + if (network) where['network'] = network; + if (status) where['status'] = status; + + const [total, events] = await Promise.all([ + prisma.reorgEvent.count({ where }), + prisma.reorgEvent.findMany({ + where, + orderBy: { detectedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { _count: { select: { affectedTransactions: true } } }, + }), + ]); + + res.json({ + data: events, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); + } catch (err) { + next(err); + } +}); + +// ── GET /api/v1/chain/reorgs/dashboard ─────────────────────────────────────── + +reorgRouter.get('/dashboard', async (_req: Request, res: Response, next: NextFunction) => { + try { + const [ + totalEvents, + byStatus, + deepReorgs, + affectedTxCount, + recentEvents, + pendingReview, + ] = await Promise.all([ + prisma.reorgEvent.count(), + prisma.reorgEvent.groupBy({ by: ['status'], _count: { id: true } }), + prisma.reorgEvent.count({ + where: { reorgDepth: { gt: 12 } }, + }), + prisma.transactionReorg.count(), + prisma.reorgEvent.findMany({ + orderBy: { detectedAt: 'desc' }, + take: 5, + include: { _count: { select: { affectedTransactions: true } } }, + }), + prisma.transactionReorg.count({ where: { status: 'pending_review' } }), + ]); + + const tracker = getConfirmationTracker(); + + const statusCounts = Object.fromEntries( + byStatus.map((row: { status: string; _count: { id: number } }) => [row.status, row._count.id]), + ); + + res.json({ + summary: { + totalReorgEvents: totalEvents, + deepReorgs, + affectedTransactions: affectedTxCount, + pendingReview, + statusCounts, + }, + networkThresholds: { + ethereum: tracker.getThreshold('ethereum'), + polygon: tracker.getThreshold('polygon'), + stellar: tracker.getThreshold('stellar'), + }, + recentEvents, + }); + } catch (err) { + next(err); + } +}); + +// ── GET /api/v1/chain/reorgs/history ───────────────────────────────────────── + +reorgRouter.get('/history', async (req: Request, res: Response, next: NextFunction) => { + try { + const page = parseIntQuery(req.query.page, 1); + const limit = Math.min(parseIntQuery(req.query.limit, 50), 200); + const network = typeof req.query.network === 'string' ? req.query.network : undefined; + + // Fix #6: validate ?since= before passing to Prisma + const since = parseSinceDate(req.query.since, 30 * 24 * 3600 * 1000); + if (since === null) { + res.status(400).json({ error: '?since must be a valid ISO 8601 date string' }); + return; + } + + const where: Record = { detectedAt: { gte: since } }; + if (network) where['network'] = network; + + const [total, events] = await Promise.all([ + prisma.reorgEvent.count({ where }), + prisma.reorgEvent.findMany({ + where, + orderBy: { detectedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + include: { + affectedTransactions: { + select: { txHash: true, status: true, resolvedAt: true }, + }, + }, + }), + ]); + + res.json({ + data: events, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); + } catch (err) { + next(err); + } +}); + +// ── GET /api/v1/chain/reorgs/:id ────────────────────────────────────────────── + +reorgRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => { + try { + const event = await prisma.reorgEvent.findUnique({ + where: { id: req.params['id'] as string }, + include: { + affectedTransactions: { + orderBy: { createdAt: 'asc' }, + include: { + payment: { select: { id: true, txHash: true, status: true, amount: true, currency: true } }, + }, + }, + }, + }); + + if (!event) { + res.status(404).json({ error: 'Reorg event not found' }); + return; + } + + res.json({ data: event }); + } catch (err) { + next(err); + } +}); + +// ── POST /api/v1/chain/reorgs/simulate ─────────────────────────────────────── + +reorgRouter.post('/simulate', async (req: Request, res: Response, next: NextFunction) => { + if (process.env.NODE_ENV === 'production') { + res.status(403).json({ error: 'Simulation not available in production' }); + return; + } + + try { + const { + network = 'ethereum', + orphanedBlockHash, + canonicalBlockHash, + fromBlock, + toBlock, + affectedTxHashes = [], + } = req.body as { + network?: string; + orphanedBlockHash: string; + canonicalBlockHash: string; + fromBlock: number; + toBlock: number; + affectedTxHashes?: string[]; + }; + + if (!orphanedBlockHash || !canonicalBlockHash || fromBlock == null || toBlock == null) { + res.status(400).json({ + error: 'orphanedBlockHash, canonicalBlockHash, fromBlock, and toBlock are required', + }); + return; + } + + // Fix #4: reject inverted block range before it produces a negative reorgDepth + if (fromBlock > toBlock) { + res.status(400).json({ error: 'fromBlock must be less than or equal to toBlock' }); + return; + } + + const detector = getReorgDetector(); + const reorgEventId = await detector.simulateReorg( + network, + orphanedBlockHash, + canonicalBlockHash, + fromBlock, + toBlock, + affectedTxHashes, + ); + + const event = await prisma.reorgEvent.findUnique({ + where: { id: reorgEventId }, + include: { _count: { select: { affectedTransactions: true } } }, + }); + + res.status(201).json({ data: event }); + } catch (err) { + next(err); + } +}); diff --git a/backend/src/services/chain/__tests__/reorg-detector.test.ts b/backend/src/services/chain/__tests__/reorg-detector.test.ts new file mode 100644 index 00000000..1ac1a9cd --- /dev/null +++ b/backend/src/services/chain/__tests__/reorg-detector.test.ts @@ -0,0 +1,438 @@ +/** + * reorg-detector.test.ts — Issue #514 + * + * Integration tests using in-memory mock providers and Prisma mocks. + * No real blockchain or database required. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ConfirmationTracker, DEFAULT_THRESHOLDS } from '../confirmation-tracker.js'; +import { + ReorgDetector, + type ChainConfig, + type ChainProvider, + type BlockHeader, +} from '../reorg-detector.js'; + +// ── Prisma mock (vi.hoisted ensures refs are available inside vi.mock factory) ─ + +const { + mockReorgEventCreate, + mockTransactionReorgCreate, + mockTransactionReorgCount, + mockTransactionReorgUpdateMany, + mockPaymentFindMany, + mockPaymentUpdate, + mockReorgEventUpdate, + mockTransaction, + mockFetch, +} = vi.hoisted(() => { + const mockReorgEventCreate = vi.fn(); + const mockTransactionReorgCreate = vi.fn(); + const mockTransactionReorgCount = vi.fn().mockResolvedValue(0); + const mockTransactionReorgUpdateMany = vi.fn(); + const mockPaymentFindMany = vi.fn().mockResolvedValue([]); + const mockPaymentUpdate = vi.fn(); + const mockReorgEventUpdate = vi.fn(); + // prisma.$transaction receives a callback; execute it with the mock client + const mockTransaction = vi.fn((cb: (tx: unknown) => Promise) => + cb({ + reorgEvent: { create: mockReorgEventCreate }, + transactionReorg: { create: mockTransactionReorgCreate }, + payment: { update: mockPaymentUpdate }, + }), + ); + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + return { + mockReorgEventCreate, + mockTransactionReorgCreate, + mockTransactionReorgCount, + mockTransactionReorgUpdateMany, + mockPaymentFindMany, + mockPaymentUpdate, + mockReorgEventUpdate, + mockTransaction, + mockFetch, + }; +}); + +// PrismaClient must use a regular function (not arrow) so `new` works correctly +vi.mock('@prisma/client', () => ({ + PrismaClient: vi.fn().mockImplementation(function () { + return { + $transaction: mockTransaction, + reorgEvent: { create: mockReorgEventCreate, update: mockReorgEventUpdate }, + transactionReorg: { + create: mockTransactionReorgCreate, + count: mockTransactionReorgCount, + updateMany: mockTransactionReorgUpdateMany, + }, + payment: { findMany: mockPaymentFindMany, update: mockPaymentUpdate }, + }; + }), +})); + +// ── node-fetch mock ─────────────────────────────────────────────────────────── + +vi.mock('node-fetch', () => ({ default: mockFetch })); + +// ── Mock ChainProvider ──────────────────────────────────────────────────────── + +function makeBlock(number: number, hash: string, parentHash: string): BlockHeader { + return { number, hash, parentHash, timestamp: Date.now() }; +} + +class MockProvider implements ChainProvider { + private blocks = new Map(); + private head = 0; + + addBlock(block: BlockHeader): void { + this.blocks.set(block.number, block); + if (block.number > this.head) this.head = block.number; + } + + async getBlockNumber(): Promise { return this.head; } + async getBlock(n: number): Promise { return this.blocks.get(n) ?? null; } + setHead(n: number): void { this.head = n; } +} + +function makeMockDetector(network: string, provider: MockProvider, safetyThreshold = 2): ReorgDetector { + return new ReorgDetector({ + chains: [{ network, safetyThreshold } as ChainConfig], + providerFactory: () => provider, + }); +} + +// ── ConfirmationTracker ─────────────────────────────────────────────────────── + +describe('ConfirmationTracker', () => { + let tracker: ConfirmationTracker; + + beforeEach(() => { + // Fix test-isolation: each test gets a fresh instance; never touches the singleton + tracker = new ConfirmationTracker(); + }); + + it('returns correct default thresholds', () => { + expect(tracker.getThreshold('ethereum')).toBe(12); + expect(tracker.getThreshold('polygon')).toBe(64); + expect(tracker.getThreshold('stellar')).toBe(1); + }); + + it('falls back to default threshold for unknown networks', () => { + expect(tracker.getThreshold('avalanche')).toBe(DEFAULT_THRESHOLDS['default']); + }); + + it('records a confirmation and computes status correctly', () => { + tracker.setNetworkHead('ethereum', 1000); + tracker.recordConfirmation('0xabc', 'ethereum', 990); + + const status = tracker.getStatus('0xabc', 'ethereum'); + expect(status).not.toBeNull(); + expect(status!.confirmations).toBe(11); // 1000 - 990 + 1 + expect(status!.isFinalized).toBe(false); // 11 < 12 threshold + }); + + it('marks transaction as finalized when confirmations reach threshold', () => { + tracker.setNetworkHead('ethereum', 1000); + tracker.recordConfirmation('0xabc', 'ethereum', 989); // exactly 12 confirmations + + expect(tracker.isFinalized('0xabc', 'ethereum')).toBe(true); + }); + + it('does not finalize below threshold', () => { + tracker.setNetworkHead('ethereum', 1000); + tracker.recordConfirmation('0xabc', 'ethereum', 991); // 10 confirmations, needs 12 + + expect(tracker.isFinalized('0xabc', 'ethereum')).toBe(false); + }); + + it('findAffected returns only txs in the orphaned block range', () => { + tracker.recordConfirmation('0xaaa', 'ethereum', 100); + tracker.recordConfirmation('0xbbb', 'ethereum', 101); + tracker.recordConfirmation('0xccc', 'ethereum', 102); + tracker.recordConfirmation('0xddd', 'ethereum', 103); + + const hashes = tracker.findAffected('ethereum', 101, 102).map((a) => a.txHash).sort(); + expect(hashes).toEqual(['0xbbb', '0xccc']); + }); + + it('findAffected returns empty array when nothing is in range', () => { + tracker.recordConfirmation('0xaaa', 'ethereum', 100); + expect(tracker.findAffected('ethereum', 200, 300)).toHaveLength(0); + }); + + it('removes a confirmation', () => { + tracker.recordConfirmation('0xabc', 'ethereum', 100); + tracker.removeConfirmation('0xabc', 'ethereum'); + expect(tracker.getStatus('0xabc', 'ethereum')).toBeNull(); + }); + + it('is network-scoped — ethereum and polygon tracked independently', () => { + tracker.recordConfirmation('0xsame', 'ethereum', 100); + tracker.recordConfirmation('0xsame', 'polygon', 200); + + expect(tracker.getStatus('0xsame', 'ethereum')!.confirmedAtBlock).toBe(100); + expect(tracker.getStatus('0xsame', 'polygon')!.confirmedAtBlock).toBe(200); + }); +}); + +// ── ReorgDetector.simulateReorg ─────────────────────────────────────────────── + +describe('ReorgDetector.simulateReorg', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockReorgEventCreate.mockResolvedValue({ id: 'evt-sim-1' }); + mockPaymentFindMany.mockResolvedValue([]); + // $transaction executes the callback synchronously in tests + mockTransaction.mockImplementation((cb: (tx: unknown) => Promise) => + cb({ + reorgEvent: { create: mockReorgEventCreate }, + transactionReorg: { create: mockTransactionReorgCreate }, + payment: { update: mockPaymentUpdate }, + }), + ); + }); + + it('persists ReorgEvent with correct depth and block range', async () => { + const detector = new ReorgDetector({ + chains: [{ network: 'ethereum', safetyThreshold: 12 }], + }); + + await detector.simulateReorg('ethereum', '0xorphaned', '0xcanonical', 500, 502, []); + + expect(mockReorgEventCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + network: 'ethereum', + reorgDepth: 3, + fromBlockNumber: 500, + toBlockNumber: 502, + orphanedBlockHash: '0xorphaned', + canonicalBlockHash: '0xcanonical', + safetyThreshold: 12, + }), + }), + ); + }); + + it('marks affected payment as pending_review inside the transaction', async () => { + const paymentId = 'payment-abc-123'; + mockPaymentFindMany.mockResolvedValue([{ id: paymentId, txHash: '0xtxaffected' }]); + + const detector = new ReorgDetector({ + chains: [{ network: 'ethereum', safetyThreshold: 12 }], + }); + + await detector.simulateReorg('ethereum', '0xorphaned', '0xcanonical', 100, 101, ['0xtxaffected']); + + expect(mockPaymentUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: paymentId }, + data: { status: 'pending_review' }, + }), + ); + }); + + it('creates TransactionReorg row with correct reorgDetails', async () => { + const paymentId = 'payment-xyz-456'; + mockPaymentFindMany.mockResolvedValue([{ id: paymentId, txHash: '0xtxb' }]); + mockReorgEventCreate.mockResolvedValue({ id: 'evt-tr-1' }); + + const detector = new ReorgDetector({ + chains: [{ network: 'polygon', safetyThreshold: 64 }], + }); + + await detector.simulateReorg('polygon', '0xoldblock', '0xnewblock', 200, 203, ['0xtxb']); + + expect(mockTransactionReorgCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + txHash: '0xtxb', + paymentId, + network: 'polygon', + reorgDetails: expect.objectContaining({ + reorgDepth: 4, + orphanedBlockHash: '0xoldblock', + }), + }), + }), + ); + }); + + it('does NOT fire alert when depth is within safety threshold', async () => { + const detector = new ReorgDetector({ + chains: [{ network: 'ethereum', safetyThreshold: 12 }], + alertWebhookUrl: 'http://alerts.test/hook', + }); + + await detector.simulateReorg('ethereum', '0xA', '0xB', 100, 102, []); // depth 3 < 12 + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('fires alert when depth exceeds safety threshold', async () => { + const detector = new ReorgDetector({ + chains: [{ network: 'ethereum', safetyThreshold: 2 }], + alertWebhookUrl: 'http://alerts.test/hook', + }); + + await detector.simulateReorg('ethereum', '0xA', '0xB', 100, 104, []); // depth 5 > 2 + + expect(mockFetch).toHaveBeenCalledWith( + 'http://alerts.test/hook', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"severity":"critical"'), + }), + ); + }); + + // Fix #9 verification + it('sets TransactionReorg status to rolled_back when tx is not re-confirmed', async () => { + mockPaymentFindMany.mockResolvedValue([{ id: 'pay-1', txHash: '0xtx1' }]); + mockReorgEventCreate.mockResolvedValue({ id: 'evt-rolled' }); + + const detector = new ReorgDetector({ + chains: [{ network: 'ethereum', safetyThreshold: 12 }], + }); + + // processReorgJob with no rpcUrl and no providerFactory → isStillConfirmed = false + await detector.processReorgJob({ + reorgEventId: 'evt-rolled', + txHash: '0xtx1', + paymentId: 'pay-1', + network: 'ethereum', + }); + + expect(mockTransactionReorgUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'rolled_back' }), + }), + ); + expect(mockPaymentUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: 'pending' } }), + ); + }); +}); + +// ── ReorgDetector.pollChain ─────────────────────────────────────────────────── + +describe('ReorgDetector.pollChain', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockReorgEventCreate.mockResolvedValue({ id: 'evt-poll' }); + mockPaymentFindMany.mockResolvedValue([]); + mockTransaction.mockImplementation((cb: (tx: unknown) => Promise) => + cb({ + reorgEvent: { create: mockReorgEventCreate }, + transactionReorg: { create: mockTransactionReorgCreate }, + payment: { update: mockPaymentUpdate }, + }), + ); + }); + + it('advances chain tip on clean linear extension without triggering reorg', async () => { + const provider = new MockProvider(); + provider.addBlock(makeBlock(1, '0xb1', '0x0')); + provider.addBlock(makeBlock(2, '0xb2', '0xb1')); + provider.addBlock(makeBlock(3, '0xb3', '0xb2')); + + const detector = makeMockDetector('ethereum', provider, 2); + await detector.start(); + + expect(detector.getCurrentTip('ethereum')?.blockHash).toBe('0xb3'); + + provider.addBlock(makeBlock(4, '0xb4', '0xb3')); + await detector.pollChain('ethereum'); + + expect(detector.getCurrentTip('ethereum')?.blockHash).toBe('0xb4'); + expect(mockReorgEventCreate).not.toHaveBeenCalled(); + + await detector.stopAsync(); + }); + + it('calls handleReorg when parentHash mismatches canonical tip', async () => { + const provider = new MockProvider(); + provider.addBlock(makeBlock(1, '0xb1', '0x0')); + provider.addBlock(makeBlock(2, '0xb2', '0xb1')); + provider.addBlock(makeBlock(3, '0xb3', '0xb2')); + + const handleReorgSpy = vi + .spyOn( + ReorgDetector.prototype as unknown as { handleReorg: () => Promise }, + 'handleReorg' as never, + ) + .mockResolvedValue(undefined); + + const detector = makeMockDetector('ethereum', provider, 2); + await detector.start(); + + provider.addBlock(makeBlock(4, '0xb4_fork', '0xb2_fork')); + await detector.pollChain('ethereum'); + + expect(handleReorgSpy).toHaveBeenCalledOnce(); + + handleReorgSpy.mockRestore(); + await detector.stopAsync(); + }); + + // Fix #3 verification: same-height reorg must NOT be skipped + it('detects a same-height sibling block reorg', async () => { + const provider = new MockProvider(); + provider.addBlock(makeBlock(1, '0xb1', '0x0')); + provider.addBlock(makeBlock(2, '0xb2', '0xb1')); + provider.addBlock(makeBlock(3, '0xb3', '0xb2')); + + const detector = makeMockDetector('ethereum', provider, 2); + await detector.start(); + + // Tip is now block 3 (0xb3). Replace it with a sibling at the same height. + // The provider returns block 3 again but with a different hash/parentHash. + provider.addBlock(makeBlock(3, '0xb3_sibling', '0xb2_fork')); + provider.setHead(3); // same height + + const handleReorgSpy = vi + .spyOn( + ReorgDetector.prototype as unknown as { handleReorg: () => Promise }, + 'handleReorg' as never, + ) + .mockResolvedValue(undefined); + + await detector.pollChain('ethereum'); + + // With the old <= comparison this would have returned early. With < it proceeds. + expect(handleReorgSpy).toHaveBeenCalledOnce(); + + handleReorgSpy.mockRestore(); + await detector.stopAsync(); + }); + + it('does not poll when chain head has not advanced', async () => { + const provider = new MockProvider(); + provider.addBlock(makeBlock(5, '0xb5', '0xb4')); + + const detector = makeMockDetector('ethereum', provider, 2); + await detector.start(); + + await detector.pollChain('ethereum'); // no new block + + expect(mockReorgEventCreate).not.toHaveBeenCalled(); + await detector.stopAsync(); + }); + + // Fix #7 verification: second start() must be a no-op + it('start() is idempotent — second call does not create duplicate timers', async () => { + const provider = new MockProvider(); + provider.addBlock(makeBlock(1, '0xb1', '0x0')); + + const detector = makeMockDetector('ethereum', provider, 2); + await detector.start(); + await detector.start(); // second call must be a no-op + + // Only one provider should be registered + expect(detector.getCurrentTip('ethereum')).toBeDefined(); + + await detector.stopAsync(); + }); +}); diff --git a/backend/src/services/chain/confirmation-tracker.ts b/backend/src/services/chain/confirmation-tracker.ts new file mode 100644 index 00000000..53e24a76 --- /dev/null +++ b/backend/src/services/chain/confirmation-tracker.ts @@ -0,0 +1,166 @@ +/** + * confirmation-tracker.ts — Issue #514 + * + * Tracks on-chain confirmation counts per transaction with configurable + * safety thresholds per network. A transaction is considered final only when + * its confirmation depth exceeds the network-specific safety threshold. + * + * Default thresholds (per issue spec): + * Ethereum — 12 blocks + * Polygon — 64 blocks + * Stellar — 1 ledger (BFT finality) + */ + +import { randomUUID } from 'node:crypto'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ConfirmationThresholds { + [network: string]: number; +} + +export interface ConfirmedTx { + id: string; + txHash: string; + network: string; + confirmedAtBlock: number; + firstSeenAt: string; +} + +export interface ConfirmationStatus { + txHash: string; + network: string; + confirmedAtBlock: number; + currentBlock: number; + confirmations: number; + required: number; + isFinalized: boolean; +} + +export interface TrackerOptions { + thresholds?: ConfirmationThresholds; +} + +// ── Defaults ────────────────────────────────────────────────────────────────── + +export const DEFAULT_THRESHOLDS: ConfirmationThresholds = { + ethereum: 12, + polygon: 64, + stellar: 1, + default: 12, +}; + +// ── ConfirmationTracker ─────────────────────────────────────────────────────── + +export class ConfirmationTracker { + private thresholds: ConfirmationThresholds; + private confirmedTxs = new Map(); + private networkHeads = new Map(); + + constructor(opts: TrackerOptions = {}) { + this.thresholds = { ...DEFAULT_THRESHOLDS, ...(opts.thresholds ?? {}) }; + } + + getThreshold(network: string): number { + return this.thresholds[network.toLowerCase()] ?? this.thresholds['default'] ?? 12; + } + + /** Update the canonical head block for a network. */ + setNetworkHead(network: string, blockNumber: number): void { + this.networkHeads.set(network.toLowerCase(), blockNumber); + } + + getNetworkHead(network: string): number { + return this.networkHeads.get(network.toLowerCase()) ?? 0; + } + + /** + * Record that a transaction was included in a specific block. + * Call this when you first see the tx confirmed on-chain. + */ + recordConfirmation(txHash: string, network: string, blockNumber: number): ConfirmedTx { + const key = this.key(txHash, network); + const existing = this.confirmedTxs.get(key); + if (existing) return existing; + + const entry: ConfirmedTx = { + id: randomUUID(), + txHash, + network: network.toLowerCase(), + confirmedAtBlock: blockNumber, + firstSeenAt: new Date().toISOString(), + }; + this.confirmedTxs.set(key, entry); + return entry; + } + + /** Remove a transaction from tracking (e.g. after reorg orphans it). */ + removeConfirmation(txHash: string, network: string): void { + this.confirmedTxs.delete(this.key(txHash, network)); + } + + /** Get the current confirmation status of a transaction. */ + getStatus(txHash: string, network: string): ConfirmationStatus | null { + const entry = this.confirmedTxs.get(this.key(txHash, network)); + if (!entry) return null; + + const currentBlock = this.getNetworkHead(network); + const confirmations = currentBlock >= entry.confirmedAtBlock + ? currentBlock - entry.confirmedAtBlock + 1 + : 0; + const required = this.getThreshold(network); + + return { + txHash, + network: network.toLowerCase(), + confirmedAtBlock: entry.confirmedAtBlock, + currentBlock, + confirmations, + required, + isFinalized: confirmations >= required, + }; + } + + /** Returns true only when confirmation count >= safety threshold. */ + isFinalized(txHash: string, network: string): boolean { + return this.getStatus(txHash, network)?.isFinalized ?? false; + } + + /** List all tracked transactions for a given network. */ + listByNetwork(network: string): ConfirmedTx[] { + const net = network.toLowerCase(); + return Array.from(this.confirmedTxs.values()).filter((t) => t.network === net); + } + + /** Identify transactions in orphaned blocks (block range [fromBlock, toBlock]). */ + findAffected(network: string, fromBlock: number, toBlock: number): ConfirmedTx[] { + const net = network.toLowerCase(); + return Array.from(this.confirmedTxs.values()).filter( + (t) => + t.network === net && + t.confirmedAtBlock >= fromBlock && + t.confirmedAtBlock <= toBlock, + ); + } + + private key(txHash: string, network: string): string { + return `${network.toLowerCase()}:${txHash}`; + } +} + +// ── Singleton ───────────────────────────────────────────────────────────────── + +let _tracker: ConfirmationTracker | undefined; + +export function getConfirmationTracker(): ConfirmationTracker { + if (!_tracker) { + _tracker = new ConfirmationTracker({ + thresholds: { + ethereum: Number(process.env.CONFIRMATION_THRESHOLD_ETHEREUM ?? 12), + polygon: Number(process.env.CONFIRMATION_THRESHOLD_POLYGON ?? 64), + stellar: Number(process.env.CONFIRMATION_THRESHOLD_STELLAR ?? 1), + }, + }); + } + return _tracker; +} diff --git a/backend/src/services/chain/reorg-detector.ts b/backend/src/services/chain/reorg-detector.ts new file mode 100644 index 00000000..15676a86 --- /dev/null +++ b/backend/src/services/chain/reorg-detector.ts @@ -0,0 +1,685 @@ +/** + * reorg-detector.ts — Issue #514 + * + * Detects blockchain reorganizations by comparing each new block's parentHash + * against the locally stored canonical chain tip. When a mismatch is found: + * 1. Walks back the chain to find the common ancestor (computes reorg depth) + * 2. Persists a ReorgEvent record (atomically via prisma.$transaction) + * 3. Identifies payments whose tx was in the orphaned range + * 4. Marks those payments as pending_review in TransactionReorg + * 5. Enqueues a BullMQ re-verification job for each affected transaction + * 6. Emits a critical alert when reorg depth exceeds the safety threshold + * + * EVM chains use ethers.js JsonRpcProvider. + * Stellar uses the Horizon REST API via @stellar/stellar-sdk. + */ + +import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; +import { PrismaClient, type Prisma } from '@prisma/client'; +import { getConfirmationTracker } from './confirmation-tracker.js'; + +const prisma = new PrismaClient(); + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ChainConfig { + network: string; + rpcUrl?: string; + pollIntervalMs?: number; + safetyThreshold?: number; +} + +export interface BlockHeader { + number: number; + hash: string; + parentHash: string; + timestamp: number; +} + +export interface ReorgIncident { + network: string; + reorgDepth: number; + canonicalBlockHash: string; + orphanedBlockHash: string; + fromBlockNumber: number; + toBlockNumber: number; + affectedTxHashes: string[]; +} + +export interface ReorgDetectorOptions { + chains: ChainConfig[]; + alertWebhookUrl?: string; + /** Override for injecting mock providers in tests */ + providerFactory?: (rpcUrl: string) => ChainProvider; +} + +export interface ChainProvider { + getBlockNumber(): Promise; + getBlock(blockNumber: number): Promise; +} + +export interface ReorgJob { + reorgEventId: string; + txHash: string; + paymentId: string | null; + network: string; +} + +type AffectedPayment = { + txHash: string; + paymentId: string | null; + originalBlock: number | null; +}; + +// ── Default safety thresholds per chain ─────────────────────────────────────── + +const SAFETY_THRESHOLDS: Record = { + ethereum: 12, + polygon: 64, + stellar: 1, +}; + +// ── In-memory canonical chain state ─────────────────────────────────────────── + +interface ChainTip { + blockNumber: number; + blockHash: string; + parentHash: string; +} + +// ── Alert helper ────────────────────────────────────────────────────────────── + +async function dispatchAlert(incident: ReorgIncident, webhookUrl?: string): Promise { + if (!webhookUrl) return; + try { + const { default: fetch } = await import('node-fetch'); + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'reorg.detected', + severity: 'critical', + ...incident, + occurredAt: new Date().toISOString(), + }), + }); + } catch { + // Non-fatal + } +} + +// ── EVM provider adapter (wraps ethers.js) ──────────────────────────────────── + +async function createEvmProvider(rpcUrl: string): Promise { + const { JsonRpcProvider } = await import('ethers'); + const provider = new JsonRpcProvider(rpcUrl); + + return { + async getBlockNumber() { + return provider.getBlockNumber(); + }, + async getBlock(blockNumber: number) { + const block = await provider.getBlock(blockNumber); + if (!block) return null; + return { + number: block.number, + hash: block.hash ?? '', + parentHash: block.parentHash, + timestamp: block.timestamp, + }; + }, + }; +} + +// ── Stellar provider adapter (wraps @stellar/stellar-sdk Horizon) ───────────── +// Fix #8: Stellar uses Horizon REST, not EVM JSON-RPC. Ledger sequence → block +// number, prev_hash → parentHash so the generic reorg logic works unchanged. + +// Minimal shape we need from Horizon LedgerRecord — avoids tight SDK version coupling +interface HorizonLedger { + sequence: number; + hash: string; + prev_hash: string; + closed_at: string; +} + +async function createStellarProvider(horizonUrl: string): Promise { + const { Horizon } = await import('@stellar/stellar-sdk'); + const server = new Horizon.Server(horizonUrl); + + return { + async getBlockNumber() { + const page = await server.ledgers().order('desc').limit(1).call(); + const record = page.records[0] as HorizonLedger | undefined; + return record?.sequence ?? 0; + }, + async getBlock(sequence: number) { + try { + const ledger = (await server.ledgers().ledger(sequence).call()) as unknown as HorizonLedger; + return { + number: ledger.sequence, + hash: ledger.hash, + parentHash: ledger.prev_hash, + timestamp: Math.floor(new Date(ledger.closed_at).getTime() / 1000), + }; + } catch { + return null; + } + }, + }; +} + +async function createProvider(cfg: ChainConfig): Promise { + const rpcUrl = cfg.rpcUrl ?? ''; + if (cfg.network === 'stellar') { + return createStellarProvider(rpcUrl); + } + return createEvmProvider(rpcUrl); +} + +// ── ReorgDetector ───────────────────────────────────────────────────────────── + +export class ReorgDetector { + private chains: ChainConfig[]; + private alertWebhookUrl?: string; + private providerFactory?: (rpcUrl: string) => ChainProvider; + private providers = new Map(); + private tips = new Map(); + private timers = new Map>(); + private reorgQueue: Queue | null = null; + private reorgWorker: Worker | null = null; + // Fix #7: idempotency guard — start() is safe to call multiple times + private started = false; + + constructor(opts: ReorgDetectorOptions) { + this.chains = opts.chains; + this.alertWebhookUrl = opts.alertWebhookUrl; + this.providerFactory = opts.providerFactory; + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + // Fix #7: guard against double-start creating zombie timers and duplicate workers + async start(): Promise { + if (this.started) return; + this.started = true; + this.initQueue(); + await Promise.all(this.chains.map((cfg) => this.startChain(cfg))); + } + + // Fix #4 (stop): await worker/queue close so shutdown is clean + async stopAsync(): Promise { + for (const timer of this.timers.values()) { + clearInterval(timer); + } + this.timers.clear(); + if (this.reorgWorker) await this.reorgWorker.close(); + if (this.reorgQueue) await this.reorgQueue.close(); + this.started = false; + } + + stop(): void { + void this.stopAsync(); + } + + private initQueue(): void { + const redisUrl = process.env.REDIS_URL; + if (!redisUrl || process.env.REDIS_ENABLED !== 'true') return; + + const connection = this.parseRedisUrl(redisUrl); + this.reorgQueue = new Queue('agenticpay:reorg-processing', { + connection, + defaultJobOptions: { + attempts: 5, + backoff: { type: 'exponential', delay: 5_000 }, + removeOnComplete: { count: 200 }, + removeOnFail: { count: 500 }, + }, + }); + + this.reorgWorker = new Worker( + 'agenticpay:reorg-processing', + (job: Job) => this.processReorgJob(job.data), + { connection, concurrency: 4 }, + ); + + this.reorgWorker.on('completed', (job) => { + console.log(`[reorg-detector] re-verification completed for tx ${job.data.txHash}`); + }); + this.reorgWorker.on('failed', (job, err) => { + console.error(`[reorg-detector] re-verification failed for tx ${job?.data.txHash}:`, err.message); + }); + } + + private async startChain(cfg: ChainConfig): Promise { + // Fix #8: use the correct provider per chain type + const provider = this.providerFactory + ? this.providerFactory(cfg.rpcUrl ?? '') + : await createProvider(cfg); + + this.providers.set(cfg.network, provider); + + try { + const head = await provider.getBlockNumber(); + const block = await provider.getBlock(head); + if (block) { + this.tips.set(cfg.network, { + blockNumber: block.number, + blockHash: block.hash, + parentHash: block.parentHash, + }); + getConfirmationTracker().setNetworkHead(cfg.network, block.number); + } + } catch (err) { + console.error(`[reorg-detector] Failed to bootstrap chain tip for ${cfg.network}:`, err); + } + + const interval = cfg.pollIntervalMs ?? 15_000; + const timer = setInterval(() => void this.pollChain(cfg.network), interval); + this.timers.set(cfg.network, timer); + + console.log(`[reorg-detector] Monitoring ${cfg.network} every ${interval}ms`); + } + + // ── Block polling ─────────────────────────────────────────────────────────── + + async pollChain(network: string): Promise { + const provider = this.providers.get(network); + if (!provider) return; + + try { + const latestNumber = await provider.getBlockNumber(); + const currentTip = this.tips.get(network); + + // Fix #3: use strict < so a same-height sibling block (latestNumber === + // currentTip.blockNumber but different hash) is NOT skipped. We continue + // and let the parentHash comparison detect the reorg. + if (!currentTip || latestNumber < currentTip.blockNumber) return; + + const latestBlock = await provider.getBlock(latestNumber); + if (!latestBlock) return; + + getConfirmationTracker().setNetworkHead(network, latestNumber); + + // Same height: compare hashes directly. Same hash → idle poll, no change. + // Different hash → same-height sibling block reorg (fix #3 complement). + if (latestNumber === currentTip.blockNumber) { + if (latestBlock.hash === currentTip.blockHash) return; + await this.handleReorg(network, provider, currentTip, latestBlock); + return; + } + + // New block: cleanly extends our known tip when parentHash matches + if (latestBlock.parentHash === currentTip.blockHash) { + this.tips.set(network, { + blockNumber: latestBlock.number, + blockHash: latestBlock.hash, + parentHash: latestBlock.parentHash, + }); + return; + } + + // Parent hash mismatch on a new block — reorg detected + await this.handleReorg(network, provider, currentTip, latestBlock); + + } catch (err) { + console.error(`[reorg-detector] Poll error on ${network}:`, err); + } + } + + // ── Reorg handling ────────────────────────────────────────────────────────── + + private async handleReorg( + network: string, + provider: ChainProvider, + oldTip: ChainTip, + newBlock: BlockHeader, + ): Promise { + const safetyThreshold = + this.chains.find((c) => c.network === network)?.safetyThreshold ?? + SAFETY_THRESHOLDS[network] ?? + 12; + + const commonAncestor = await this.findCommonAncestor( + provider, + oldTip.blockNumber, + newBlock.number, + ); + + const fromBlock = commonAncestor + 1; + const toBlock = oldTip.blockNumber; + const reorgDepth = toBlock - fromBlock + 1; + + console.warn( + `[reorg-detector] REORG detected on ${network}: depth=${reorgDepth}, ` + + `orphaned blocks ${fromBlock}–${toBlock}, new tip ${newBlock.hash}`, + ); + + const tracker = getConfirmationTracker(); + const affectedEntries = tracker.findAffected(network, fromBlock, toBlock); + const affectedTxHashes = affectedEntries.map((e) => e.txHash); + + // Fix #1: capture originalBlock from tracker BEFORE removing entries, + // and pass the map directly so findAffectedPayments never needs to re-query + // the (now-cleared) tracker. + const originalBlocks = new Map( + affectedEntries.map((e) => [e.txHash, e.confirmedAtBlock]), + ); + + for (const entry of affectedEntries) { + tracker.removeConfirmation(entry.txHash, network); + } + + const incident: ReorgIncident = { + network, + reorgDepth, + canonicalBlockHash: newBlock.hash, + orphanedBlockHash: oldTip.blockHash, + fromBlockNumber: fromBlock, + toBlockNumber: toBlock, + affectedTxHashes, + }; + + // Fix #2: resolve affected payments ONCE here and pass the result through + // to both persistReorgEvent and the enqueue loop — no second DB query. + const affectedPayments = await this.resolveAffectedPayments(affectedTxHashes, originalBlocks); + + const reorgEventId = await this.persistReorgEvent(incident, affectedPayments); + + if (reorgDepth > safetyThreshold) { + console.error( + `[reorg-detector] CRITICAL: reorg depth ${reorgDepth} exceeds safety threshold ${safetyThreshold} on ${network}`, + ); + await dispatchAlert(incident, this.alertWebhookUrl ?? process.env.ALERT_WEBHOOK_URL); + } + + this.tips.set(network, { + blockNumber: newBlock.number, + blockHash: newBlock.hash, + parentHash: newBlock.parentHash, + }); + + for (const tx of affectedPayments) { + await this.enqueueReVerification({ reorgEventId, txHash: tx.txHash, paymentId: tx.paymentId, network }); + } + } + + private async findCommonAncestor( + provider: ChainProvider, + oldTipNumber: number, + newTipNumber: number, + ): Promise { + const maxWalk = Math.min(200, Math.max(oldTipNumber, newTipNumber)); + let searchBlock = Math.min(oldTipNumber, newTipNumber) - 1; + + for (let i = 0; i < maxWalk && searchBlock > 0; i++, searchBlock--) { + const block = await provider.getBlock(searchBlock); + if (block) { + return searchBlock; + } + } + return Math.max(0, searchBlock); + } + + // ── Persistence ───────────────────────────────────────────────────────────── + + // Fix #5: wrap all writes in a single prisma.$transaction so a mid-loop + // DB failure cannot leave a ReorgEvent without its TransactionReorg children. + private async persistReorgEvent( + incident: ReorgIncident, + affectedPayments: AffectedPayment[], + ): Promise { + const safetyThreshold = + this.chains.find((c) => c.network === incident.network)?.safetyThreshold ?? + SAFETY_THRESHOLDS[incident.network] ?? + 12; + + const reorgEventId = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const event = await tx.reorgEvent.create({ + data: { + network: incident.network, + reorgDepth: incident.reorgDepth, + safetyThreshold, + canonicalBlockHash: incident.canonicalBlockHash, + orphanedBlockHash: incident.orphanedBlockHash, + fromBlockNumber: incident.fromBlockNumber, + toBlockNumber: incident.toBlockNumber, + metadata: { affectedTxCount: affectedPayments.length }, + }, + }); + + for (const p of affectedPayments) { + await tx.transactionReorg.create({ + data: { + reorgEventId: event.id, + txHash: p.txHash, + paymentId: p.paymentId, + network: incident.network, + originalBlock: p.originalBlock ?? undefined, + reorgDetails: { + orphanedBlockHash: incident.orphanedBlockHash, + reorgDepth: incident.reorgDepth, + fromBlockNumber: incident.fromBlockNumber, + toBlockNumber: incident.toBlockNumber, + }, + }, + }); + + if (p.paymentId) { + await tx.payment.update({ + where: { id: p.paymentId }, + data: { status: 'pending_review' }, + }); + } + } + + return event.id; + }); + + return reorgEventId; + } + + // Fix #1 + #2: single DB query, originalBlock comes from the pre-removal + // tracker snapshot passed in — no tracker lookup after entries are cleared. + private async resolveAffectedPayments( + txHashes: string[], + originalBlocks: Map, + ): Promise { + if (txHashes.length === 0) return []; + + const payments = await prisma.payment.findMany({ + where: { txHash: { in: txHashes } }, + select: { id: true, txHash: true }, + }); + + return txHashes.map((hash) => { + const payment = payments.find( + (p: { id: string; txHash: string | null }) => p.txHash === hash, + ); + return { + txHash: hash, + paymentId: payment?.id ?? null, + originalBlock: originalBlocks.get(hash) ?? null, + }; + }); + } + + // ── BullMQ re-verification ────────────────────────────────────────────────── + + private async enqueueReVerification(job: ReorgJob): Promise { + if (!this.reorgQueue) { + await this.processReorgJob(job); + return; + } + await this.reorgQueue.add('re-verify', job, { + jobId: `reverify:${job.network}:${job.txHash}:${Date.now()}`, + }); + } + + async processReorgJob(job: ReorgJob): Promise { + const { reorgEventId, txHash, paymentId, network } = job; + + try { + let isStillConfirmed = false; + + const rpcUrl = this.chains.find((c) => c.network === network)?.rpcUrl; + if (rpcUrl && network !== 'stellar') { + try { + const { JsonRpcProvider } = await import('ethers'); + const provider = new JsonRpcProvider(rpcUrl); + const receipt = await provider.getTransactionReceipt(txHash); + if (receipt && receipt.blockHash) { + const block = await provider.getBlock(receipt.blockNumber); + isStillConfirmed = block?.hash === receipt.blockHash; + } + } catch { + // RPC failure — leave as pending_review for manual review + } + } else if (this.providerFactory) { + // Test path: injected mock provider + const provider = this.providers.get(network); + if (provider) { + const currentHead = await provider.getBlockNumber(); + isStillConfirmed = currentHead > 0; + } + } + + // Fix #9: use 'rolled_back' (not 're_verified') when the tx is NOT + // confirmed on the canonical chain after the reorg. + const newStatus = isStillConfirmed ? 'confirmed' : 'rolled_back'; + + await prisma.transactionReorg.updateMany({ + where: { reorgEventId, txHash }, + data: { + status: newStatus, + reVerifiedAt: new Date(), + resolvedAt: new Date(), + }, + }); + + if (paymentId) { + await prisma.payment.update({ + where: { id: paymentId }, + data: { status: isStillConfirmed ? 'completed' : 'pending' }, + }); + } + + const remaining = await prisma.transactionReorg.count({ + where: { reorgEventId, status: 'pending_review' }, + }); + if (remaining === 0) { + await prisma.reorgEvent.update({ + where: { id: reorgEventId }, + data: { status: 'resolved', resolvedAt: new Date() }, + }); + } + } catch (err) { + console.error(`[reorg-detector] Re-verification failed for tx ${txHash}:`, err); + throw err; + } + } + + // ── Simulate reorg (for testing / POST /simulate) ──────────────────────────── + + async simulateReorg( + network: string, + orphanedBlockHash: string, + canonicalBlockHash: string, + fromBlock: number, + toBlock: number, + affectedTxHashes: string[] = [], + ): Promise { + const safetyThreshold = + this.chains.find((c) => c.network === network)?.safetyThreshold ?? + SAFETY_THRESHOLDS[network] ?? + 12; + + const reorgDepth = toBlock - fromBlock + 1; + const incident: ReorgIncident = { + network, + reorgDepth, + canonicalBlockHash, + orphanedBlockHash, + fromBlockNumber: fromBlock, + toBlockNumber: toBlock, + affectedTxHashes, + }; + + // originalBlocks are unknown in a simulation — use empty map + const affectedPayments = await this.resolveAffectedPayments( + affectedTxHashes, + new Map(), + ); + + const reorgEventId = await this.persistReorgEvent(incident, affectedPayments); + + if (reorgDepth > safetyThreshold) { + await dispatchAlert(incident, this.alertWebhookUrl ?? process.env.ALERT_WEBHOOK_URL); + } + + for (const tx of affectedPayments) { + await this.enqueueReVerification({ reorgEventId, txHash: tx.txHash, paymentId: tx.paymentId, network }); + } + + return reorgEventId; + } + + // ── Utilities ─────────────────────────────────────────────────────────────── + + getCurrentTip(network: string): ChainTip | undefined { + return this.tips.get(network); + } + + private parseRedisUrl(url: string): ConnectionOptions { + try { + const parsed = new URL(url); + return { + host: parsed.hostname || 'localhost', + port: parseInt(parsed.port || '6379', 10), + password: parsed.password || undefined, + tls: parsed.protocol === 'rediss:' ? {} : undefined, + }; + } catch { + const [host, port] = url.split(':'); + return { host: host || 'localhost', port: parseInt(port || '6379', 10) }; + } + } +} + +// ── Singleton ───────────────────────────────────────────────────────────────── + +let _detector: ReorgDetector | undefined; + +export function getReorgDetector(): ReorgDetector { + if (!_detector) { + const chains: ChainConfig[] = []; + + if (process.env.ETHEREUM_RPC_URL) { + chains.push({ + network: 'ethereum', + rpcUrl: process.env.ETHEREUM_RPC_URL, + pollIntervalMs: 15_000, + safetyThreshold: Number(process.env.CONFIRMATION_THRESHOLD_ETHEREUM ?? 12), + }); + } + if (process.env.POLYGON_RPC_URL) { + chains.push({ + network: 'polygon', + rpcUrl: process.env.POLYGON_RPC_URL, + pollIntervalMs: 10_000, + safetyThreshold: Number(process.env.CONFIRMATION_THRESHOLD_POLYGON ?? 64), + }); + } + if (process.env.STELLAR_RPC_URL) { + chains.push({ + network: 'stellar', + rpcUrl: process.env.STELLAR_RPC_URL, + pollIntervalMs: 6_000, + safetyThreshold: Number(process.env.CONFIRMATION_THRESHOLD_STELLAR ?? 1), + }); + } + + _detector = new ReorgDetector({ chains }); + } + return _detector; +} diff --git a/package-lock.json b/package-lock.json index fadef42a..daf0a78b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^5.22.0", + "@rollup/rollup-linux-x64-gnu": "*", "@sentry/node": "^10.50.0", "@sentry/profiling-node": "^10.50.0", "@stellar/stellar-sdk": "^12.0.0", @@ -77,10 +78,14 @@ "openapi-typescript": "^7.4.4", "pino-pretty": "^13.0.0", "prisma": "^5.22.0", + "ts-morph": "^24.0.0", "tsx": "^4.19.0", "typescript": "^5.6.0", "typescript-eslint": "^8.0.0", "vitest": "^4.1.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.62.2" } }, "backend/node_modules/@types/ws": { @@ -204,6 +209,10 @@ "version": "1.10.1", "license": "MIT" }, + "node_modules/@agenticpay/api-hooks-generator": { + "resolved": "packages/api-hooks-generator", + "link": true + }, "node_modules/@agenticpay/contracts": { "resolved": "packages/contracts", "link": true @@ -212,10 +221,18 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@agenticpay/log-viewer": { + "resolved": "packages/log-viewer", + "link": true + }, "node_modules/@agenticpay/sdk": { "resolved": "packages/sdk", "link": true }, + "node_modules/@agenticpay/sdk-generator": { + "resolved": "packages/sdk-generator", + "link": true + }, "node_modules/@agenticpay/types": { "resolved": "packages/types", "link": true @@ -5633,7 +5650,6 @@ "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -5650,21 +5666,18 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, "node_modules/@redocly/config": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "dev": true, "license": "MIT" }, "node_modules/@redocly/openapi-core": { "version": "1.34.15", "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", - "dev": true, "license": "MIT", "dependencies": { "@redocly/ajv": "8.11.2", @@ -5686,7 +5699,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5696,7 +5708,6 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -6575,6 +6586,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.2", "cpu": [ @@ -6586,6 +6623,292 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "dev": true, @@ -9265,6 +9588,44 @@ "version": "1.0.4", "license": "Unlicense" }, + "node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -13058,7 +13419,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13097,7 +13457,6 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -14000,7 +14359,6 @@ "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "dev": true, "license": "MIT" }, "node_modules/char-spinner": { @@ -14237,6 +14595,13 @@ "node": ">=0.10.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/color": { "version": "4.2.3", "license": "MIT", @@ -14284,7 +14649,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -17384,7 +17748,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -18130,7 +18493,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18146,7 +18508,6 @@ }, "node_modules/js-yaml": { "version": "4.1.1", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -18418,26 +18779,6 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/linkify-it": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", @@ -20363,7 +20704,6 @@ "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", - "dev": true, "license": "MIT", "dependencies": { "@redocly/openapi-core": "^1.34.6", @@ -20388,7 +20728,6 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -20401,7 +20740,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -20572,7 +20910,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -20590,7 +20927,6 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -20632,6 +20968,13 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "license": "MIT", @@ -20892,7 +21235,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -20992,6 +21334,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.5.tgz", + "integrity": "sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -22001,6 +22358,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/rpc-websockets": { "version": "9.3.8", "license": "LGPL-3.0-only", @@ -23324,6 +23694,17 @@ "node": ">=14.0.0" } }, + "node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "dev": true, @@ -24016,7 +24397,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", - "dev": true, "license": "MIT" }, "node_modules/urijs": { @@ -25248,7 +25628,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -25264,7 +25643,6 @@ "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", - "dev": true, "license": "Apache-2.0" }, "node_modules/yargs": { @@ -25407,6 +25785,27 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/api-hooks-generator": { + "name": "@agenticpay/api-hooks-generator", + "version": "0.1.0", + "dependencies": { + "openapi-fetch": "^0.13.5", + "openapi-typescript": "^7.4.4", + "prettier": "^3.0.0", + "yaml": "^2.6.0", + "zod": "^3.23.8" + }, + "bin": { + "generate-api-hooks": "dist/cli.js" + }, + "devDependencies": { + "@agenticpay/eslint-config": "*", + "@agenticpay/typescript-config": "*", + "@types/node": "^22.5.0", + "typescript": "^5.6.0", + "vitest": "^4.1.1" + } + }, "packages/contracts": { "name": "@agenticpay/contracts", "version": "0.1.0", @@ -25423,6 +25822,17 @@ "name": "@agenticpay/eslint-config", "version": "0.1.0" }, + "packages/log-viewer": { + "name": "@agenticpay/log-viewer", + "version": "0.1.0", + "devDependencies": { + "@agenticpay/eslint-config": "*", + "@agenticpay/typescript-config": "*", + "@types/node": "^22.5.0", + "typescript": "^5.6.0", + "vitest": "^4.1.1" + } + }, "packages/sdk": { "name": "@agenticpay/sdk", "version": "0.1.0", @@ -25435,6 +25845,32 @@ "vitest": "^4.1.1" } }, + "packages/sdk-generator": { + "name": "@agenticpay/sdk-generator", + "version": "0.1.0", + "license": "MIT", + "bin": { + "agenticpay-sdk-gen": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "typescript": "~5.7.2" + } + }, + "packages/sdk-generator/node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "packages/sdk/node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",