Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions backend/payments/routing/__tests__/route-engine.test.ts
Original file line number Diff line number Diff line change
@@ -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 => ({
Expand All @@ -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),
Expand Down Expand Up @@ -134,4 +145,27 @@ describe('route-engine', () => {
)
).rejects.toThrow('All routing attempts failed');
});
});

it('falls back when a network execution times out', async () => {
const executeFn = jest.fn()
.mockImplementationOnce(() => new Promise<string>(() => undefined))
.mockResolvedValueOnce('0xfallback');

const result = await executeWithFallback(
{
primary: 'ethereum',
fallbacks: ['arbitrum'],
bridgeRequired: false,
scores: {} as Record<NetworkId, any>,
recommended: 'ethereum',
userOverridden: false,
},
executeFn,
undefined,
1
);

expect(result.networkUsed).toBe('arbitrum');
expect(result.attempts).toBe(2);
});
});
11 changes: 9 additions & 2 deletions backend/payments/routing/api.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -177,4 +184,4 @@ router.get('/analytics', async (req, res) => {
}
});

export default router;
export default router;
43 changes: 39 additions & 4 deletions backend/payments/routing/route-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,6 +89,9 @@ export async function calculateOptimalRoute(
preferences: UserPreferences,
metrics: NetworkMetrics[]
): Promise<PaymentRoute> {
const validationError = validateRoutingRequest(context, preferences);
if (validationError) throw new Error(validationError);

let scores = scoreAllNetworks(metrics, preferences);

// Apply user preferences
Expand Down Expand Up @@ -128,15 +154,24 @@ async function checkLiquidity(networkId: NetworkId, context: RoutingContext): Pr
export async function executeWithFallback(
route: PaymentRoute,
executeFn: (networkId: NetworkId) => Promise<string>,
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;

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<string>((_, 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));
Expand All @@ -149,4 +184,4 @@ export async function executeWithFallback(
throw new Error(
`All routing attempts failed. Tried: ${networksToTry.join(', ')}. Last error: ${lastError?.message}`
);
}
}
Loading