From 5087c0b97985e30f4ad789fa4883c8458ce5a921 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Fri, 16 Oct 2026 18:26:03 +0100 Subject: [PATCH] fix: validate payment routes and retry timed out payouts --- .../routing/__tests__/route-engine.test.ts | 38 +++++++++++++++- backend/payments/routing/api.ts | 11 ++++- backend/payments/routing/route-engine.ts | 43 +++++++++++++++++-- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/backend/payments/routing/__tests__/route-engine.test.ts b/backend/payments/routing/__tests__/route-engine.test.ts index e342487d..b42ac2ad 100644 --- a/backend/payments/routing/__tests__/route-engine.test.ts +++ b/backend/payments/routing/__tests__/route-engine.test.ts @@ -1,4 +1,4 @@ -import { calculateOptimalRoute, executeWithFallback } from '../route-engine'; +import { calculateOptimalRoute, executeWithFallback, validateRoutingRequest } from '../route-engine'; import { NetworkMetrics, UserPreferences, NetworkId } from '../types'; const mockMetrics = (id: NetworkId, gasPrice: number, blockTime: number): NetworkMetrics => ({ @@ -13,6 +13,17 @@ const mockMetrics = (id: NetworkId, gasPrice: number, blockTime: number): Networ }); describe('route-engine', () => { + it('rejects invalid amounts and self-transfers before routing', () => { + expect(validateRoutingRequest( + { amountUsd: 0, fromAddress: '0x1', toAddress: '0x2' }, + { priority: 'cost' } + )).toContain('positive'); + expect(validateRoutingRequest( + { amountUsd: 10, fromAddress: '0x1', toAddress: '0x1' }, + { priority: 'cost' } + )).toContain('different'); + }); + it('selects cheapest network with cost priority', async () => { const metrics = [ mockMetrics('ethereum', 50, 12), @@ -134,4 +145,27 @@ describe('route-engine', () => { ) ).rejects.toThrow('All routing attempts failed'); }); -}); \ No newline at end of file + + it('falls back when a network execution times out', async () => { + const executeFn = jest.fn() + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce('0xfallback'); + + const result = await executeWithFallback( + { + primary: 'ethereum', + fallbacks: ['arbitrum'], + bridgeRequired: false, + scores: {} as Record, + recommended: 'ethereum', + userOverridden: false, + }, + executeFn, + undefined, + 1 + ); + + expect(result.networkUsed).toBe('arbitrum'); + expect(result.attempts).toBe(2); + }); +}); diff --git a/backend/payments/routing/api.ts b/backend/payments/routing/api.ts index 30ac1baa..7ea08933 100644 --- a/backend/payments/routing/api.ts +++ b/backend/payments/routing/api.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { calculateOptimalRoute, executeWithFallback } from './route-engine'; +import { calculateOptimalRoute, executeWithFallback, validateRoutingRequest } from './route-engine'; import { getAllNetworkMetrics, getAllNetworkStatuses } from './network-monitor'; import { notifyRoutingDecision, notifyFallback } from './notification-service'; import { recordRoutingEvent, generateRoutingReport } from './analytics'; @@ -30,6 +30,13 @@ router.post('/route', async (req, res) => { } const userPrefs: UserPreferences = preferences || { priority: 'cost' }; + const validationError = validateRoutingRequest( + { amountUsd, tokenAddress, fromAddress, toAddress }, + userPrefs + ); + if (validationError) { + return res.status(400).json({ error: validationError }); + } // Fetch real-time metrics const metrics = await getAllNetworkMetrics(); @@ -177,4 +184,4 @@ router.get('/analytics', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/backend/payments/routing/route-engine.ts b/backend/payments/routing/route-engine.ts index 2c84be3d..a20f737d 100644 --- a/backend/payments/routing/route-engine.ts +++ b/backend/payments/routing/route-engine.ts @@ -3,13 +3,36 @@ import { scoreAllNetworks } from './scoring-engine'; import { getBridgeContract, getAllNetworkIds } from './network-registry'; import { getNetworkStatus } from './network-monitor'; -interface RoutingContext { +export interface RoutingContext { amountUsd: number; tokenAddress?: string; fromAddress: string; toAddress: string; } +export function validateRoutingRequest( + context: RoutingContext, + preferences: UserPreferences +): string | null { + if (!Number.isFinite(context.amountUsd) || context.amountUsd <= 0) { + return 'amountUsd must be a finite positive number'; + } + if (!context.fromAddress || !context.toAddress) { + return 'fromAddress and toAddress are required'; + } + if (context.fromAddress === context.toAddress) { + return 'fromAddress and toAddress must be different'; + } + if (!['cost', 'speed', 'reliability'].includes(preferences.priority)) { + return 'priority must be cost, speed, or reliability'; + } + if (preferences.maxFeeUsd !== undefined && + (!Number.isFinite(preferences.maxFeeUsd) || preferences.maxFeeUsd < 0)) { + return 'maxFeeUsd must be a finite non-negative number'; + } + return null; +} + function determineBridgeRequirement( primary: NetworkId, target: NetworkId, @@ -66,6 +89,9 @@ export async function calculateOptimalRoute( preferences: UserPreferences, metrics: NetworkMetrics[] ): Promise { + const validationError = validateRoutingRequest(context, preferences); + if (validationError) throw new Error(validationError); + let scores = scoreAllNetworks(metrics, preferences); // Apply user preferences @@ -128,7 +154,8 @@ async function checkLiquidity(networkId: NetworkId, context: RoutingContext): Pr export async function executeWithFallback( route: PaymentRoute, executeFn: (networkId: NetworkId) => Promise, - onFallback?: (from: NetworkId, to: NetworkId, error: Error) => void + onFallback?: (from: NetworkId, to: NetworkId, error: Error) => void, + timeoutMs = 30_000 ): Promise<{ txHash: string; networkUsed: NetworkId; attempts: number }> { const networksToTry = [route.primary, ...route.fallbacks]; let lastError: Error | undefined; @@ -136,7 +163,15 @@ export async function executeWithFallback( for (let i = 0; i < networksToTry.length; i++) { const networkId = networksToTry[i]; try { - const txHash = await executeFn(networkId); + const txHash = await Promise.race([ + executeFn(networkId), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Execution timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + if (!txHash || typeof txHash !== 'string') { + throw new Error('Execution returned an empty transaction hash'); + } return { txHash, networkUsed: networkId, attempts: i + 1 }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -149,4 +184,4 @@ export async function executeWithFallback( throw new Error( `All routing attempts failed. Tried: ${networksToTry.join(', ')}. Last error: ${lastError?.message}` ); -} \ No newline at end of file +}