Files
smom-dbis-138/orchestration/bridge/quote-service.ts
2026-03-02 12:14:09 -08:00

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
);
}