397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
/**
|
|
* @file quote-service.ts
|
|
* @notice Quote service for bridge transfers with route intelligence
|
|
*/
|
|
|
|
import { ethers } from 'ethers';
|
|
import { BridgeRegistry } from '../../contracts/bridge/interop';
|
|
import { RouteInfo, DestinationType } from './workflow-engine';
|
|
|
|
export interface QuoteRequest {
|
|
token: string; // address(0) for native
|
|
amount: string;
|
|
destinationChainId: number;
|
|
destinationType: DestinationType;
|
|
destinationAddress: string;
|
|
}
|
|
|
|
export interface QuoteResponse {
|
|
transferId: string;
|
|
routes: RouteInfo[];
|
|
recommendedRoute: RouteInfo;
|
|
totalFee: string;
|
|
minReceived: string;
|
|
estimatedTime: number;
|
|
slippage: string;
|
|
riskLevel: number;
|
|
/** Best swap quote (e.g. Dodoex) on source chain when EnhancedSwapRouter is configured */
|
|
sourceSwapQuote?: string;
|
|
/** Best swap quote on destination chain when swap router is configured */
|
|
destinationSwapQuote?: string;
|
|
}
|
|
|
|
const ENHANCED_SWAP_ROUTER_ABI = [
|
|
'function getQuotes(address stablecoinToken, uint256 amountIn) view returns (uint8[] providers, uint256[] amounts)',
|
|
];
|
|
|
|
export class QuoteService {
|
|
private provider: ethers.Provider;
|
|
private registry: ethers.Contract;
|
|
private thirdwebClientId?: string;
|
|
private enhancedSwapRouterAddress?: string;
|
|
private destinationProvider?: ethers.Provider;
|
|
private destinationSwapRouterAddress?: string;
|
|
|
|
constructor(
|
|
rpcUrl: string,
|
|
registryAddress: string,
|
|
registryAbi: any[],
|
|
thirdwebClientId?: string,
|
|
enhancedSwapRouterAddress?: string,
|
|
destinationRpcUrl?: string,
|
|
destinationSwapRouterAddress?: string
|
|
) {
|
|
this.provider = new ethers.JsonRpcProvider(rpcUrl);
|
|
this.registry = new ethers.Contract(registryAddress, registryAbi, this.provider);
|
|
this.thirdwebClientId = thirdwebClientId;
|
|
this.enhancedSwapRouterAddress = enhancedSwapRouterAddress;
|
|
if (destinationRpcUrl) {
|
|
this.destinationProvider = new ethers.JsonRpcProvider(destinationRpcUrl);
|
|
}
|
|
this.destinationSwapRouterAddress = destinationSwapRouterAddress;
|
|
}
|
|
|
|
/**
|
|
* Get quote for bridge transfer
|
|
*/
|
|
async getQuote(request: QuoteRequest): Promise<QuoteResponse> {
|
|
// Validate request with registry
|
|
const [isValid, fee] = await this.registry.validateBridgeRequest(
|
|
request.token,
|
|
request.amount,
|
|
request.destinationChainId
|
|
);
|
|
|
|
if (!isValid) {
|
|
throw new Error('Invalid bridge request');
|
|
}
|
|
|
|
// Get route options
|
|
const routes = await this.getRouteOptions(
|
|
request.token,
|
|
request.amount,
|
|
request.destinationChainId,
|
|
request.destinationType
|
|
);
|
|
|
|
// Select best route
|
|
const recommendedRoute = this.selectBestRoute(routes);
|
|
|
|
// Calculate totals
|
|
const totalFee = BigInt(fee.toString());
|
|
const amountBigInt = BigInt(request.amount);
|
|
const minReceived = amountBigInt - totalFee;
|
|
|
|
// Estimate time based on destination
|
|
const estimatedTime = this.estimateSettlementTime(
|
|
request.destinationType,
|
|
request.destinationChainId
|
|
);
|
|
|
|
// Calculate slippage (default 0.5%)
|
|
const slippage = '50'; // 50 basis points = 0.5%
|
|
|
|
// Get risk level from registry
|
|
let riskLevel = 0;
|
|
if (request.token !== ethers.ZeroAddress) {
|
|
const tokenConfig = await this.registry.tokenConfigs(request.token);
|
|
riskLevel = tokenConfig.riskLevel;
|
|
}
|
|
|
|
// Optional: Dodoex-inclusive swap quotes when EnhancedSwapRouter is configured
|
|
let sourceSwapQuote: string | undefined;
|
|
let destinationSwapQuote: string | undefined;
|
|
if (this.enhancedSwapRouterAddress && request.token !== ethers.ZeroAddress) {
|
|
try {
|
|
const router = new ethers.Contract(
|
|
this.enhancedSwapRouterAddress,
|
|
ENHANCED_SWAP_ROUTER_ABI,
|
|
this.provider
|
|
);
|
|
const [providers, amounts] = await router.getQuotes(request.token, request.amount);
|
|
if (amounts && amounts.length > 0) {
|
|
const best = amounts.reduce((a: bigint, b: bigint) => (a > b ? a : b), 0n);
|
|
sourceSwapQuote = best.toString();
|
|
}
|
|
} catch {
|
|
// Router not deployed or getQuotes failed; leave undefined
|
|
}
|
|
}
|
|
if (this.destinationProvider && this.destinationSwapRouterAddress && request.token !== ethers.ZeroAddress) {
|
|
try {
|
|
const router = new ethers.Contract(
|
|
this.destinationSwapRouterAddress,
|
|
ENHANCED_SWAP_ROUTER_ABI,
|
|
this.destinationProvider
|
|
);
|
|
const [providers, amounts] = await router.getQuotes(request.token, request.amount);
|
|
if (amounts && amounts.length > 0) {
|
|
const best = amounts.reduce((a: bigint, b: bigint) => (a > b ? a : b), 0n);
|
|
destinationSwapQuote = best.toString();
|
|
}
|
|
} catch {
|
|
// Destination router not available
|
|
}
|
|
}
|
|
|
|
return {
|
|
transferId: this.generateTransferId(),
|
|
routes,
|
|
recommendedRoute,
|
|
totalFee: totalFee.toString(),
|
|
minReceived: minReceived.toString(),
|
|
estimatedTime,
|
|
slippage,
|
|
riskLevel,
|
|
sourceSwapQuote,
|
|
destinationSwapQuote
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get available route options
|
|
*/
|
|
private async getRouteOptions(
|
|
token: string,
|
|
amount: string,
|
|
destinationChainId: number,
|
|
destinationType: DestinationType
|
|
): Promise<RouteInfo[]> {
|
|
const routes: RouteInfo[] = [];
|
|
|
|
if (destinationType === DestinationType.EVM) {
|
|
// Get EVM routes via thirdweb or direct bridge
|
|
if (this.thirdwebClientId) {
|
|
const thirdwebRoute = await this.getThirdwebRoute(
|
|
token,
|
|
amount,
|
|
destinationChainId
|
|
);
|
|
if (thirdwebRoute) {
|
|
routes.push(thirdwebRoute);
|
|
}
|
|
}
|
|
|
|
// Add direct bridge route if available
|
|
const directRoute = await this.getDirectBridgeRoute(
|
|
token,
|
|
amount,
|
|
destinationChainId
|
|
);
|
|
if (directRoute) {
|
|
routes.push(directRoute);
|
|
}
|
|
} else if (destinationType === DestinationType.XRPL) {
|
|
// XRPL route via Cacti
|
|
const xrplRoute = await this.getXRPLRoute(token, amount);
|
|
if (xrplRoute) {
|
|
routes.push(xrplRoute);
|
|
}
|
|
} else if (destinationType === DestinationType.FABRIC) {
|
|
// Fabric route via Cacti
|
|
const fabricRoute = await this.getFabricRoute(token, amount);
|
|
if (fabricRoute) {
|
|
routes.push(fabricRoute);
|
|
}
|
|
}
|
|
|
|
return routes;
|
|
}
|
|
|
|
/**
|
|
* Get thirdweb route quote
|
|
*/
|
|
private async getThirdwebRoute(
|
|
token: string,
|
|
amount: string,
|
|
destinationChainId: number
|
|
): Promise<RouteInfo | null> {
|
|
try {
|
|
// Call thirdweb API for quote
|
|
const response = await fetch(
|
|
`https://api.thirdweb.com/v1/bridge/quote?clientId=${this.thirdwebClientId}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
fromChain: 138,
|
|
toChain: destinationChainId,
|
|
fromToken: token,
|
|
toToken: token, // Same token on destination
|
|
amount: amount
|
|
})
|
|
}
|
|
);
|
|
|
|
if (!response.ok) return null;
|
|
|
|
const data = await response.json();
|
|
const healthScore = await this.registry.getRouteHealthScore(
|
|
destinationChainId,
|
|
token
|
|
);
|
|
|
|
return {
|
|
chainId: destinationChainId,
|
|
chainName: await this.getChainName(destinationChainId),
|
|
provider: 'thirdweb',
|
|
estimatedTime: data.estimatedTime || 300, // 5 minutes default
|
|
fee: data.fee || '0',
|
|
healthScore: Number(healthScore)
|
|
};
|
|
} catch (error) {
|
|
console.error('Thirdweb quote error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get direct bridge route
|
|
*/
|
|
private async getDirectBridgeRoute(
|
|
token: string,
|
|
amount: string,
|
|
destinationChainId: number
|
|
): Promise<RouteInfo | null> {
|
|
const destination = await this.registry.destinations(destinationChainId);
|
|
if (!destination.enabled) return null;
|
|
|
|
const healthScore = await this.registry.getRouteHealthScore(destinationChainId, token);
|
|
const baseFee = (BigInt(amount) * BigInt(destination.baseFee)) / BigInt(10000);
|
|
|
|
return {
|
|
chainId: destinationChainId,
|
|
chainName: destination.chainName,
|
|
provider: 'direct',
|
|
estimatedTime: destination.timeoutSeconds / 2, // Estimate half of timeout
|
|
fee: baseFee.toString(),
|
|
healthScore: Number(healthScore)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get XRPL route
|
|
*/
|
|
private async getXRPLRoute(token: string, amount: string): Promise<RouteInfo | null> {
|
|
// XRPL destination has chainId = 0
|
|
const destination = await this.registry.destinations(0);
|
|
if (!destination.enabled) return null;
|
|
|
|
const baseFee = (BigInt(amount) * BigInt(destination.baseFee)) / BigInt(10000);
|
|
|
|
return {
|
|
chainId: 0,
|
|
chainName: 'XRPL',
|
|
provider: 'cacti-xrpl',
|
|
estimatedTime: 60, // XRPL is fast, ~3-5 seconds, but add buffer
|
|
fee: baseFee.toString(),
|
|
healthScore: 8000 // Default high score for XRPL
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get Fabric route. Set FABRIC_CHAIN_ID in env when Fabric is live; default 999 until then.
|
|
*/
|
|
private async getFabricRoute(token: string, amount: string): Promise<RouteInfo | null> {
|
|
const fabricChainId = process.env.FABRIC_CHAIN_ID ? parseInt(process.env.FABRIC_CHAIN_ID, 10) : 999;
|
|
return {
|
|
chainId: fabricChainId,
|
|
chainName: 'Hyperledger Fabric',
|
|
provider: 'cacti-fabric',
|
|
estimatedTime: 120, // Fabric settlement time
|
|
fee: '0', // May vary
|
|
healthScore: 7000
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Select best route based on health score, fee, and time
|
|
*/
|
|
private selectBestRoute(routes: RouteInfo[]): RouteInfo {
|
|
if (routes.length === 0) {
|
|
throw new Error('No routes available');
|
|
}
|
|
|
|
// Score routes: health (40%), low fee (30%), fast time (30%)
|
|
const scored = routes.map(route => {
|
|
const healthScore = route.healthScore / 10000; // Normalize to 0-1
|
|
const feeScore = 1 - (parseFloat(route.fee) / parseFloat(routes[0].amount || '1')); // Relative fee
|
|
const timeScore = 1 - (route.estimatedTime / 3600); // Normalize to 1 hour max
|
|
|
|
const totalScore = healthScore * 0.4 + feeScore * 0.3 + timeScore * 0.3;
|
|
|
|
return { route, score: totalScore };
|
|
});
|
|
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored[0].route;
|
|
}
|
|
|
|
/**
|
|
* Estimate settlement time
|
|
*/
|
|
private estimateSettlementTime(
|
|
destinationType: DestinationType,
|
|
chainId: number
|
|
): number {
|
|
if (destinationType === DestinationType.XRPL) {
|
|
return 60; // ~1 minute for XRPL
|
|
} else if (destinationType === DestinationType.FABRIC) {
|
|
return 120; // ~2 minutes for Fabric
|
|
} else {
|
|
// EVM chains: estimate based on finality
|
|
return 300; // Default 5 minutes for EVM
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get chain name
|
|
*/
|
|
private async getChainName(chainId: number): Promise<string> {
|
|
const destination = await this.registry.destinations(chainId);
|
|
return destination.chainName || `Chain ${chainId}`;
|
|
}
|
|
|
|
/**
|
|
* Generate unique transfer ID
|
|
*/
|
|
private generateTransferId(): string {
|
|
return ethers.keccak256(
|
|
ethers.toUtf8Bytes(`${Date.now()}-${Math.random()}`)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create QuoteService from environment variables (for bridge API / orchestration).
|
|
* Set RPC_URL, BRIDGE_REGISTRY_ADDRESS; optional: ENHANCED_SWAP_ROUTER_ADDRESS,
|
|
* DESTINATION_RPC_URL, DESTINATION_SWAP_ROUTER_ADDRESS, THIRDWEB_CLIENT_ID.
|
|
*/
|
|
export function createQuoteServiceFromEnv(): QuoteService {
|
|
const rpcUrl = process.env.RPC_URL || process.env.RPC_URL_138 || '';
|
|
const registryAddress = process.env.BRIDGE_REGISTRY_ADDRESS || '';
|
|
const registryAbi: any[] = []; // Bridge registry ABI - load from contract if needed
|
|
if (!rpcUrl || !registryAddress) {
|
|
throw new Error('RPC_URL (or RPC_URL_138) and BRIDGE_REGISTRY_ADDRESS are required');
|
|
}
|
|
return new QuoteService(
|
|
rpcUrl,
|
|
registryAddress,
|
|
registryAbi,
|
|
process.env.THIRDWEB_CLIENT_ID,
|
|
process.env.ENHANCED_SWAP_ROUTER_ADDRESS,
|
|
process.env.DESTINATION_RPC_URL,
|
|
process.env.DESTINATION_SWAP_ROUTER_ADDRESS
|
|
);
|
|
}
|