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
436 changes: 436 additions & 0 deletions src/bridge-compare/BridgeCompare.test.tsx

Large diffs are not rendered by default.

449 changes: 449 additions & 0 deletions src/bridge-compare/BridgeCompare.tsx

Large diffs are not rendered by default.

146 changes: 146 additions & 0 deletions src/bridge-compare/aggregation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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);
}
});
});
});
182 changes: 182 additions & 0 deletions src/bridge-compare/aggregation.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, MockQuoteTemplate> = {
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<RawBridgeQuote> {
// 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;
}
}
Loading
Loading