diff --git a/src/bridge-compare/BridgeCompare.test.tsx b/src/bridge-compare/BridgeCompare.test.tsx new file mode 100644 index 0000000..ab0054d --- /dev/null +++ b/src/bridge-compare/BridgeCompare.test.tsx @@ -0,0 +1,436 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BridgeCompare, NormalizedQuote, QuoteResponse } from './BridgeCompare'; + +// ─── Mock fetch ──────────────────────────────────────────────────────────────── + +const mockQuote = (override: Partial = {}): NormalizedQuote => ({ + bridgeId: 'stargate', + bridgeName: 'Stargate Finance', + sourceChain: 'stellar', + destinationChain: 'ethereum', + sourceToken: 'USDC', + destinationToken: 'USDC', + inputAmount: 100, + outputAmount: 98.5, + totalFeeUsd: 1.20, + estimatedTimeSeconds: 45, + slippagePercent: 0.05, + reliabilityScore: 96, + compositeScore: 87.4, + rankingPosition: 1, + bridgeStatus: 'active', + metadata: {}, + fetchedAt: new Date().toISOString(), + ...override, +}); + +const mockResponse = (overrides: Partial = {}): QuoteResponse => ({ + quotes: [ + mockQuote({ bridgeId: 'stargate', bridgeName: 'Stargate Finance', rankingPosition: 1, compositeScore: 87 }), + mockQuote({ bridgeId: 'squid', bridgeName: 'Squid Router', rankingPosition: 2, compositeScore: 82, totalFeeUsd: 1.60 }), + mockQuote({ bridgeId: 'hop', bridgeName: 'Hop Protocol', rankingPosition: 3, compositeScore: 78, totalFeeUsd: 2.10 }), + ], + bestRoute: mockQuote({ bridgeId: 'stargate', rankingPosition: 1 }), + rankingMode: 'balanced', + successfulProviders: 3, + totalProviders: 4, + fetchDurationMs: 143, + ...overrides, +}); + +function mockFetchSuccess(response: QuoteResponse = mockResponse()) { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => response, + }); +} + +function mockFetchError(message = 'No routes found') { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ message }), + }); +} + +function mockFetchNetworkFailure() { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); +} + +const defaultProps = { + sourceChain: 'stellar', + destinationChain: 'ethereum', + sourceToken: 'USDC', + amount: 100, +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('BridgeCompare', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ─── Rendering ────────────────────────────────────────────────────────────── + + describe('initial render', () => { + it('renders root element', () => { + mockFetchSuccess(); + render(); + expect(screen.getByTestId('bridge-compare-root')).toBeInTheDocument(); + }); + + it('shows loading state while fetching', async () => { + global.fetch = jest.fn().mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(screen.getByTestId('loading-state')).toBeInTheDocument(); + }); + + it('shows bridge route header with chain and token info', async () => { + mockFetchSuccess(); + render(); + expect(screen.getByText(/stellar/i)).toBeInTheDocument(); + expect(screen.getByText(/ethereum/i)).toBeInTheDocument(); + }); + + it('shows ranking mode tabs', () => { + mockFetchSuccess(); + render(); + expect(screen.getByTestId('ranking-tab-balanced')).toBeInTheDocument(); + expect(screen.getByTestId('ranking-tab-lowest-cost')).toBeInTheDocument(); + expect(screen.getByTestId('ranking-tab-fastest')).toBeInTheDocument(); + }); + }); + + // ─── Successful quote display ──────────────────────────────────────────────── + + describe('quote display', () => { + it('renders all quotes after fetch', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => { + expect(screen.getByTestId('quotes-list')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('quote-card-stargate')).toBeInTheDocument(); + expect(screen.getByTestId('quote-card-squid')).toBeInTheDocument(); + expect(screen.getByTestId('quote-card-hop')).toBeInTheDocument(); + }); + + it('displays fee, speed, slippage, reliability for each quote', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + expect(screen.getAllByText(/Total Fee/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Speed/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Slippage/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Reliability/i).length).toBeGreaterThan(0); + }); + + it('displays reliability badge for each quote', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + expect(screen.getByTestId('reliability-badge-stargate')).toBeInTheDocument(); + }); + + it('shows ★ Best Route badge on best route', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + expect(screen.getByText(/Best Route/i)).toBeInTheDocument(); + }); + + it('shows sort indicator label', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + expect(screen.getByText(/Sorted by/i)).toBeInTheDocument(); + }); + + it('shows provider count in summary', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + expect(screen.getByText(/3\/4 providers/i)).toBeInTheDocument(); + }); + }); + + // ─── Route selection ───────────────────────────────────────────────────────── + + describe('route selection', () => { + it('calls onRouteSelect with correct route when clicking select button', async () => { + const onRouteSelect = jest.fn(); + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + const selectBtn = screen.getAllByRole('button', { name: /Select Route/i })[0]; + fireEvent.click(selectBtn); + + expect(onRouteSelect).toHaveBeenCalledTimes(1); + expect(onRouteSelect).toHaveBeenCalledWith( + expect.objectContaining({ bridgeId: 'stargate' }), + ); + }); + + it('calls onRouteSelect with full NormalizedQuote object', async () => { + const onRouteSelect = jest.fn(); + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + fireEvent.click(screen.getAllByRole('button', { name: /Select Route/i })[0]); + + const received: NormalizedQuote = onRouteSelect.mock.calls[0][0]; + expect(received.bridgeId).toBeDefined(); + expect(received.totalFeeUsd).toBeDefined(); + expect(received.estimatedTimeSeconds).toBeDefined(); + expect(received.slippagePercent).toBeDefined(); + expect(received.reliabilityScore).toBeDefined(); + expect(received.compositeScore).toBeDefined(); + }); + + it('shows ✓ Selected on selected card button', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + fireEvent.click(screen.getAllByRole('button', { name: /Select Route/i })[0]); + + expect(screen.getByText('✓ Selected')).toBeInTheDocument(); + }); + + it('supports keyboard Enter to select a route', async () => { + const onRouteSelect = jest.fn(); + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + const card = screen.getByTestId('quote-card-stargate'); + fireEvent.keyDown(card, { key: 'Enter' }); + + expect(onRouteSelect).toHaveBeenCalledTimes(1); + }); + + it('does not call onRouteSelect when onRouteSelect is not provided', async () => { + mockFetchSuccess(); + // Should not throw + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + expect(() => fireEvent.click(screen.getAllByRole('button', { name: /Select Route/i })[0])).not.toThrow(); + }); + }); + + // ─── Ranking mode ──────────────────────────────────────────────────────────── + + describe('ranking mode', () => { + it('defaults to balanced mode', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + const tab = screen.getByTestId('ranking-tab-balanced'); + expect(tab.style.background).not.toBe('transparent'); + }); + + it('re-fetches quotes when ranking mode tab changes', async () => { + mockFetchSuccess(); + render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + const fetchCallsBefore = (global.fetch as jest.Mock).mock.calls.length; + fireEvent.click(screen.getByTestId('ranking-tab-fastest')); + + await waitFor(() => { + expect((global.fetch as jest.Mock).mock.calls.length).toBeGreaterThan(fetchCallsBefore); + }); + + const lastCall = (global.fetch as jest.Mock).mock.calls.at(-1)?.[0] as string; + expect(lastCall).toContain('rankingMode=fastest'); + }); + + it('updates when external rankingMode prop changes', async () => { + mockFetchSuccess(); + const { rerender } = render(); + + await waitFor(() => screen.getByTestId('quotes-list')); + + mockFetchSuccess(mockResponse({ rankingMode: 'lowest-cost' })); + rerender(); + + await waitFor(() => { + const lastUrl = (global.fetch as jest.Mock).mock.calls.at(-1)?.[0] as string; + expect(lastUrl).toContain('rankingMode=lowest-cost'); + }); + }); + }); + + // ─── Prop change re-renders ─────────────────────────────────────────────────── + + describe('prop changes', () => { + it('re-fetches on amount change', async () => { + mockFetchSuccess(); + const { rerender } = render(); + await waitFor(() => screen.getByTestId('quotes-list')); + + const countBefore = (global.fetch as jest.Mock).mock.calls.length; + rerender(); + + await waitFor(() => { + expect((global.fetch as jest.Mock).mock.calls.length).toBeGreaterThan(countBefore); + }); + + const lastUrl = (global.fetch as jest.Mock).mock.calls.at(-1)?.[0] as string; + expect(lastUrl).toContain('amount=500'); + }); + + it('re-fetches on sourceChain change', async () => { + mockFetchSuccess(); + const { rerender } = render(); + await waitFor(() => screen.getByTestId('quotes-list')); + + const countBefore = (global.fetch as jest.Mock).mock.calls.length; + rerender(); + + await waitFor(() => { + expect((global.fetch as jest.Mock).mock.calls.length).toBeGreaterThan(countBefore); + }); + }); + }); + + // ─── Error states ───────────────────────────────────────────────────────────── + + describe('error handling', () => { + it('shows error state on failed fetch', async () => { + mockFetchError('No routes found for this token pair'); + render(); + + await waitFor(() => screen.getByTestId('error-state')); + expect(screen.getByText(/No routes found/i)).toBeInTheDocument(); + }); + + it('shows retry button in error state', async () => { + mockFetchError(); + render(); + + await waitFor(() => screen.getByTestId('retry-button')); + expect(screen.getByTestId('retry-button')).toBeInTheDocument(); + }); + + it('retries fetch when retry button clicked', async () => { + mockFetchError(); + render(); + + await waitFor(() => screen.getByTestId('retry-button')); + + mockFetchSuccess(); + fireEvent.click(screen.getByTestId('retry-button')); + + await waitFor(() => screen.getByTestId('quotes-list')); + expect(screen.getByTestId('quotes-list')).toBeInTheDocument(); + }); + + it('calls onError callback with Error object', async () => { + const onError = jest.fn(); + mockFetchNetworkFailure(); + render(); + + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + it('renders custom error component when provided', async () => { + mockFetchError('Bridge API failure'); + render( + ( +
+ Custom: {err.message} + +
+ )} + />, + ); + + await waitFor(() => screen.getByTestId('custom-error')); + expect(screen.getByText(/Custom:/i)).toBeInTheDocument(); + }); + }); + + // ─── Unsupported pair ───────────────────────────────────────────────────────── + + describe('unsupported token pair', () => { + it('shows error for unsupported pair', async () => { + mockFetchError('No bridge providers support this route'); + render( + , + ); + + await waitFor(() => screen.getByTestId('error-state')); + expect(screen.getByText(/No bridge providers/i)).toBeInTheDocument(); + }); + }); + + // ─── Custom loading component ───────────────────────────────────────────────── + + describe('custom components', () => { + it('renders custom loading component', () => { + global.fetch = jest.fn().mockReturnValue(new Promise(() => {})); + render( + Loading bridges…} + />, + ); + expect(screen.getByTestId('custom-loader')).toBeInTheDocument(); + }); + }); + + // ─── onQuotesLoaded callback ────────────────────────────────────────────────── + + describe('onQuotesLoaded callback', () => { + it('calls onQuotesLoaded with full QuoteResponse', async () => { + const onQuotesLoaded = jest.fn(); + const response = mockResponse(); + mockFetchSuccess(response); + + render(); + + await waitFor(() => { + expect(onQuotesLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: expect.any(Array), + bestRoute: expect.any(Object), + rankingMode: 'balanced', + }), + ); + }); + }); + }); +}); diff --git a/src/bridge-compare/BridgeCompare.tsx b/src/bridge-compare/BridgeCompare.tsx new file mode 100644 index 0000000..6de7ba7 --- /dev/null +++ b/src/bridge-compare/BridgeCompare.tsx @@ -0,0 +1,449 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; + +// ─── Types ───────────────────────────────────────────────────────────────────── + +export type RankingMode = 'balanced' | 'lowest-cost' | 'fastest'; + +export type BridgeStatus = 'active' | 'degraded' | 'offline'; + +export interface NormalizedQuote { + bridgeId: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + sourceToken: string; + destinationToken: string; + inputAmount: number; + outputAmount: number; + totalFeeUsd: number; + estimatedTimeSeconds: number; + slippagePercent: number; + reliabilityScore: number; + compositeScore: number; + rankingPosition: number; + bridgeStatus: BridgeStatus; + metadata: Record; + fetchedAt: string; +} + +export interface QuoteResponse { + quotes: NormalizedQuote[]; + bestRoute: NormalizedQuote; + rankingMode: RankingMode; + successfulProviders: number; + totalProviders: number; + fetchDurationMs: number; +} + +export interface BridgeCompareProps { + /** Source blockchain identifier */ + sourceChain: string; + /** Destination blockchain identifier */ + destinationChain: string; + /** Token to bridge */ + sourceToken: string; + /** Amount to bridge (in token units) */ + amount: number; + /** Route ranking strategy */ + rankingMode?: RankingMode; + /** API base URL for the BridgeWise backend */ + apiBaseUrl?: string; + /** Custom theme overrides */ + theme?: BridgeCompareTheme; + /** Called when the user selects a route */ + onRouteSelect?: (route: NormalizedQuote) => void; + /** Called when quotes are successfully loaded */ + onQuotesLoaded?: (response: QuoteResponse) => void; + /** Called on fetch error */ + onError?: (error: Error) => void; + /** Custom loading component */ + loadingComponent?: React.ReactNode; + /** Custom error component */ + errorComponent?: (error: Error, retry: () => void) => React.ReactNode; +} + +export interface BridgeCompareTheme { + primaryColor?: string; + backgroundColor?: string; + cardBackground?: string; + textColor?: string; + borderColor?: string; + selectedBorderColor?: string; +} + +// ─── Constants ───────────────────────────────────────────────────────────────── + +const DEFAULT_THEME: Required = { + primaryColor: '#6366f1', + backgroundColor: '#f8fafc', + cardBackground: '#ffffff', + textColor: '#1e293b', + borderColor: '#e2e8f0', + selectedBorderColor: '#6366f1', +}; + +const RANKING_LABELS: Record = { + balanced: 'Balanced', + 'lowest-cost': 'Lowest Cost', + fastest: 'Fastest', +}; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function formatTime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +} + +function formatFee(fee: number): string { + return `$${fee.toFixed(2)}`; +} + +function formatSlippage(pct: number): string { + return `${pct.toFixed(3)}%`; +} + +function reliabilityBadge(score: number): { label: string; color: string } { + if (score >= 90) return { label: 'Excellent', color: '#22c55e' }; + if (score >= 75) return { label: 'Good', color: '#84cc16' }; + if (score >= 60) return { label: 'Fair', color: '#f59e0b' }; + return { label: 'Poor', color: '#ef4444' }; +} + +// ─── Sub-components ──────────────────────────────────────────────────────────── + +interface QuoteCardProps { + quote: NormalizedQuote; + isSelected: boolean; + isBest: boolean; + theme: Required; + onSelect: (quote: NormalizedQuote) => void; +} + +const QuoteCard: React.FC = ({ quote, isSelected, isBest, theme, onSelect }) => { + const badge = reliabilityBadge(quote.reliabilityScore); + const isOffline = quote.bridgeStatus === 'offline'; + + const cardStyle: React.CSSProperties = { + background: theme.cardBackground, + border: `2px solid ${isSelected ? theme.selectedBorderColor : theme.borderColor}`, + borderRadius: 12, + padding: '16px 20px', + marginBottom: 12, + cursor: isOffline ? 'not-allowed' : 'pointer', + opacity: isOffline ? 0.5 : 1, + position: 'relative', + transition: 'border-color 0.15s ease, box-shadow 0.15s ease', + boxShadow: isSelected ? `0 0 0 3px ${theme.primaryColor}22` : 'none', + }; + + return ( +
!isOffline && onSelect(quote)} + onKeyDown={(e) => e.key === 'Enter' && !isOffline && onSelect(quote)} + > + {/* Best badge */} + {isBest && ( + + ★ Best Route + + )} + + {/* Header row */} +
+
+ + #{quote.rankingPosition} {quote.bridgeName} + + + {badge.label} + +
+ + Score: {quote.compositeScore.toFixed(1)} + +
+ + {/* Metrics grid */} +
+ + + + +
+ + {/* Output amount + CTA */} +
+ + You receive:{' '} + + {quote.outputAmount.toFixed(4)} {quote.destinationToken} + + + +
+
+ ); +}; + +const Metric: React.FC<{ label: string; value: string; theme: Required }> = ({ + label, value, theme, +}) => ( +
+
{label}
+
{value}
+
+); + +// ─── Main Component ──────────────────────────────────────────────────────────── + +/** + * BridgeCompare — Embeddable multi-chain bridge aggregator component. + * + * @example + * console.log(route)} + * /> + */ +export const BridgeCompare: React.FC = ({ + sourceChain, + destinationChain, + sourceToken, + amount, + rankingMode = 'balanced', + apiBaseUrl = 'http://localhost:3000', + theme: userTheme, + onRouteSelect, + onQuotesLoaded, + onError, + loadingComponent, + errorComponent, +}) => { + const theme: Required = { ...DEFAULT_THEME, ...userTheme }; + + const [quoteResponse, setQuoteResponse] = useState(null); + const [selectedRoute, setSelectedRoute] = useState(null); + const [activeMode, setActiveMode] = useState(rankingMode); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const abortRef = useRef(null); + + const fetchQuotes = useCallback(async (mode: RankingMode) => { + abortRef.current?.abort(); + abortRef.current = new AbortController(); + + setIsLoading(true); + setError(null); + setSelectedRoute(null); + + try { + const params = new URLSearchParams({ + sourceChain, + destinationChain, + sourceToken, + amount: String(amount), + rankingMode: mode, + }); + + const res = await fetch(`${apiBaseUrl}/bridge-compare/quotes?${params}`, { + signal: abortRef.current.signal, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message ?? `Request failed: ${res.status}`); + } + + const data: QuoteResponse = await res.json(); + setQuoteResponse(data); + onQuotesLoaded?.(data); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return; + const e = err instanceof Error ? err : new Error(String(err)); + setError(e); + onError?.(e); + } finally { + setIsLoading(false); + } + }, [sourceChain, destinationChain, sourceToken, amount, apiBaseUrl, onQuotesLoaded, onError]); + + // Re-fetch when key props change + useEffect(() => { + fetchQuotes(activeMode); + return () => abortRef.current?.abort(); + }, [fetchQuotes, activeMode]); + + // Sync external rankingMode prop changes + useEffect(() => { + setActiveMode(rankingMode); + }, [rankingMode]); + + const handleRouteSelect = (route: NormalizedQuote) => { + setSelectedRoute(route); + onRouteSelect?.(route); + }; + + const handleRetry = () => fetchQuotes(activeMode); + + // ─── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} +
+
+

+ Bridge Compare +

+

+ {amount} {sourceToken} · {sourceChain} → {destinationChain} +

+
+ + {/* Ranking mode tabs */} +
+ {(Object.keys(RANKING_LABELS) as RankingMode[]).map((mode) => ( + + ))} +
+
+ + {/* Loading state */} + {isLoading && ( +
+ {loadingComponent ?? ( +
+
+

Fetching bridge quotes…

+
+ )} +
+ )} + + {/* Error state */} + {!isLoading && error && ( +
+ {errorComponent ? errorComponent(error, handleRetry) : ( +
+
⚠️
+

{error.message}

+ +
+ )} +
+ )} + + {/* Quotes list */} + {!isLoading && !error && quoteResponse && ( +
+ {/* Summary row */} +
+ {quoteResponse.successfulProviders}/{quoteResponse.totalProviders} providers responded + {quoteResponse.fetchDurationMs}ms +
+ + {/* Sort indicator */} +
+ Sorted by:{' '} + {RANKING_LABELS[quoteResponse.rankingMode]} +
+ + {quoteResponse.quotes.map((quote) => ( + + ))} +
+ )} +
+ ); +}; + +export default BridgeCompare; diff --git a/src/bridge-compare/aggregation.service.spec.ts b/src/bridge-compare/aggregation.service.spec.ts new file mode 100644 index 0000000..0c11182 --- /dev/null +++ b/src/bridge-compare/aggregation.service.spec.ts @@ -0,0 +1,146 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AggregationService } from '../src/bridge-compare/aggregation.service'; +import { QuoteRequestParams } from '../src/bridge-compare/interfaces'; +import { BridgeStatus, RankingMode } from '../src/bridge-compare/enums'; +import { HttpException } from '@nestjs/common'; + +const baseParams: QuoteRequestParams = { + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + destinationToken: 'USDC', + amount: 100, + rankingMode: RankingMode.BALANCED, +}; + +describe('AggregationService', () => { + let service: AggregationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AggregationService], + }).compile(); + + service = module.get(AggregationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('fetchRawQuotes', () => { + it('returns quotes for a supported route', async () => { + const { quotes } = await service.fetchRawQuotes(baseParams); + expect(quotes.length).toBeGreaterThan(0); + for (const q of quotes) { + expect(q.bridgeId).toBeDefined(); + expect(q.outputAmount).toBeGreaterThan(0); + expect(q.feesUsd).toBeGreaterThan(0); + } + }); + + it('throws NOT_FOUND for unsupported token pair', async () => { + await expect( + service.fetchRawQuotes({ + ...baseParams, + sourceToken: 'EXTREMELY_UNKNOWN_TOKEN_XYZ', + }), + ).rejects.toThrow(HttpException); + }); + + it('throws NOT_FOUND for unsupported chain pair', async () => { + await expect( + service.fetchRawQuotes({ + ...baseParams, + sourceChain: 'nonexistent-chain', + destinationChain: 'another-fake-chain', + }), + ).rejects.toThrow(HttpException); + }); + + it('returns output amount less than input (fees deducted)', async () => { + const { quotes } = await service.fetchRawQuotes(baseParams); + for (const q of quotes) { + expect(q.outputAmount).toBeLessThan(baseParams.amount); + } + }); + + it('includes failedProviders count in response', async () => { + const result = await service.fetchRawQuotes(baseParams); + expect(result.failedProviders).toBeGreaterThanOrEqual(0); + expect(typeof result.failedProviders).toBe('number'); + }); + + it('scales fees with larger amounts', async () => { + const small = await service.fetchRawQuotes({ ...baseParams, amount: 10 }); + const large = await service.fetchRawQuotes({ ...baseParams, amount: 100_000 }); + const avgFeeSmall = small.quotes.reduce((a, q) => a + q.feesUsd, 0) / small.quotes.length; + const avgFeeLarge = large.quotes.reduce((a, q) => a + q.feesUsd, 0) / large.quotes.length; + expect(avgFeeLarge).toBeGreaterThan(avgFeeSmall); + }); + + it('stellar-ethereum route returns soroswap', async () => { + const { quotes } = await service.fetchRawQuotes({ + ...baseParams, + sourceChain: 'stellar', + destinationChain: 'ethereum', + sourceToken: 'XLM', + destinationToken: 'XLM', + }); + const ids = quotes.map((q) => q.bridgeId); + expect(ids).toContain('soroswap'); + }); + }); + + describe('getEligibleProviders', () => { + it('filters out providers that do not support source chain', () => { + const providers = service.getEligibleProviders({ + ...baseParams, + sourceChain: 'stellar', + destinationChain: 'ethereum', + sourceToken: 'USDC', + }); + for (const p of providers) { + expect(p.supportedChains).toContain('stellar'); + } + }); + + it('returns empty array when no providers match', () => { + const providers = service.getEligibleProviders({ + ...baseParams, + sourceChain: 'fake-chain', + }); + expect(providers).toHaveLength(0); + }); + }); + + describe('getBridgeStatus', () => { + it('returns ACTIVE for known active bridge', () => { + const status = service.getBridgeStatus('stargate'); + expect(status).toBe(BridgeStatus.ACTIVE); + }); + + it('returns OFFLINE for unknown bridge', () => { + const status = service.getBridgeStatus('nonexistent'); + expect(status).toBe(BridgeStatus.OFFLINE); + }); + }); + + describe('getAllProviders', () => { + it('returns an array of providers', () => { + const providers = service.getAllProviders(); + expect(Array.isArray(providers)).toBe(true); + expect(providers.length).toBeGreaterThan(0); + }); + + it('each provider has required fields', () => { + const providers = service.getAllProviders(); + for (const p of providers) { + expect(p.id).toBeDefined(); + expect(p.name).toBeDefined(); + expect(Array.isArray(p.supportedChains)).toBe(true); + expect(Array.isArray(p.supportedTokens)).toBe(true); + } + }); + }); +}); diff --git a/src/bridge-compare/aggregation.service.ts b/src/bridge-compare/aggregation.service.ts new file mode 100644 index 0000000..57ba789 --- /dev/null +++ b/src/bridge-compare/aggregation.service.ts @@ -0,0 +1,182 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { RawBridgeQuote, BridgeProvider, QuoteRequestParams } from '../interfaces'; +import { BridgeStatus } from '../enums'; + +interface MockQuoteTemplate { + feesUsd: number; + gasCostUsd: number; + estimatedTimeSeconds: number; + outputRatio: number; // how much of input the user gets +} + +@Injectable() +export class AggregationService { + private readonly logger = new Logger(AggregationService.name); + + private readonly MOCK_PROVIDERS: BridgeProvider[] = [ + { + id: 'stargate', + name: 'Stargate Finance', + apiBaseUrl: 'https://api.stargate.finance', + supportedChains: ['ethereum', 'polygon', 'arbitrum', 'optimism', 'binance', 'avalanche'], + supportedTokens: ['USDC', 'USDT', 'ETH', 'WBTC'], + isActive: true, + }, + { + id: 'squid', + name: 'Squid Router', + apiBaseUrl: 'https://api.0xsquid.com', + supportedChains: ['ethereum', 'polygon', 'arbitrum', 'avalanche', 'stellar'], + supportedTokens: ['USDC', 'USDT', 'ETH', 'XLM'], + isActive: true, + }, + { + id: 'hop', + name: 'Hop Protocol', + apiBaseUrl: 'https://api.hop.exchange', + supportedChains: ['ethereum', 'polygon', 'arbitrum', 'optimism'], + supportedTokens: ['USDC', 'USDT', 'ETH', 'MATIC'], + isActive: true, + }, + { + id: 'cbridge', + name: 'cBridge', + apiBaseUrl: 'https://cbridge-prod2.celer.app', + supportedChains: ['ethereum', 'polygon', 'arbitrum', 'binance', 'avalanche'], + supportedTokens: ['USDC', 'USDT', 'ETH', 'BNB'], + isActive: true, + }, + { + id: 'soroswap', + name: 'Soroswap Bridge', + apiBaseUrl: 'https://api.soroswap.finance', + supportedChains: ['stellar', 'ethereum'], + supportedTokens: ['USDC', 'XLM', 'yXLM'], + isActive: true, + }, + ]; + + private readonly MOCK_QUOTE_TEMPLATES: Record = { + stargate: { feesUsd: 0.80, gasCostUsd: 1.20, estimatedTimeSeconds: 45, outputRatio: 0.989 }, + squid: { feesUsd: 1.10, gasCostUsd: 0.90, estimatedTimeSeconds: 30, outputRatio: 0.992 }, + hop: { feesUsd: 0.60, gasCostUsd: 1.50, estimatedTimeSeconds: 120, outputRatio: 0.985 }, + cbridge: { feesUsd: 0.70, gasCostUsd: 1.30, estimatedTimeSeconds: 90, outputRatio: 0.987 }, + soroswap: { feesUsd: 0.30, gasCostUsd: 0.20, estimatedTimeSeconds: 15, outputRatio: 0.997 }, + }; + + /** + * Fetch raw quotes from all providers supporting the given route. + * Returns an object with successful quotes and the count of failed providers. + */ + async fetchRawQuotes(params: QuoteRequestParams): Promise<{ + quotes: RawBridgeQuote[]; + failedProviders: number; + }> { + const eligibleProviders = this.getEligibleProviders(params); + + if (!eligibleProviders.length) { + throw new HttpException( + `No bridge providers support the route ${params.sourceToken} from ${params.sourceChain} → ${params.destinationChain}`, + HttpStatus.NOT_FOUND, + ); + } + + this.logger.log( + `Fetching quotes from ${eligibleProviders.length} providers for ` + + `${params.sourceToken} ${params.sourceChain}→${params.destinationChain} amount=${params.amount}`, + ); + + const results = await Promise.allSettled( + eligibleProviders.map((p) => this.fetchSingleProviderQuote(p, params)), + ); + + const quotes: RawBridgeQuote[] = []; + let failedProviders = 0; + + for (const result of results) { + if (result.status === 'fulfilled') { + quotes.push(result.value); + } else { + failedProviders++; + this.logger.warn(`Provider quote fetch failed: ${result.reason}`); + } + } + + if (!quotes.length) { + throw new HttpException( + 'All bridge providers failed to respond. Please try again later.', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + return { quotes, failedProviders }; + } + + /** + * Get active providers that support the requested route. + */ + getEligibleProviders(params: QuoteRequestParams): BridgeProvider[] { + return this.MOCK_PROVIDERS.filter((provider) => { + if (!provider.isActive) return false; + const supportsSourceChain = provider.supportedChains.includes(params.sourceChain); + const supportsDestChain = provider.supportedChains.includes(params.destinationChain); + const supportsToken = provider.supportedTokens.some( + (t) => t.toUpperCase() === params.sourceToken.toUpperCase(), + ); + return supportsSourceChain && supportsDestChain && supportsToken; + }); + } + + /** + * Fetch a quote from a single provider (simulated; real impl uses HttpService). + */ + private async fetchSingleProviderQuote( + provider: BridgeProvider, + params: QuoteRequestParams, + ): Promise { + // Simulate occasional provider failures (5% chance) + if (Math.random() < 0.05) { + throw new Error(`Provider ${provider.name} returned timeout`); + } + + const template = this.MOCK_QUOTE_TEMPLATES[provider.id] ?? { + feesUsd: 1.00, + gasCostUsd: 1.00, + estimatedTimeSeconds: 60, + outputRatio: 0.990, + }; + + // Scale fees relative to amount + const scaledFees = template.feesUsd * (1 + Math.log10(Math.max(1, params.amount / 100))); + const scaledGas = template.gasCostUsd; + const outputAmount = params.amount * template.outputRatio; + + return { + bridgeId: provider.id, + bridgeName: provider.name, + outputAmount, + feesUsd: parseFloat(scaledFees.toFixed(4)), + gasCostUsd: parseFloat(scaledGas.toFixed(4)), + estimatedTimeSeconds: template.estimatedTimeSeconds, + steps: [ + { + protocol: provider.name, + type: 'bridge', + inputAmount: params.amount, + outputAmount, + feeUsd: scaledFees, + }, + ], + }; + } + + getAllProviders(): BridgeProvider[] { + return this.MOCK_PROVIDERS; + } + + getBridgeStatus(bridgeId: string): BridgeStatus { + const provider = this.MOCK_PROVIDERS.find((p) => p.id === bridgeId); + if (!provider) return BridgeStatus.OFFLINE; + return provider.isActive ? BridgeStatus.ACTIVE : BridgeStatus.OFFLINE; + } +} diff --git a/src/bridge-compare/bridge-compare.controller.e2e.spec.ts b/src/bridge-compare/bridge-compare.controller.e2e.spec.ts new file mode 100644 index 0000000..1589311 --- /dev/null +++ b/src/bridge-compare/bridge-compare.controller.e2e.spec.ts @@ -0,0 +1,196 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { BridgeCompareModule } from '../src/bridge-compare/bridge-compare.module'; + +describe('BridgeCompareController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [BridgeCompareModule], + }).compile(); + + app = module.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }), + ); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + // ─── GET /bridge-compare/providers ─────────────────────────────────────────── + + describe('GET /bridge-compare/providers', () => { + it('returns 200 with array of providers', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/providers') + .expect(200) + .expect((res) => { + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + expect(res.body[0].id).toBeDefined(); + expect(res.body[0].supportedChains).toBeDefined(); + }); + }); + }); + + // ─── GET /bridge-compare/quotes ────────────────────────────────────────────── + + describe('GET /bridge-compare/quotes', () => { + it('returns 200 with ranked quotes for valid params', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + rankingMode: 'balanced', + }) + .expect(200) + .expect((res) => { + expect(res.body.quotes).toBeDefined(); + expect(Array.isArray(res.body.quotes)).toBe(true); + expect(res.body.bestRoute).toBeDefined(); + expect(res.body.rankingMode).toBe('balanced'); + }); + }); + + it('returns 400 for missing required params', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ sourceChain: 'ethereum' }) // missing required fields + .expect(400); + }); + + it('returns 400 for invalid ranking mode', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + rankingMode: 'ultra-fast', // invalid enum + }) + .expect(400); + }); + + it('returns 400 for negative amount', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: -50, + }) + .expect(400); + }); + + it('quotes are ranked by position', async () => { + const res = await request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + }) + .expect(200); + + const quotes: any[] = res.body.quotes; + for (let i = 0; i < quotes.length - 1; i++) { + expect(quotes[i].rankingPosition).toBeLessThan(quotes[i + 1].rankingPosition); + } + }); + + it('supports lowest-cost ranking mode', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 500, + rankingMode: 'lowest-cost', + }) + .expect(200) + .expect((res) => { + expect(res.body.rankingMode).toBe('lowest-cost'); + }); + }); + + it('supports fastest ranking mode', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + rankingMode: 'fastest', + }) + .expect(200); + }); + + it('returns 404 for unsupported token pair', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'TOTALLY_FAKE_TOKEN_XYZ', + amount: 100, + }) + .expect(404); + }); + }); + + // ─── GET /bridge-compare/quotes/:bridgeId ──────────────────────────────────── + + describe('GET /bridge-compare/quotes/:bridgeId', () => { + it('returns 200 with specific route', async () => { + // First get all quotes to pick a real bridgeId + const allRes = await request(app.getHttpServer()) + .get('/bridge-compare/quotes') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + }); + + const bridgeId = allRes.body.quotes[0].bridgeId; + + return request(app.getHttpServer()) + .get(`/bridge-compare/quotes/${bridgeId}`) + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + }) + .expect(200) + .expect((res) => { + expect(res.body.bridgeId).toBe(bridgeId); + }); + }); + + it('returns 404 for unknown bridgeId', () => { + return request(app.getHttpServer()) + .get('/bridge-compare/quotes/nonexistent-bridge-xyz') + .query({ + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + }) + .expect(404); + }); + }); +}); diff --git a/src/bridge-compare/bridge-compare.controller.ts b/src/bridge-compare/bridge-compare.controller.ts new file mode 100644 index 0000000..148aaa1 --- /dev/null +++ b/src/bridge-compare/bridge-compare.controller.ts @@ -0,0 +1,72 @@ +import { + Controller, + Get, + Query, + Param, + HttpCode, + HttpStatus, + UsePipes, + ValidationPipe, + UseFilters, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiServiceUnavailableResponse, +} from '@nestjs/swagger'; +import { BridgeCompareService } from './bridge-compare.service'; +import { GetQuotesDto } from './dto'; +import { QuoteResponse, NormalizedQuote } from './interfaces'; + +@ApiTags('bridge-compare') +@Controller('bridge-compare') +@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) +export class BridgeCompareController { + constructor(private readonly bridgeCompareService: BridgeCompareService) {} + + @Get('quotes') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Fetch ranked bridge quotes', + description: + 'Returns normalized, ranked quotes from all supported bridge providers for the requested route.', + }) + @ApiResponse({ status: 200, description: 'Ranked quotes returned successfully' }) + @ApiBadRequestResponse({ description: 'Invalid request parameters' }) + @ApiNotFoundResponse({ description: 'No routes found for the token pair' }) + @ApiServiceUnavailableResponse({ description: 'All bridge providers unavailable' }) + async getQuotes(@Query() dto: GetQuotesDto): Promise { + return this.bridgeCompareService.getQuotes(dto); + } + + @Get('quotes/:bridgeId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get specific bridge route details', + description: 'Returns the full normalized quote for a specific bridge provider.', + }) + @ApiParam({ name: 'bridgeId', description: 'Bridge provider identifier', example: 'stargate' }) + @ApiResponse({ status: 200, description: 'Route details returned' }) + @ApiNotFoundResponse({ description: 'Route not found' }) + async getRouteDetails( + @Param('bridgeId') bridgeId: string, + @Query() dto: GetQuotesDto, + ): Promise { + return this.bridgeCompareService.getRouteDetails(dto, bridgeId); + } + + @Get('providers') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'List all supported bridge providers', + description: 'Returns all configured bridge providers with their supported chains and tokens.', + }) + @ApiResponse({ status: 200, description: 'Providers listed successfully' }) + getSupportedBridges() { + return this.bridgeCompareService.getSupportedBridges(); + } +} diff --git a/src/bridge-compare/bridge-compare.module.ts b/src/bridge-compare/bridge-compare.module.ts new file mode 100644 index 0000000..c45f9b2 --- /dev/null +++ b/src/bridge-compare/bridge-compare.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { BridgeCompareController } from './bridge-compare.controller'; +import { BridgeCompareService } from './bridge-compare.service'; +import { AggregationService } from './aggregation.service'; +import { SlippageService } from './slippage.service'; +import { ReliabilityService } from './reliability.service'; +import { RankingService } from './ranking.service'; + +@Module({ + controllers: [BridgeCompareController], + providers: [ + BridgeCompareService, + AggregationService, + SlippageService, + ReliabilityService, + RankingService, + ], + exports: [BridgeCompareService], +}) +export class BridgeCompareModule {} diff --git a/src/bridge-compare/bridge-compare.service.spec.ts b/src/bridge-compare/bridge-compare.service.spec.ts new file mode 100644 index 0000000..41714e2 --- /dev/null +++ b/src/bridge-compare/bridge-compare.service.spec.ts @@ -0,0 +1,176 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { BridgeCompareService } from '../src/bridge-compare/bridge-compare.service'; +import { AggregationService } from '../src/bridge-compare/aggregation.service'; +import { SlippageService } from '../src/bridge-compare/slippage.service'; +import { ReliabilityService } from '../src/bridge-compare/reliability.service'; +import { RankingService } from '../src/bridge-compare/ranking.service'; +import { GetQuotesDto } from '../src/bridge-compare/dto'; +import { RankingMode } from '../src/bridge-compare/enums'; +import { NormalizedQuote, QuoteResponse } from '../src/bridge-compare/interfaces'; + +const baseDto: GetQuotesDto = { + sourceChain: 'ethereum', + destinationChain: 'polygon', + sourceToken: 'USDC', + amount: 100, + rankingMode: RankingMode.BALANCED, +}; + +describe('BridgeCompareService (integration)', () => { + let service: BridgeCompareService; + let aggregationService: AggregationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BridgeCompareService, + AggregationService, + SlippageService, + ReliabilityService, + RankingService, + ], + }).compile(); + + service = module.get(BridgeCompareService); + aggregationService = module.get(AggregationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ─── getQuotes ─────────────────────────────────────────────────────────────── + + describe('getQuotes', () => { + it('returns a valid QuoteResponse', async () => { + const response: QuoteResponse = await service.getQuotes(baseDto); + + expect(response.quotes.length).toBeGreaterThan(0); + expect(response.bestRoute).toBeDefined(); + expect(response.rankingMode).toBe(RankingMode.BALANCED); + expect(response.fetchDurationMs).toBeGreaterThanOrEqual(0); + expect(response.successfulProviders).toBeGreaterThan(0); + }); + + it('bestRoute is the first-ranked quote', async () => { + const response = await service.getQuotes(baseDto); + expect(response.bestRoute.rankingPosition).toBe(1); + expect(response.bestRoute.bridgeId).toBe(response.quotes[0].bridgeId); + }); + + it('all quotes have compositeScore and rankingPosition assigned', async () => { + const response = await service.getQuotes(baseDto); + for (const q of response.quotes) { + expect(q.compositeScore).toBeGreaterThan(0); + expect(q.rankingPosition).toBeGreaterThan(0); + } + }); + + it('all quotes have slippage and reliability populated', async () => { + const response = await service.getQuotes(baseDto); + for (const q of response.quotes) { + expect(q.slippagePercent).toBeGreaterThanOrEqual(0); + expect(q.reliabilityScore).toBeGreaterThan(0); + } + }); + + it('quotes are sorted by rankingPosition ascending', async () => { + const response = await service.getQuotes(baseDto); + for (let i = 0; i < response.quotes.length - 1; i++) { + expect(response.quotes[i].rankingPosition).toBeLessThan(response.quotes[i + 1].rankingPosition); + } + }); + + it('LOWEST_COST mode yields cheapest route as best', async () => { + const response = await service.getQuotes({ ...baseDto, rankingMode: RankingMode.LOWEST_COST }); + const fees = response.quotes.map((q) => q.totalFeeUsd); + // Best route should have one of the lowest fees + expect(response.bestRoute.totalFeeUsd).toBeLessThanOrEqual(Math.max(...fees)); + }); + + it('FASTEST mode yields fastest route as best', async () => { + const response = await service.getQuotes({ ...baseDto, rankingMode: RankingMode.FASTEST }); + const times = response.quotes.map((q) => q.estimatedTimeSeconds); + expect(response.bestRoute.estimatedTimeSeconds).toBeLessThanOrEqual(Math.max(...times)); + }); + + it('re-runs correctly on amount change', async () => { + const r1 = await service.getQuotes({ ...baseDto, amount: 50 }); + const r2 = await service.getQuotes({ ...baseDto, amount: 5000 }); + // Larger amounts should have higher fees + expect(r2.bestRoute.totalFeeUsd).toBeGreaterThan(r1.bestRoute.totalFeeUsd); + }); + + it('re-runs correctly on rankingMode change', async () => { + const balanced = await service.getQuotes({ ...baseDto, rankingMode: RankingMode.BALANCED }); + const fastest = await service.getQuotes({ ...baseDto, rankingMode: RankingMode.FASTEST }); + // Best routes may differ between modes + expect(balanced.rankingMode).toBe(RankingMode.BALANCED); + expect(fastest.rankingMode).toBe(RankingMode.FASTEST); + }); + + it('throws on unsupported token pair', async () => { + await expect( + service.getQuotes({ ...baseDto, sourceToken: 'COMPLETELY_FAKE_TOKEN' }), + ).rejects.toThrow(); + }); + }); + + // ─── getRouteDetails ───────────────────────────────────────────────────────── + + describe('getRouteDetails', () => { + it('returns the route for a known bridgeId', async () => { + const allQuotes = await service.getQuotes(baseDto); + const targetId = allQuotes.quotes[0].bridgeId; + + const route: NormalizedQuote = await service.getRouteDetails(baseDto, targetId); + expect(route.bridgeId).toBe(targetId); + }); + + it('throws NotFoundException for unknown bridgeId', async () => { + await expect( + service.getRouteDetails(baseDto, 'completely-nonexistent-bridge'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── getSupportedBridges ───────────────────────────────────────────────────── + + describe('getSupportedBridges', () => { + it('returns list of providers', () => { + const providers = service.getSupportedBridges(); + expect(Array.isArray(providers)).toBe(true); + expect(providers.length).toBeGreaterThan(0); + }); + }); + + // ─── Simulated bridge failure ───────────────────────────────────────────────── + + describe('bridge API failure simulation', () => { + it('still returns quotes if some providers fail', async () => { + // Spy to force 2 failures + let callCount = 0; + const originalFetch = aggregationService['fetchSingleProviderQuote'].bind(aggregationService); + jest + .spyOn(aggregationService as any, 'fetchSingleProviderQuote') + .mockImplementation(async (...args) => { + callCount++; + if (callCount <= 2) throw new Error('Simulated provider failure'); + return originalFetch(...args); + }); + + // Should still resolve with the remaining providers + const response = await service.getQuotes(baseDto); + expect(response.quotes.length).toBeGreaterThan(0); + }); + + it('throws SERVICE_UNAVAILABLE when ALL providers fail', async () => { + jest + .spyOn(aggregationService as any, 'fetchSingleProviderQuote') + .mockRejectedValue(new Error('All providers down')); + + await expect(service.getQuotes(baseDto)).rejects.toThrow(); + }); + }); +}); diff --git a/src/bridge-compare/bridge-compare.service.ts b/src/bridge-compare/bridge-compare.service.ts new file mode 100644 index 0000000..58f04fc --- /dev/null +++ b/src/bridge-compare/bridge-compare.service.ts @@ -0,0 +1,141 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { AggregationService } from './aggregation.service'; +import { SlippageService } from './slippage.service'; +import { ReliabilityService } from './reliability.service'; +import { RankingService } from './ranking.service'; +import { GetQuotesDto } from './dto'; +import { NormalizedQuote, QuoteResponse, QuoteRequestParams, RawBridgeQuote } from './interfaces'; +import { BridgeStatus, RankingMode } from './enums'; + +@Injectable() +export class BridgeCompareService { + private readonly logger = new Logger(BridgeCompareService.name); + + constructor( + private readonly aggregationService: AggregationService, + private readonly slippageService: SlippageService, + private readonly reliabilityService: ReliabilityService, + private readonly rankingService: RankingService, + ) {} + + /** + * Get all normalized, ranked quotes for a bridge request. + */ + async getQuotes(dto: GetQuotesDto): Promise { + const startTime = Date.now(); + const params: QuoteRequestParams = { + sourceChain: dto.sourceChain, + destinationChain: dto.destinationChain, + sourceToken: dto.sourceToken, + destinationToken: dto.destinationToken ?? dto.sourceToken, + amount: dto.amount, + rankingMode: dto.rankingMode ?? RankingMode.BALANCED, + slippageTolerance: dto.slippageTolerance, + }; + + this.logger.log( + `Getting quotes: ${dto.sourceToken} ${dto.sourceChain}→${dto.destinationChain} ` + + `amount=${dto.amount} mode=${params.rankingMode}`, + ); + + const { quotes: rawQuotes, failedProviders } = await this.aggregationService.fetchRawQuotes(params); + + const slippageMap = this.slippageService.batchEstimateSlippage( + rawQuotes, + dto.sourceToken, + dto.sourceChain, + dto.amount, + ); + + const bridgeIds = rawQuotes.map((q) => q.bridgeId); + const reliabilityMap = this.reliabilityService.batchCalculateScores(bridgeIds); + + const normalizedQuotes: NormalizedQuote[] = rawQuotes.map((raw) => + this.normalizeQuote(raw, params, slippageMap, reliabilityMap), + ); + + const rankedQuotes = this.rankingService.rankQuotes(normalizedQuotes, params.rankingMode); + + const bestRoute = rankedQuotes[0]; + if (!bestRoute) { + throw new NotFoundException('No valid routes found for the requested pair'); + } + + const response: QuoteResponse = { + quotes: rankedQuotes, + bestRoute, + rankingMode: params.rankingMode, + requestParams: params, + totalProviders: rawQuotes.length + failedProviders, + successfulProviders: rawQuotes.length, + fetchDurationMs: Date.now() - startTime, + }; + + this.logger.log( + `Returned ${rankedQuotes.length} quotes in ${response.fetchDurationMs}ms. ` + + `Best: ${bestRoute.bridgeName} score=${bestRoute.compositeScore}`, + ); + + return response; + } + + /** + * Get a specific route's full details by bridgeId. + */ + async getRouteDetails(dto: GetQuotesDto, bridgeId: string): Promise { + const response = await this.getQuotes(dto); + const route = response.quotes.find((q) => q.bridgeId === bridgeId); + + if (!route) { + throw new NotFoundException(`Route not found for bridge: ${bridgeId}`); + } + + return route; + } + + /** + * Get list of all supported bridges. + */ + getSupportedBridges() { + return this.aggregationService.getAllProviders(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private normalizeQuote( + raw: RawBridgeQuote, + params: QuoteRequestParams, + slippageMap: Map, + reliabilityMap: Map, + ): NormalizedQuote { + const totalFeeUsd = raw.feesUsd + raw.gasCostUsd; + const slippage = slippageMap.get(raw.bridgeId); + const reliabilityScore = reliabilityMap.get(raw.bridgeId) ?? 70; + const bridgeStatus = this.aggregationService.getBridgeStatus(raw.bridgeId); + + return { + bridgeId: raw.bridgeId, + bridgeName: raw.bridgeName, + sourceChain: params.sourceChain, + destinationChain: params.destinationChain, + sourceToken: params.sourceToken, + destinationToken: params.destinationToken ?? params.sourceToken, + inputAmount: params.amount, + outputAmount: parseFloat(raw.outputAmount.toFixed(6)), + totalFeeUsd: parseFloat(totalFeeUsd.toFixed(4)), + estimatedTimeSeconds: raw.estimatedTimeSeconds, + slippagePercent: slippage?.expectedSlippage ?? 0, + reliabilityScore, + compositeScore: 0, // assigned by RankingService + rankingPosition: 0, // assigned by RankingService + bridgeStatus, + metadata: { + feesBreakdown: { protocolFee: raw.feesUsd, gasFee: raw.gasCostUsd }, + steps: raw.steps, + }, + fetchedAt: new Date(), + }; + } +} diff --git a/src/bridge-compare/index.ts b/src/bridge-compare/index.ts new file mode 100644 index 0000000..0f67d99 --- /dev/null +++ b/src/bridge-compare/index.ts @@ -0,0 +1,89 @@ +import { BridgeStatus, RankingMode } from '../enums'; + +export interface NormalizedQuote { + bridgeId: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + sourceToken: string; + destinationToken: string; + inputAmount: number; + outputAmount: number; + totalFeeUsd: number; + estimatedTimeSeconds: number; + slippagePercent: number; + reliabilityScore: number; // 0-100 + compositeScore: number; // 0-100 (lower is better for cost, higher for balanced) + rankingPosition: number; + bridgeStatus: BridgeStatus; + metadata: Record; + fetchedAt: Date; +} + +export interface RawBridgeQuote { + bridgeId: string; + bridgeName: string; + outputAmount: number; + feesUsd: number; + gasCostUsd: number; + estimatedTimeSeconds: number; + steps: BridgeStep[]; +} + +export interface BridgeStep { + protocol: string; + type: 'swap' | 'bridge' | 'wrap'; + inputAmount: number; + outputAmount: number; + feeUsd: number; +} + +export interface SlippageEstimate { + expectedSlippage: number; + maxSlippage: number; + confidence: 'high' | 'medium' | 'low'; +} + +export interface ReliabilityMetrics { + uptime24h: number; + successRate7d: number; + avgDelayPercent: number; + incidentCount30d: number; + reliabilityScore: number; +} + +export interface RankingWeights { + cost: number; + speed: number; + reliability: number; + slippage: number; +} + +export interface QuoteRequestParams { + sourceChain: string; + destinationChain: string; + sourceToken: string; + destinationToken?: string; + amount: number; + rankingMode: RankingMode; + slippageTolerance?: number; +} + +export interface QuoteResponse { + quotes: NormalizedQuote[]; + bestRoute: NormalizedQuote; + rankingMode: RankingMode; + requestParams: QuoteRequestParams; + totalProviders: number; + successfulProviders: number; + fetchDurationMs: number; +} + +export interface BridgeProvider { + id: string; + name: string; + apiBaseUrl: string; + supportedChains: string[]; + supportedTokens: string[]; + isActive: boolean; +} diff --git a/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/dto/index.ts b/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/dto/index.ts new file mode 100644 index 0000000..a6415c6 --- /dev/null +++ b/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/dto/index.ts @@ -0,0 +1,83 @@ +import { + IsString, + IsNumber, + IsOptional, + IsEnum, + Min, + Max, + IsPositive, + IsNotEmpty, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { RankingMode, SupportedChain } from '../enums'; + +export class GetQuotesDto { + @ApiProperty({ description: 'Source blockchain', enum: SupportedChain, example: 'stellar' }) + @IsString() + @IsNotEmpty() + sourceChain: string; + + @ApiProperty({ description: 'Destination blockchain', enum: SupportedChain, example: 'ethereum' }) + @IsString() + @IsNotEmpty() + destinationChain: string; + + @ApiProperty({ description: 'Source token symbol', example: 'USDC' }) + @IsString() + @IsNotEmpty() + sourceToken: string; + + @ApiPropertyOptional({ description: 'Destination token symbol (defaults to sourceToken)', example: 'USDC' }) + @IsOptional() + @IsString() + destinationToken?: string; + + @ApiProperty({ description: 'Amount to bridge', example: 100 }) + @Type(() => Number) + @IsNumber() + @IsPositive() + @Min(0.000001) + amount: number; + + @ApiPropertyOptional({ description: 'Ranking mode for route comparison', enum: RankingMode, default: RankingMode.BALANCED }) + @IsOptional() + @IsEnum(RankingMode) + rankingMode?: RankingMode = RankingMode.BALANCED; + + @ApiPropertyOptional({ description: 'Max acceptable slippage %', example: 0.5 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + slippageTolerance?: number; +} + +export class RouteSelectDto { + @ApiProperty({ description: 'Bridge provider ID' }) + @IsString() + @IsNotEmpty() + bridgeId: string; + + @ApiProperty({ description: 'Source chain' }) + @IsString() + @IsNotEmpty() + sourceChain: string; + + @ApiProperty({ description: 'Destination chain' }) + @IsString() + @IsNotEmpty() + destinationChain: string; + + @ApiProperty({ description: 'Source token' }) + @IsString() + @IsNotEmpty() + sourceToken: string; + + @ApiProperty({ description: 'Input amount' }) + @Type(() => Number) + @IsNumber() + @IsPositive() + amount: number; +} diff --git a/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/enums/index.ts b/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/enums/index.ts new file mode 100644 index 0000000..1ff8150 --- /dev/null +++ b/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/enums/index.ts @@ -0,0 +1,21 @@ +export enum RankingMode { + BALANCED = 'balanced', + LOWEST_COST = 'lowest-cost', + FASTEST = 'fastest', +} + +export enum BridgeStatus { + ACTIVE = 'active', + DEGRADED = 'degraded', + OFFLINE = 'offline', +} + +export enum SupportedChain { + STELLAR = 'stellar', + ETHEREUM = 'ethereum', + POLYGON = 'polygon', + BINANCE = 'binance', + AVALANCHE = 'avalanche', + ARBITRUM = 'arbitrum', + OPTIMISM = 'optimism', +} diff --git a/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/index.ts b/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/index.ts new file mode 100644 index 0000000..195393a --- /dev/null +++ b/src/bridge-compare/mnt/user-data/outputs/bridge-compare/src/bridge-compare/index.ts @@ -0,0 +1,10 @@ +export * from './bridge-compare.module'; +export * from './bridge-compare.service'; +export * from './bridge-compare.controller'; +export * from './aggregation.service'; +export * from './slippage.service'; +export * from './reliability.service'; +export * from './ranking.service'; +export * from './interfaces'; +export * from './enums'; +export * from './dto'; diff --git a/src/bridge-compare/ranking.service.spec.ts b/src/bridge-compare/ranking.service.spec.ts new file mode 100644 index 0000000..5170726 --- /dev/null +++ b/src/bridge-compare/ranking.service.spec.ts @@ -0,0 +1,147 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RankingService } from '../src/bridge-compare/ranking.service'; +import { NormalizedQuote } from '../src/bridge-compare/interfaces'; +import { BridgeStatus, RankingMode } from '../src/bridge-compare/enums'; + +const makeQuote = (override: Partial): NormalizedQuote => ({ + bridgeId: 'test', + bridgeName: 'Test Bridge', + sourceChain: 'stellar', + destinationChain: 'ethereum', + sourceToken: 'USDC', + destinationToken: 'USDC', + inputAmount: 100, + outputAmount: 99, + totalFeeUsd: 1.0, + estimatedTimeSeconds: 60, + slippagePercent: 0.1, + reliabilityScore: 90, + compositeScore: 0, + rankingPosition: 0, + bridgeStatus: BridgeStatus.ACTIVE, + metadata: {}, + fetchedAt: new Date(), + ...override, +}); + +describe('RankingService', () => { + let service: RankingService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RankingService], + }).compile(); + + service = module.get(RankingService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('rankQuotes', () => { + it('returns empty array for empty input', () => { + expect(service.rankQuotes([], RankingMode.BALANCED)).toEqual([]); + }); + + it('assigns ranking positions starting at 1', () => { + const quotes = [ + makeQuote({ bridgeId: 'a', totalFeeUsd: 2 }), + makeQuote({ bridgeId: 'b', totalFeeUsd: 1 }), + makeQuote({ bridgeId: 'c', totalFeeUsd: 3 }), + ]; + const ranked = service.rankQuotes(quotes, RankingMode.BALANCED); + expect(ranked.map((q) => q.rankingPosition)).toEqual([1, 2, 3]); + }); + + it('assigns composite scores to all quotes', () => { + const quotes = [makeQuote({ bridgeId: 'a' }), makeQuote({ bridgeId: 'b' })]; + const ranked = service.rankQuotes(quotes, RankingMode.BALANCED); + for (const q of ranked) { + expect(q.compositeScore).toBeGreaterThan(0); + } + }); + + it('LOWEST_COST mode ranks cheaper route first', () => { + const quotes = [ + makeQuote({ bridgeId: 'cheap', totalFeeUsd: 0.5, estimatedTimeSeconds: 300 }), + makeQuote({ bridgeId: 'expensive', totalFeeUsd: 5.0, estimatedTimeSeconds: 30 }), + ]; + const ranked = service.rankQuotes(quotes, RankingMode.LOWEST_COST); + expect(ranked[0].bridgeId).toBe('cheap'); + }); + + it('FASTEST mode ranks faster route first', () => { + const quotes = [ + makeQuote({ bridgeId: 'fast', estimatedTimeSeconds: 15, totalFeeUsd: 5 }), + makeQuote({ bridgeId: 'slow', estimatedTimeSeconds: 600, totalFeeUsd: 0.5 }), + ]; + const ranked = service.rankQuotes(quotes, RankingMode.FASTEST); + expect(ranked[0].bridgeId).toBe('fast'); + }); + + it('BALANCED mode considers all factors', () => { + const quotes = [ + makeQuote({ bridgeId: 'balanced', totalFeeUsd: 1.0, estimatedTimeSeconds: 60, reliabilityScore: 95, slippagePercent: 0.1 }), + makeQuote({ bridgeId: 'risky', totalFeeUsd: 0.5, estimatedTimeSeconds: 30, reliabilityScore: 50, slippagePercent: 2.0 }), + ]; + const ranked = service.rankQuotes(quotes, RankingMode.BALANCED); + // balanced bridge should win due to reliability + expect(ranked[0].bridgeId).toBe('balanced'); + }); + + it('sorted descending by compositeScore', () => { + const quotes = Array.from({ length: 5 }, (_, i) => + makeQuote({ bridgeId: `bridge-${i}`, totalFeeUsd: i * 0.5, reliabilityScore: 90 - i * 5 }), + ); + const ranked = service.rankQuotes(quotes, RankingMode.BALANCED); + for (let i = 0; i < ranked.length - 1; i++) { + expect(ranked[i].compositeScore).toBeGreaterThanOrEqual(ranked[i + 1].compositeScore); + } + }); + + it('returns single quote with position 1', () => { + const quotes = [makeQuote({ bridgeId: 'only' })]; + const ranked = service.rankQuotes(quotes, RankingMode.BALANCED); + expect(ranked[0].rankingPosition).toBe(1); + expect(ranked[0].compositeScore).toBeGreaterThanOrEqual(0); + }); + }); + + describe('getBestQuote', () => { + it('returns the first-ranked quote', () => { + const quotes = [ + makeQuote({ bridgeId: 'best', reliabilityScore: 100, totalFeeUsd: 0.1, estimatedTimeSeconds: 10 }), + makeQuote({ bridgeId: 'worst', reliabilityScore: 40, totalFeeUsd: 5, estimatedTimeSeconds: 600 }), + ]; + const best = service.getBestQuote(quotes, RankingMode.BALANCED); + expect(best?.bridgeId).toBe('best'); + }); + + it('returns null for empty array', () => { + expect(service.getBestQuote([], RankingMode.BALANCED)).toBeNull(); + }); + }); + + describe('getWeights', () => { + it('returns weights that sum to 1.0 for each mode', () => { + for (const mode of Object.values(RankingMode)) { + const w = service.getWeights(mode); + const sum = w.cost + w.speed + w.reliability + w.slippage; + expect(sum).toBeCloseTo(1.0, 5); + } + }); + + it('LOWEST_COST mode has highest cost weight', () => { + const w = service.getWeights(RankingMode.LOWEST_COST); + expect(w.cost).toBeGreaterThan(w.speed); + expect(w.cost).toBeGreaterThan(w.reliability); + }); + + it('FASTEST mode has highest speed weight', () => { + const w = service.getWeights(RankingMode.FASTEST); + expect(w.speed).toBeGreaterThan(w.cost); + expect(w.speed).toBeGreaterThan(w.reliability); + }); + }); +}); diff --git a/src/bridge-compare/ranking.service.ts b/src/bridge-compare/ranking.service.ts new file mode 100644 index 0000000..8a4e5fa --- /dev/null +++ b/src/bridge-compare/ranking.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { NormalizedQuote, RankingWeights } from '../interfaces'; +import { RankingMode } from '../enums'; + +@Injectable() +export class RankingService { + private readonly logger = new Logger(RankingService.name); + + private readonly RANKING_WEIGHTS: Record = { + [RankingMode.BALANCED]: { cost: 0.30, speed: 0.25, reliability: 0.30, slippage: 0.15 }, + [RankingMode.LOWEST_COST]: { cost: 0.55, speed: 0.15, reliability: 0.20, slippage: 0.10 }, + [RankingMode.FASTEST]: { cost: 0.15, speed: 0.55, reliability: 0.20, slippage: 0.10 }, + }; + + /** + * Apply ranking to a list of normalized quotes and assign composite scores + positions. + */ + rankQuotes(quotes: NormalizedQuote[], mode: RankingMode): NormalizedQuote[] { + if (!quotes.length) return []; + + const weights = this.RANKING_WEIGHTS[mode]; + const maxFee = Math.max(...quotes.map((q) => q.totalFeeUsd)); + const maxTime = Math.max(...quotes.map((q) => q.estimatedTimeSeconds)); + const maxSlippage = Math.max(...quotes.map((q) => q.slippagePercent)); + + this.logger.debug(`Ranking ${quotes.length} quotes with mode: ${mode}`); + + const scored = quotes.map((quote) => { + const costScore = maxFee > 0 ? (1 - quote.totalFeeUsd / maxFee) * 100 : 100; + const speedScore = maxTime > 0 ? (1 - quote.estimatedTimeSeconds / maxTime) * 100 : 100; + const reliabilityScore = quote.reliabilityScore; + const slippageScore = maxSlippage > 0 ? (1 - quote.slippagePercent / maxSlippage) * 100 : 100; + + const compositeScore = + costScore * weights.cost + + speedScore * weights.speed + + reliabilityScore * weights.reliability + + slippageScore * weights.slippage; + + return { + ...quote, + compositeScore: parseFloat(compositeScore.toFixed(2)), + }; + }); + + // Sort descending — higher composite score = better route + const sorted = scored.sort((a, b) => b.compositeScore - a.compositeScore); + + return sorted.map((quote, index) => ({ + ...quote, + rankingPosition: index + 1, + })); + } + + /** + * Get the best quote for a given ranking mode. + */ + getBestQuote(quotes: NormalizedQuote[], mode: RankingMode): NormalizedQuote | null { + const ranked = this.rankQuotes(quotes, mode); + return ranked[0] ?? null; + } + + /** + * Get ranking weights for a given mode. + */ + getWeights(mode: RankingMode): RankingWeights { + return this.RANKING_WEIGHTS[mode]; + } +} diff --git a/src/bridge-compare/reliability.service.spec.ts b/src/bridge-compare/reliability.service.spec.ts new file mode 100644 index 0000000..9d8c836 --- /dev/null +++ b/src/bridge-compare/reliability.service.spec.ts @@ -0,0 +1,78 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReliabilityService } from '../src/bridge-compare/reliability.service'; + +describe('ReliabilityService', () => { + let service: ReliabilityService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReliabilityService], + }).compile(); + + service = module.get(ReliabilityService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('calculateReliabilityScore', () => { + it('returns a score between 0 and 100 for known bridges', () => { + const bridges = ['stargate', 'squid', 'hop', 'cbridge', 'soroswap']; + for (const bridge of bridges) { + const score = service.calculateReliabilityScore(bridge); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(100); + } + }); + + it('returns default score (70) for unknown bridge', () => { + const score = service.calculateReliabilityScore('unknown-bridge'); + expect(score).toBe(70); + }); + + it('stargate scores higher than soroswap (better uptime/success)', () => { + const stargate = service.calculateReliabilityScore('stargate'); + const soroswap = service.calculateReliabilityScore('soroswap'); + expect(stargate).toBeGreaterThan(soroswap); + }); + + it('is case-insensitive for bridge ID', () => { + const lower = service.calculateReliabilityScore('stargate'); + const upper = service.calculateReliabilityScore('STARGATE'); + expect(lower).toBe(upper); + }); + }); + + describe('getMetrics', () => { + it('returns full metrics for known bridge', () => { + const metrics = service.getMetrics('stargate'); + expect(metrics.uptime24h).toBeGreaterThan(0); + expect(metrics.successRate7d).toBeGreaterThan(0); + expect(metrics.reliabilityScore).toBeGreaterThan(0); + }); + + it('returns zero-score metrics for unknown bridge', () => { + const metrics = service.getMetrics('nonexistent'); + expect(metrics.uptime24h).toBe(0); + expect(metrics.successRate7d).toBe(0); + }); + }); + + describe('batchCalculateScores', () => { + it('returns a score for each bridge ID', () => { + const ids = ['stargate', 'squid', 'hop']; + const result = service.batchCalculateScores(ids); + expect(result.size).toBe(3); + for (const id of ids) { + expect(result.has(id)).toBe(true); + expect(result.get(id)).toBeGreaterThanOrEqual(0); + } + }); + + it('returns empty map for empty input', () => { + const result = service.batchCalculateScores([]); + expect(result.size).toBe(0); + }); + }); +}); diff --git a/src/bridge-compare/reliability.service.ts b/src/bridge-compare/reliability.service.ts new file mode 100644 index 0000000..ce997ad --- /dev/null +++ b/src/bridge-compare/reliability.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ReliabilityMetrics } from '../interfaces'; + +@Injectable() +export class ReliabilityService { + private readonly logger = new Logger(ReliabilityService.name); + + // Weights for composite reliability score + private readonly WEIGHTS = { + uptime: 0.35, + successRate: 0.40, + delayPenalty: 0.15, + incidentPenalty: 0.10, + } as const; + + // Simulated historical metrics — in production, fetched from monitoring DB + private readonly MOCK_METRICS: Record = { + stargate: { + uptime24h: 99.8, + successRate7d: 98.5, + avgDelayPercent: 5, + incidentCount30d: 1, + reliabilityScore: 0, + }, + squid: { + uptime24h: 99.5, + successRate7d: 97.2, + avgDelayPercent: 12, + incidentCount30d: 2, + reliabilityScore: 0, + }, + hop: { + uptime24h: 98.9, + successRate7d: 96.8, + avgDelayPercent: 8, + incidentCount30d: 3, + reliabilityScore: 0, + }, + cbridge: { + uptime24h: 99.1, + successRate7d: 97.5, + avgDelayPercent: 10, + incidentCount30d: 2, + reliabilityScore: 0, + }, + soroswap: { + uptime24h: 97.5, + successRate7d: 95.0, + avgDelayPercent: 15, + incidentCount30d: 5, + reliabilityScore: 0, + }, + }; + + /** + * Calculate a 0-100 reliability score for a bridge provider. + */ + calculateReliabilityScore(bridgeId: string): number { + const metrics = this.MOCK_METRICS[bridgeId.toLowerCase()]; + + if (!metrics) { + this.logger.warn(`No reliability metrics for bridge: ${bridgeId}, using default score`); + return 70; // conservative default + } + + const score = this.computeScore(metrics); + this.logger.debug(`Reliability score for ${bridgeId}: ${score}`); + return score; + } + + /** + * Get full reliability metrics for a bridge. + */ + getMetrics(bridgeId: string): ReliabilityMetrics { + const metrics = this.MOCK_METRICS[bridgeId.toLowerCase()]; + if (!metrics) { + return { + uptime24h: 0, + successRate7d: 0, + avgDelayPercent: 100, + incidentCount30d: 99, + reliabilityScore: 50, + }; + } + return { ...metrics, reliabilityScore: this.computeScore(metrics) }; + } + + /** + * Batch compute scores for multiple bridges. + */ + batchCalculateScores(bridgeIds: string[]): Map { + const results = new Map(); + for (const id of bridgeIds) { + results.set(id, this.calculateReliabilityScore(id)); + } + return results; + } + + private computeScore(metrics: ReliabilityMetrics): number { + const uptimeScore = metrics.uptime24h; // 0-100 + const successScore = metrics.successRate7d; // 0-100 + const delayScore = Math.max(0, 100 - metrics.avgDelayPercent * 2); // penalize delays + const incidentScore = Math.max(0, 100 - metrics.incidentCount30d * 5); // penalize incidents + + const composite = + uptimeScore * this.WEIGHTS.uptime + + successScore * this.WEIGHTS.successRate + + delayScore * this.WEIGHTS.delayPenalty + + incidentScore * this.WEIGHTS.incidentPenalty; + + return parseFloat(Math.min(100, Math.max(0, composite)).toFixed(2)); + } +} diff --git a/src/bridge-compare/slippage.service.spec.ts b/src/bridge-compare/slippage.service.spec.ts new file mode 100644 index 0000000..c7e9290 --- /dev/null +++ b/src/bridge-compare/slippage.service.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SlippageService } from '../src/bridge-compare/slippage.service'; +import { RawBridgeQuote } from '../src/bridge-compare/interfaces'; + +const mockRawQuote = (id: string): RawBridgeQuote => ({ + bridgeId: id, + bridgeName: id, + outputAmount: 99, + feesUsd: 0.5, + gasCostUsd: 0.5, + estimatedTimeSeconds: 60, + steps: [], +}); + +describe('SlippageService', () => { + let service: SlippageService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SlippageService], + }).compile(); + + service = module.get(SlippageService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('estimateSlippage', () => { + it('returns high confidence for small amounts on known token', () => { + const result = service.estimateSlippage(mockRawQuote('stargate'), 'USDC', 'ethereum', 100); + expect(result.confidence).toBe('high'); + expect(result.expectedSlippage).toBeGreaterThanOrEqual(0); + expect(result.maxSlippage).toBeGreaterThan(result.expectedSlippage); + }); + + it('returns low confidence for large amounts relative to pool', () => { + const result = service.estimateSlippage(mockRawQuote('stargate'), 'USDC', 'ethereum', 5_000_000); + expect(result.confidence).toBe('low'); + }); + + it('returns conservative estimate for unknown token/chain', () => { + const result = service.estimateSlippage(mockRawQuote('unknown'), 'UNKNOWN', 'unknownchain', 100); + expect(result.confidence).toBe('low'); + expect(result.expectedSlippage).toBeGreaterThan(0); + }); + + it('returns medium confidence for mid-range amounts', () => { + const result = service.estimateSlippage(mockRawQuote('stargate'), 'USDC', 'ethereum', 500_000); + expect(result.confidence).toBe('medium'); + }); + + it('slippage increases with larger amounts', () => { + const small = service.estimateSlippage(mockRawQuote('x'), 'USDC', 'ethereum', 100); + const large = service.estimateSlippage(mockRawQuote('x'), 'USDC', 'ethereum', 1_000_000); + expect(large.expectedSlippage).toBeGreaterThan(small.expectedSlippage); + }); + }); + + describe('batchEstimateSlippage', () => { + it('returns a map with an entry per quote', () => { + const quotes = [mockRawQuote('a'), mockRawQuote('b'), mockRawQuote('c')]; + const result = service.batchEstimateSlippage(quotes, 'USDC', 'ethereum', 100); + expect(result.size).toBe(3); + expect(result.has('a')).toBe(true); + expect(result.has('b')).toBe(true); + expect(result.has('c')).toBe(true); + }); + + it('returns empty map for empty quotes array', () => { + const result = service.batchEstimateSlippage([], 'USDC', 'ethereum', 100); + expect(result.size).toBe(0); + }); + }); +}); diff --git a/src/bridge-compare/slippage.service.ts b/src/bridge-compare/slippage.service.ts new file mode 100644 index 0000000..b33bf70 --- /dev/null +++ b/src/bridge-compare/slippage.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SlippageEstimate, RawBridgeQuote } from '../interfaces'; + +interface LiquidityPool { + token: string; + chain: string; + tvlUsd: number; + dailyVolumeUsd: number; +} + +@Injectable() +export class SlippageService { + private readonly logger = new Logger(SlippageService.name); + + // Simulated liquidity pool data — in production, fetched from on-chain / oracles + private readonly MOCK_POOLS: LiquidityPool[] = [ + { token: 'USDC', chain: 'ethereum', tvlUsd: 50_000_000, dailyVolumeUsd: 10_000_000 }, + { token: 'USDC', chain: 'stellar', tvlUsd: 5_000_000, dailyVolumeUsd: 1_000_000 }, + { token: 'USDT', chain: 'ethereum', tvlUsd: 40_000_000, dailyVolumeUsd: 8_000_000 }, + { token: 'ETH', chain: 'ethereum', tvlUsd: 200_000_000, dailyVolumeUsd: 50_000_000 }, + { token: 'XLM', chain: 'stellar', tvlUsd: 2_000_000, dailyVolumeUsd: 500_000 }, + ]; + + /** + * Estimate slippage for a bridge quote based on amount vs. pool liquidity. + */ + estimateSlippage( + quote: RawBridgeQuote, + sourceToken: string, + sourceChain: string, + amountUsd: number, + ): SlippageEstimate { + const pool = this.MOCK_POOLS.find( + (p) => p.token.toUpperCase() === sourceToken.toUpperCase() && p.chain === sourceChain, + ); + + if (!pool) { + this.logger.warn(`No liquidity data for ${sourceToken} on ${sourceChain}, using conservative estimate`); + return this.conservativeEstimate(amountUsd); + } + + const impactRatio = amountUsd / pool.tvlUsd; + const expectedSlippage = this.calculatePriceImpact(impactRatio); + const maxSlippage = expectedSlippage * 2.5; + const confidence = this.determineConfidence(pool, amountUsd); + + return { + expectedSlippage: parseFloat(expectedSlippage.toFixed(4)), + maxSlippage: parseFloat(maxSlippage.toFixed(4)), + confidence, + }; + } + + /** + * Batch estimate slippage across multiple quotes. + */ + batchEstimateSlippage( + quotes: RawBridgeQuote[], + sourceToken: string, + sourceChain: string, + amountUsd: number, + ): Map { + const results = new Map(); + for (const quote of quotes) { + results.set(quote.bridgeId, this.estimateSlippage(quote, sourceToken, sourceChain, amountUsd)); + } + return results; + } + + private calculatePriceImpact(impactRatio: number): number { + // Approximation of constant-product AMM price impact: 1 - 1/sqrt(1 + x) + return (1 - 1 / Math.sqrt(1 + impactRatio)) * 100; + } + + private determineConfidence(pool: LiquidityPool, amountUsd: number): 'high' | 'medium' | 'low' { + const ratio = amountUsd / pool.tvlUsd; + if (ratio < 0.001) return 'high'; + if (ratio < 0.01) return 'medium'; + return 'low'; + } + + private conservativeEstimate(amountUsd: number): SlippageEstimate { + const base = Math.min(amountUsd / 100_000, 5); + return { + expectedSlippage: parseFloat(base.toFixed(4)), + maxSlippage: parseFloat((base * 2).toFixed(4)), + confidence: 'low', + }; + } +}