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
23 changes: 23 additions & 0 deletions src/real-time-fee-aggregation/bridge-adapter.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface QuoteRequest {
fromChain: number;
toChain: number;
token: string;
amount: string;
}

export interface NormalizedQuote {
bridgeName: string;
totalFeeUSD: number;
feeToken: string;
estimatedArrivalTime: number; // seconds
outputAmount: string;
score?: number;
supported: boolean;
error?: string;
}

export interface BridgeAdapter {
readonly name: string;
getQuote(request: QuoteRequest): Promise<NormalizedQuote>;
supportsRoute(fromChain: number, toChain: number, token: string): boolean;
}
74 changes: 74 additions & 0 deletions src/real-time-fee-aggregation/bridge-registry.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BridgeRegistryService } from '../src/services/bridge-registry.service';
import { BridgeAdapter, NormalizedQuote, QuoteRequest } from '../src/interfaces/bridge-adapter.interface';

const makeAdapter = (name: string, supported = true): BridgeAdapter => ({
name,
supportsRoute: jest.fn().mockReturnValue(supported),
getQuote: jest.fn().mockResolvedValue({
bridgeName: name,
totalFeeUSD: 1.5,
feeToken: 'USDC',
estimatedArrivalTime: 180,
outputAmount: '998.5',
supported: true,
} as NormalizedQuote),
});

describe('BridgeRegistryService', () => {
let service: BridgeRegistryService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [BridgeRegistryService],
}).compile();

service = module.get<BridgeRegistryService>(BridgeRegistryService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

it('should register a single adapter', () => {
const adapter = makeAdapter('TestBridge');
service.register(adapter);
expect(service.count).toBe(1);
});

it('should register multiple adapters', () => {
service.register(makeAdapter('Bridge1'));
service.register(makeAdapter('Bridge2'));
service.register(makeAdapter('Bridge3'));
expect(service.count).toBe(3);
});

it('should overwrite duplicate adapter names', () => {
const original = makeAdapter('DupBridge');
const replacement = makeAdapter('DupBridge');
service.register(original);
service.register(replacement);
expect(service.count).toBe(1);
expect(service.getAdapter('DupBridge')).toBe(replacement);
});

it('should list all registered adapters', () => {
const a1 = makeAdapter('A');
const a2 = makeAdapter('B');
service.register(a1);
service.register(a2);
const list = service.listAdapters();
expect(list).toHaveLength(2);
expect(list).toContain(a1);
expect(list).toContain(a2);
});

it('should return undefined for unknown adapter', () => {
expect(service.getAdapter('NonExistent')).toBeUndefined();
});

it('should return empty array when no adapters registered', () => {
expect(service.listAdapters()).toEqual([]);
expect(service.count).toBe(0);
});
});
30 changes: 30 additions & 0 deletions src/real-time-fee-aggregation/bridge-registry.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { BridgeAdapter } from '../interfaces/bridge-adapter.interface';

export const BRIDGE_ADAPTERS = 'BRIDGE_ADAPTERS';

@Injectable()
export class BridgeRegistryService {
private readonly logger = new Logger(BridgeRegistryService.name);
private readonly adapters: Map<string, BridgeAdapter> = new Map();

register(adapter: BridgeAdapter): void {
if (this.adapters.has(adapter.name)) {
this.logger.warn(`Adapter "${adapter.name}" is already registered. Overwriting.`);
}
this.adapters.set(adapter.name, adapter);
this.logger.log(`Registered bridge adapter: ${adapter.name}`);
}

listAdapters(): BridgeAdapter[] {
return Array.from(this.adapters.values());
}

getAdapter(name: string): BridgeAdapter | undefined {
return this.adapters.get(name);
}

get count(): number {
return this.adapters.size;
}
}
167 changes: 167 additions & 0 deletions src/real-time-fee-aggregation/bridge.adapters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AcrossAdapter, HopAdapter, StargateAdapter } from '../src/adapters/bridge.adapters';
import { QuoteRequest } from '../src/interfaces/bridge-adapter.interface';

const baseRequest: QuoteRequest = {
fromChain: 1,
toChain: 137,
token: 'USDC',
amount: '1000',
};

describe('Bridge Adapters', () => {
let across: AcrossAdapter;
let hop: HopAdapter;
let stargate: StargateAdapter;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AcrossAdapter, HopAdapter, StargateAdapter],
}).compile();

across = module.get<AcrossAdapter>(AcrossAdapter);
hop = module.get<HopAdapter>(HopAdapter);
stargate = module.get<StargateAdapter>(StargateAdapter);
});

// ─── AcrossAdapter ──────────────────────────────────────────────────────────

describe('AcrossAdapter', () => {
it('should have name "Across"', () => {
expect(across.name).toBe('Across');
});

it('should support ETH→Polygon USDC route', () => {
expect(across.supportsRoute(1, 137, 'USDC')).toBe(true);
});

it('should support ETH→Arbitrum WETH route', () => {
expect(across.supportsRoute(1, 42161, 'WETH')).toBe(true);
});

it('should not support unsupported token on a valid route', () => {
expect(across.supportsRoute(1, 137, 'SHIB')).toBe(false);
});

it('should not support unknown chain pair', () => {
expect(across.supportsRoute(1, 99999, 'USDC')).toBe(false);
});

it('should return normalized quote with correct structure', async () => {
const quote = await across.getQuote(baseRequest);

expect(quote.bridgeName).toBe('Across');
expect(quote.supported).toBe(true);
expect(quote.totalFeeUSD).toBeGreaterThan(0);
expect(quote.estimatedArrivalTime).toBe(120);
expect(quote.feeToken).toBe('USDC');
expect(parseFloat(quote.outputAmount)).toBeLessThan(1000);
expect(parseFloat(quote.outputAmount)).toBeGreaterThan(0);
}, 3000);

it('should compute outputAmount as amount minus fees', async () => {
const quote = await across.getQuote(baseRequest);
const expected = 1000 - quote.totalFeeUSD;
expect(Math.abs(parseFloat(quote.outputAmount) - expected)).toBeLessThan(0.01);
}, 3000);
});

// ─── HopAdapter ─────────────────────────────────────────────────────────────

describe('HopAdapter', () => {
it('should have name "Hop"', () => {
expect(hop.name).toBe('Hop');
});

it('should support ETH→Polygon USDC', () => {
expect(hop.supportsRoute(1, 137, 'USDC')).toBe(true);
});

it('should support Polygon→Arbitrum USDT', () => {
expect(hop.supportsRoute(137, 42161, 'USDT')).toBe(true);
});

it('should not support unavailable token', () => {
expect(hop.supportsRoute(1, 137, 'WBTC')).toBe(false);
});

it('should return normalized quote', async () => {
const quote = await hop.getQuote(baseRequest);

expect(quote.bridgeName).toBe('Hop');
expect(quote.supported).toBe(true);
expect(quote.totalFeeUSD).toBeGreaterThan(0);
expect(quote.estimatedArrivalTime).toBe(300);
expect(parseFloat(quote.outputAmount)).toBeGreaterThan(0);
}, 3000);

it('should include gas cost in total fee', async () => {
const quote = await hop.getQuote(baseRequest);
// Hop always adds $2.5 gas cost, so totalFeeUSD > 2.5
expect(quote.totalFeeUSD).toBeGreaterThan(2.5);
}, 3000);
});

// ─── StargateAdapter ────────────────────────────────────────────────────────

describe('StargateAdapter', () => {
it('should have name "Stargate"', () => {
expect(stargate.name).toBe('Stargate');
});

it('should support ETH→Polygon USDC', () => {
expect(stargate.supportsRoute(1, 137, 'USDC')).toBe(true);
});

it('should support Arbitrum→Optimism ETH', () => {
expect(stargate.supportsRoute(42161, 10, 'ETH')).toBe(true);
});

it('should not support DAI (no liquidity pool)', () => {
expect(stargate.supportsRoute(1, 137, 'DAI')).toBe(false);
});

it('should return normalized quote', async () => {
const quote = await stargate.getQuote(baseRequest);

expect(quote.bridgeName).toBe('Stargate');
expect(quote.supported).toBe(true);
expect(quote.totalFeeUSD).toBeGreaterThan(0);
expect(quote.estimatedArrivalTime).toBe(600);
expect(parseFloat(quote.outputAmount)).toBeGreaterThan(0);
}, 3000);

it('should include LayerZero messaging fee', async () => {
const quote = await stargate.getQuote(baseRequest);
// LayerZero fee is $1.8, so total must exceed that
expect(quote.totalFeeUSD).toBeGreaterThan(1.8);
}, 3000);
});

// ─── Comparative checks ─────────────────────────────────────────────────────

describe('Comparative', () => {
it('Across should be faster than Hop and Stargate', async () => {
const [acrossQ, hopQ, stargateQ] = await Promise.all([
across.getQuote(baseRequest),
hop.getQuote(baseRequest),
stargate.getQuote(baseRequest),
]);

expect(acrossQ.estimatedArrivalTime).toBeLessThan(hopQ.estimatedArrivalTime);
expect(acrossQ.estimatedArrivalTime).toBeLessThan(stargateQ.estimatedArrivalTime);
}, 5000);

it('all adapters should return positive outputAmount', async () => {
const quotes = await Promise.all([
across.getQuote(baseRequest),
hop.getQuote(baseRequest),
stargate.getQuote(baseRequest),
]);

quotes.forEach((q) => {
expect(parseFloat(q.outputAmount)).toBeGreaterThan(0);
});
}, 5000);
});
});
Loading
Loading