diff --git a/CLAUDE.md b/CLAUDE.md index 8f2a74ea2f..1a2e1d6181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ This file provides guidance to AI coding assistants when working with code in th - `raydium/`: Contains both `amm-routes/` and `clmm-routes/` - `uniswap/`: Contains `router-routes/`, `amm-routes/`, and `clmm-routes/` - `0x/router-routes/`: 0x aggregator routes + - `fibrous/router-routes/`: Fibrous aggregator routes - `services/`: Core services and utilities - `config-manager-v2.ts`: Configuration management - `logger.ts`: Logging service @@ -200,6 +201,8 @@ Gateway supports optimized RPC providers for enhanced performance: - Base - BSC (Binance Smart Chain) - Celo +- HyperEVM +- Monad - Optimism - Polygon - Sepolia (testnet) @@ -214,6 +217,7 @@ Gateway supports optimized RPC providers for enhanced performance: - **Raydium** (Solana): Standard AMM and CLMM operations - **Uniswap** (Ethereum/EVM): V2 AMM, V3 CLMM, and V3 Smart Order Router swaps - **0x** (Ethereum/EVM): Router-based swaps via DEX aggregator +- **Fibrous** (Ethereum/EVM): Router-based swaps via DEX aggregator (Base, HyperEVM, Monad) ### Supported DEX Protocols @@ -224,6 +228,7 @@ Gateway supports optimized RPC providers for enhanced performance: | Raydium | Solana | ❌ | ✅ | ✅ | | Uniswap | Ethereum/EVM | ✅ | ✅ | ✅ | | 0x | Ethereum/EVM | ✅ | ❌ | ❌ | +| Fibrous | Ethereum/EVM | ✅ | ❌ | ❌ | ## Environment Variables - `GATEWAY_PASSPHRASE`: Set passphrase for wallet encryption diff --git a/README.md b/README.md index 54fc4cef19..04f11996c7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Gateway can be accessed through: - **Hummingbot Client**: For automated trading strategies, use the [Hummingbot repository](https://github.com/hummingbot/hummingbot) ### Key Features -- **Standardized REST API**: Consistent endpoints for interacting with blockchains (Ethereum, Solana) and DEXs (Uniswap, Jupiter, Raydium, Meteora, PancakeSwap, 0x) +- **Standardized REST API**: Consistent endpoints for interacting with blockchains (Ethereum, Solana) and DEXs (Uniswap, Jupiter, Raydium, Meteora, PancakeSwap, 0x, Fibrous) - **Three Trading Types**: Router (DEX aggregators), AMM (V2-style pools), and CLMM (V3-style concentrated liquidity) - **Modular Architecture**: Clear separation of concerns with distinct modules for chains, connectors, configuration, and wallet management - **TypeScript-based**: Leverages the TypeScript ecosystem and popular libraries like Fastify, Ethers.js, and Solana/web3.js @@ -46,6 +46,8 @@ Gateway may be used alongside the main [Hummingbot client](https://github.com/hu - Base - BSC (Binance Smart Chain) - Celo +- HyperEVM +- Monad - Optimism - Polygon - Sepolia (testnet) @@ -83,6 +85,7 @@ Both RPC providers maintain full backward compatibility - networks default to st | PancakeSwap | Ethereum/EVM | ✅ | ✅ | ✅ | Multi-chain DEX with V2 AMM, V3 CLMM, and Smart Router | | Uniswap | Ethereum/EVM | ✅ | ✅ | ✅ | Complete V2 AMM, V3 CLMM, and Smart Order Router | | 0x | Ethereum/EVM | ✅ | ❌ | ❌ | DEX aggregator with professional market making features | +| Fibrous | Ethereum/EVM | ✅ | ❌ | ❌ | DEX aggregator for Base, HyperEVM and Monad | #### Trading Types Explained: - **Router**: DEX aggregators that find optimal swap routes across multiple liquidity sources diff --git a/src/app.ts b/src/app.ts index 0d1591e162..d107e8f397 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,7 @@ import { solanaRoutes } from './chains/solana/solana.routes'; import { configRoutes } from './config/config.routes'; import { register0xRoutes } from './connectors/0x/0x.routes'; import { dflowRoutes } from './connectors/dflow/dflow.routes'; +import { registerFibrousRoutes } from './connectors/fibrous/fibrous.routes'; import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; import { meteoraRoutes } from './connectors/meteora/meteora.routes'; import { okxRoutes } from './connectors/okx/okx.routes'; @@ -114,6 +115,10 @@ const swaggerOptions = { description: 'Uniswap connector endpoints', }, { name: '/connector/0x', description: '0x connector endpoints' }, + { + name: '/connector/fibrous', + description: 'Fibrous connector endpoints', + }, { name: '/connector/pancakeswap-sol', description: 'PancakeSwap Solana connector endpoints', @@ -342,6 +347,9 @@ const configureGatewayServer = () => { // 0x routes app.register(register0xRoutes); + // Fibrous routes + app.register(registerFibrousRoutes); + // Pancakeswap routes app.register(pancakeswapRoutes.router, { prefix: '/connectors/pancakeswap/router', diff --git a/src/chains/ethereum/ethereum.ts b/src/chains/ethereum/ethereum.ts index ec3e34a931..aaf0adca9f 100644 --- a/src/chains/ethereum/ethereum.ts +++ b/src/chains/ethereum/ethereum.ts @@ -39,6 +39,8 @@ export const EIP1559_NETWORKS = [ 'robinhoodchain', 'robinhoodchain-testnet', 'unichain', + 'hyperevm', + 'monad', ]; export class Ethereum { @@ -917,6 +919,16 @@ export class Ethereum { symbol: 'WETH', nativeSymbol: 'ETH', }, + hyperevm: { + address: '0x5555555555555555555555555555555555555555', + symbol: 'WHYPE', + nativeSymbol: 'HYPE', + }, + monad: { + address: '0x3bD359C1119dA7dA1D913D1c4d2b7C461115433a', + symbol: 'WMON', + nativeSymbol: 'MON', + }, }; /** diff --git a/src/config/routes/getConnectors.ts b/src/config/routes/getConnectors.ts index c6eed4dd2c..b96b1dd213 100644 --- a/src/config/routes/getConnectors.ts +++ b/src/config/routes/getConnectors.ts @@ -5,6 +5,7 @@ import { PancakeswapConfig } from '#src/connectors/pancakeswap/pancakeswap.confi import { ZeroXConfig } from '../../connectors/0x/0x.config'; import { DFlowConfig } from '../../connectors/dflow/dflow.config'; +import { FibrousConfig } from '../../connectors/fibrous/fibrous.config'; import { JupiterConfig } from '../../connectors/jupiter/jupiter.config'; import { MeteoraConfig } from '../../connectors/meteora/meteora.config'; import { OkxConfig } from '../../connectors/okx/okx.config'; @@ -62,6 +63,12 @@ export const connectorsConfig = [ chain: ZeroXConfig.chain, networks: [...ZeroXConfig.networks], }, + { + name: 'fibrous', + trading_types: [...FibrousConfig.tradingTypes], + chain: FibrousConfig.chain, + networks: [...FibrousConfig.networks], + }, { name: 'pancakeswap', trading_types: [...PancakeswapConfig.tradingTypes], diff --git a/src/connectors/fibrous/fibrous.abi.ts b/src/connectors/fibrous/fibrous.abi.ts new file mode 100644 index 0000000000..a9d6c7d5f9 --- /dev/null +++ b/src/connectors/fibrous/fibrous.abi.ts @@ -0,0 +1,48 @@ +/** + * Minimal ABI for the Fibrous EVM router contract. + * + * Only the `swap` entrypoint is included, since that is the sole function + * Gateway encodes. The full ABI is published at: + * https://github.com/Fibrous-Finance/router-contract-abi + * + * The tuple layouts below mirror the `route` and `swap_parameters` objects + * returned verbatim by the Fibrous `POST /{network}/v2/calldata` endpoint. + */ +export const FibrousRouterABI = [ + { + type: 'function', + name: 'swap', + inputs: [ + { + name: 'route', + type: 'tuple', + internalType: 'struct RouteParam', + components: [ + { name: 'token_in', type: 'address', internalType: 'address' }, + { name: 'token_out', type: 'address', internalType: 'address' }, + { name: 'amount_in', type: 'uint256', internalType: 'uint256' }, + { name: 'amount_out', type: 'uint256', internalType: 'uint256' }, + { name: 'min_received', type: 'uint256', internalType: 'uint256' }, + { name: 'destination', type: 'address', internalType: 'address' }, + { name: 'swap_type', type: 'uint8', internalType: 'enum SwapType' }, + ], + }, + { + name: 'swap_parameters', + type: 'tuple[]', + internalType: 'struct SwapParams[]', + components: [ + { name: 'token_in', type: 'address', internalType: 'address' }, + { name: 'token_out', type: 'address', internalType: 'address' }, + { name: 'rate', type: 'uint32', internalType: 'uint32' }, + { name: 'protocol_id', type: 'int24', internalType: 'int24' }, + { name: 'pool_address', type: 'address', internalType: 'address' }, + { name: 'swap_type', type: 'uint8', internalType: 'enum SwapType' }, + { name: 'extra_data', type: 'bytes', internalType: 'bytes' }, + ], + }, + ], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'payable', + }, +] as const; diff --git a/src/connectors/fibrous/fibrous.config.ts b/src/connectors/fibrous/fibrous.config.ts new file mode 100644 index 0000000000..88bc1c847f --- /dev/null +++ b/src/connectors/fibrous/fibrous.config.ts @@ -0,0 +1,49 @@ +import { getAvailableEthereumNetworks } from '../../chains/ethereum/ethereum.utils'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; + +export namespace FibrousConfig { + // Supported networks for Fibrous + // See https://docs.fibrous.finance + export const chain = 'ethereum'; + // Only include networks that are supported by Fibrous and available in Gateway. + // Fibrous also supports Starknet, which is not an EVM chain and therefore not + // reachable through Gateway's Ethereum chain implementation. + export const networks = getAvailableEthereumNetworks().filter((network) => + ['base', 'hyperevm', 'monad'].includes(network), + ); + export type Network = string; + + // Supported trading types + export const tradingTypes = ['router'] as const; + + export interface RootConfig { + // Global configuration + apiKey: string; + slippagePct: number; + } + + export const config: RootConfig = { + apiKey: ConfigManagerV2.getInstance().get('fibrous.apiKey'), + slippagePct: ConfigManagerV2.getInstance().get('fibrous.slippagePct'), + }; + + // Maps a Gateway network name to the Fibrous API network path segment + const networkMap: Record = { + base: 'base', + hyperevm: 'hyperevm', + monad: 'monad', + }; + + export const getApiNetwork = (network: string): string => { + const apiNetwork = networkMap[network]; + if (!apiNetwork) { + throw new Error( + `Fibrous API network not found for network: ${network}. Supported networks: ${Object.keys(networkMap).join(', ')}`, + ); + } + return apiNetwork; + }; + + // Fibrous exposes a V2 API for EVM networks. V1 remains available for Starknet only. + export const getApiEndpoint = (network: string): string => `https://api.fibrous.finance/${getApiNetwork(network)}/v2`; +} diff --git a/src/connectors/fibrous/fibrous.routes.ts b/src/connectors/fibrous/fibrous.routes.ts new file mode 100644 index 0000000000..958f946079 --- /dev/null +++ b/src/connectors/fibrous/fibrous.routes.ts @@ -0,0 +1,10 @@ +import { FastifyInstance } from 'fastify'; + +import fibrousRouterRoutes from './router-routes'; + +export const registerFibrousRoutes = async (fastify: FastifyInstance): Promise => { + // Register router routes (3 endpoints) + await fastify.register(fibrousRouterRoutes, { + prefix: '/connectors/fibrous/router', + }); +}; diff --git a/src/connectors/fibrous/fibrous.ts b/src/connectors/fibrous/fibrous.ts new file mode 100644 index 0000000000..54c825b634 --- /dev/null +++ b/src/connectors/fibrous/fibrous.ts @@ -0,0 +1,323 @@ +import { BigNumber, utils } from 'ethers'; + +import { Ethereum } from '../../chains/ethereum/ethereum'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; +import { createHttpClient, HttpClient, HttpClientError } from '../../services/http-client'; +import { logger } from '../../services/logger'; + +import { FibrousRouterABI } from './fibrous.abi'; +import { FibrousConfig } from './fibrous.config'; + +/** Address Fibrous uses to represent a chain's native coin (ETH, HYPE, MON). */ +export const FIBROUS_NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000'; + +/** + * Gas limit used when the Fibrous API does not return a usable estimate. + * Aggregator routes can span several pools, so this is deliberately generous. + */ +const DEFAULT_GAS_LIMIT = '500000'; + +/** Scale used for integer percentage math on wei values (parts per million). */ +const PPM = 1_000_000; + +/** + * Fraction of the trade size used as the "spot rate" reference when measuring + * price impact. A hundredth of the trade is small enough to barely move the + * pools while still being large enough to route on most pairs. + */ +const PRICE_IMPACT_REFERENCE_DIVISOR = 100; + +export interface FibrousToken { + address: string; + name: string; + decimals: number; + price: number; + extra_data: any; +} + +export interface FibrousRouteParams { + tokenInAddress: string; + tokenOutAddress: string; + /** Amount of tokenIn, in the token's smallest unit. */ + amount: string; + slippagePct?: number; +} + +export interface FibrousRouteSuccess { + success: true; + routeId: string; + inputToken: FibrousToken; + inputAmount: string; + outputToken: FibrousToken; + outputAmount: string; + estimatedGasUsed: string; + estimatedGasUsedInUsd: number; + route: Array<{ percent: string; swaps: any[][] }>; + time: number; + meta?: { apiVersion: string; timestamp: string }; +} + +/** `route` tuple of the router's `swap` entrypoint, as returned by the API. */ +export interface FibrousEvmRouteParam { + token_in: string; + token_out: string; + amount_in: string; + amount_out: string; + min_received: string; + destination: string; + swap_type: number; +} + +/** One element of the `swap_parameters` tuple array. */ +export interface FibrousEvmSwapParam { + token_in: string; + token_out: string; + rate: string | number; + protocol_id: string | number; + pool_address: string; + swap_type: number; + extra_data: string; +} + +export interface FibrousCalldataResponse { + routeId: string; + route: FibrousEvmRouteParam; + swap_parameters: FibrousEvmSwapParam[]; + router_address: string; + meta?: { apiVersion: string; timestamp: string }; +} + +/** An unsigned transaction ready to be sent by an ethers Wallet. */ +export interface FibrousSwapTransaction { + to: string; + data: string; + value: string; +} + +export class Fibrous { + private static instances: Map = new Map(); + private client: HttpClient; + private routerInterface: utils.Interface; + private _slippagePct: number; + + private constructor( + private network: string, + private chainId: number, + ) { + this._slippagePct = FibrousConfig.config.slippagePct; + + const apiKey = FibrousConfig.config.apiKey; + const headers: Record = { 'Content-Type': 'application/json' }; + // An API key is optional: it unlocks integrator fees and higher rate limits. + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + this.client = createHttpClient({ + baseURL: FibrousConfig.getApiEndpoint(network), + timeout: ConfigManagerV2.getInstance().get('fibrous.requestTimeout') || 30000, + headers, + enableLogging: ConfigManagerV2.getInstance().get('fibrous.enableLogging'), + }); + + this.routerInterface = new utils.Interface(FibrousRouterABI as any); + } + + public static async getInstance(network: string): Promise { + if (!Fibrous.instances.has(network)) { + const ethereum = await Ethereum.getInstance(network); + Fibrous.instances.set(network, new Fibrous(network, ethereum.chainId)); + } + return Fibrous.instances.get(network)!; + } + + /** + * Fetches the best exact-input route from the Fibrous API. + */ + public async getRoute(params: FibrousRouteParams): Promise { + const queryParams: Record = { + amount: params.amount, + tokenInAddress: params.tokenInAddress, + tokenOutAddress: params.tokenOutAddress, + slippage: params.slippagePct ?? this._slippagePct, + }; + + try { + const response = await this.client.get('/route', { + params: queryParams, + }); + + const data = response.data as any; + if (!data?.success) { + throw new Error(`Fibrous API Error: ${data?.errorMessage || 'no route found'}`); + } + + return data as FibrousRouteSuccess; + } catch (error: any) { + throw this.wrapApiError(error); + } + } + + /** + * Turns a route into router calldata parameters. + * + * @param slippagePct Slippage tolerance as a percentage (1 = 1%) + * @param destination Address that receives the output token + */ + public async getCalldata( + route: FibrousRouteSuccess, + slippagePct: number, + destination: string, + ): Promise { + try { + const response = await this.client.post('/calldata', { + route, + slippage: slippagePct, + destination, + }); + + const data = response.data; + if (!data?.route || !data?.swap_parameters || !data?.router_address) { + throw new Error('Fibrous API Error: calldata response is missing route, swap parameters or router address'); + } + + return data; + } catch (error: any) { + throw this.wrapApiError(error); + } + } + + /** + * ABI-encodes the router's `swap` call from a calldata response. + * + * The Fibrous API returns the swap arguments as structured objects rather + * than pre-encoded calldata, so encoding happens here. + */ + public buildSwapTransaction(calldata: FibrousCalldataResponse): FibrousSwapTransaction { + const { route, swap_parameters: swapParameters, router_address: routerAddress } = calldata; + + const routeTuple = [ + route.token_in, + route.token_out, + BigNumber.from(route.amount_in), + BigNumber.from(route.amount_out), + BigNumber.from(route.min_received), + route.destination, + route.swap_type, + ]; + + const swapTuples = swapParameters.map((swap) => [ + swap.token_in, + swap.token_out, + BigNumber.from(swap.rate), + BigNumber.from(swap.protocol_id), + swap.pool_address, + swap.swap_type, + swap.extra_data, + ]); + + const data = this.routerInterface.encodeFunctionData('swap', [routeTuple, swapTuples]); + + // Native-coin swaps must forward the input amount as transaction value. + const isNativeInput = route.token_in.toLowerCase() === FIBROUS_NATIVE_TOKEN_ADDRESS; + const value = isNativeInput ? BigNumber.from(route.amount_in).toString() : '0'; + + return { to: utils.getAddress(routerAddress), data, value }; + } + + /** + * Measures price impact for a route. + * + * The Fibrous API does not report price impact, and the USD reference prices + * it returns per token are too noisy to derive it from (the implied figure + * does not even grow with trade size). Instead the execution rate is compared + * against the rate of a much smaller trade on the same pair, which is the + * marginal "spot" rate. + * + * Returns 0 when the reference trade is too small to route. + */ + public async getPriceImpactPct(route: FibrousRouteSuccess): Promise { + const executedIn = BigNumber.from(route.inputAmount); + const executedOut = BigNumber.from(route.outputAmount); + const referenceAmount = executedIn.div(PRICE_IMPACT_REFERENCE_DIVISOR); + + if (referenceAmount.isZero() || executedIn.isZero() || executedOut.isZero()) { + return 0; + } + + let reference: FibrousRouteSuccess; + try { + reference = await this.getRoute({ + tokenInAddress: route.inputToken.address, + tokenOutAddress: route.outputToken.address, + amount: referenceAmount.toString(), + }); + } catch (error: any) { + logger.debug(`Fibrous price impact reference route unavailable: ${error.message}`); + return 0; + } + + const referenceIn = BigNumber.from(reference.inputAmount); + const referenceOut = BigNumber.from(reference.outputAmount); + if (referenceIn.isZero() || referenceOut.isZero()) { + return 0; + } + + // impact = 1 - (executedOut / executedIn) / (referenceOut / referenceIn) + const numerator = executedOut.mul(referenceIn); + const denominator = executedIn.mul(referenceOut); + if (denominator.isZero()) { + return 0; + } + + const impactPpm = BigNumber.from(PPM).sub(numerator.mul(PPM).div(denominator)); + const impactPct = impactPpm.toNumber() / (PPM / 100); + + // Negative values mean the reference route was simply priced differently; + // treat those as no measurable impact. + return Math.max(0, impactPct); + } + + /** + * Gas limit to use for a route. + * + * Note that the API's `estimatedGasUsed` field cannot be used here: it is + * zero on Base and reports the fee in native wei (not a gas unit count) on + * HyperEVM and Monad, so feeding it to `gasLimit` would produce an + * unusable transaction. Callers can override this with `maxGas`. + */ + public getGasEstimate(_route: FibrousRouteSuccess): string { + return DEFAULT_GAS_LIMIT; + } + + public get slippagePct(): number { + return this._slippagePct; + } + + public get networkChainId(): number { + return this.chainId; + } + + /** Converts a smallest-unit amount into a human-readable decimal string. */ + public formatTokenAmount(amount: string, decimals: number): string { + return utils.formatUnits(BigNumber.from(amount), decimals); + } + + /** Converts a decimal amount into the token's smallest unit. */ + public parseTokenAmount(amount: number, decimals: number): string { + return utils.parseUnits(amount.toFixed(decimals), decimals).toString(); + } + + /** Normalizes Fibrous API errors into a single readable Error. */ + private wrapApiError(error: any): Error { + if (error instanceof HttpClientError && error.response?.data) { + const data = error.response.data; + logger.error(`Fibrous API Error Response: ${JSON.stringify(data)}`); + return new Error(`Fibrous API Error: ${data.message || data.errorMessage || JSON.stringify(data)}`); + } + if (error?.message?.startsWith('Fibrous API Error')) { + return error; + } + return error; + } +} diff --git a/src/connectors/fibrous/router-routes/executeQuote.ts b/src/connectors/fibrous/router-routes/executeQuote.ts new file mode 100644 index 0000000000..4a7e1a1704 --- /dev/null +++ b/src/connectors/fibrous/router-routes/executeQuote.ts @@ -0,0 +1,119 @@ +import { BigNumber } from 'ethers'; +import { FastifyPluginAsync } from 'fastify'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { ExecuteQuoteRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { quoteCache } from '../../../services/quote-cache'; +import { Fibrous, FIBROUS_NATIVE_TOKEN_ADDRESS } from '../fibrous'; +import { FibrousExecuteQuoteRequest } from '../schemas'; + +async function executeQuote( + walletAddress: string, + network: string, + quoteId: string, + gasPrice?: string, + maxGas?: number, +): Promise { + // Retrieve cached quote from global cache + const quote = quoteCache.get(quoteId); + if (!quote) { + throw httpErrors.badRequest('Quote not found or expired'); + } + + const ethereum = await Ethereum.getInstance(network); + const wallet = await ethereum.getWallet(walletAddress); + const fibrous = await Fibrous.getInstance(network); + + logger.info(`Executing Fibrous quote ${quoteId} on ${network}`); + + const { tokenIn, tokenOut } = quote; + + // ERC-20 inputs must have approved the router before the swap can settle. + const isNativeInput = tokenIn.address.toLowerCase() === FIBROUS_NATIVE_TOKEN_ADDRESS; + if (!isNativeInput) { + const tokenContract = ethereum.getContract(tokenIn.address, wallet); + const allowance = await ethereum.getERC20Allowance(tokenContract, wallet, quote.routerAddress, tokenIn.decimals); + + const requiredAllowance = BigNumber.from(quote.amountIn); + if (BigNumber.from(allowance.value).lt(requiredAllowance)) { + throw httpErrors.badRequest( + `Insufficient allowance for ${tokenIn.symbol}. Required: ${fibrous.formatTokenAmount(quote.amountIn, tokenIn.decimals)}, Current: ${fibrous.formatTokenAmount(allowance.value.toString(), tokenIn.decimals)}`, + ); + } + } + + // Execute the swap transaction + const txData = { + to: quote.to, + data: quote.data, + value: BigNumber.from(quote.value), + gasLimit: maxGas || parseInt(quote.gasEstimate), + ...(gasPrice && { gasPrice: BigNumber.from(gasPrice) }), + }; + + const txResponse = await wallet.sendTransaction(txData); + const txReceipt = await ethereum.handleTransactionExecution(txResponse); + + const result = ethereum.handleExecuteQuoteTransactionConfirmation( + txReceipt, + tokenIn.address, + tokenOut.address, + quote.expectedAmountIn, + quote.expectedAmountOut, + ); + + // Handle different transaction states + if (result.status === -1) { + throw httpErrors.internalServerError('Transaction failed on-chain'); + } + + if (result.status === 0) { + logger.info(`Transaction ${result.signature || 'pending'} is still pending`); + return result; + } + + // Transaction confirmed (status === 1) + logger.info( + `Swap executed successfully: ${quote.expectedAmountIn.toFixed(4)} ${tokenIn.symbol} -> ${quote.expectedAmountOut.toFixed(4)} ${tokenOut.symbol}`, + ); + + // Remove quote from cache only after successful execution (confirmed) + quoteCache.delete(quoteId); + + return result; +} + +export { executeQuote }; + +export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: ExecuteQuoteRequestType; + Reply: SwapExecuteResponseType; + }>( + '/execute-quote', + { + schema: { + description: 'Execute a previously fetched quote from Fibrous', + tags: ['/connector/fibrous'], + body: FibrousExecuteQuoteRequest, + response: { 200: SwapExecuteResponse }, + }, + }, + async (request) => { + try { + const { walletAddress, network, quoteId, gasPrice, maxGas } = + request.body as typeof FibrousExecuteQuoteRequest._type; + + return await executeQuote(walletAddress, network, quoteId, gasPrice, maxGas); + } catch (e) { + if (e.statusCode) throw e; + logger.error('Error executing Fibrous quote:', e); + throw httpErrors.internalServerError(e.message || 'Internal server error'); + } + }, + ); +}; + +export default executeQuoteRoute; diff --git a/src/connectors/fibrous/router-routes/executeSwap.ts b/src/connectors/fibrous/router-routes/executeSwap.ts new file mode 100644 index 0000000000..c960843d4e --- /dev/null +++ b/src/connectors/fibrous/router-routes/executeSwap.ts @@ -0,0 +1,95 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { FibrousConfig } from '../fibrous.config'; +import { FibrousExecuteSwapRequest } from '../schemas'; + +import { executeQuote } from './executeQuote'; +import { quoteSwap } from './quoteSwap'; + +async function executeSwap( + walletAddress: string, + network: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = FibrousConfig.config.slippagePct, + gasPrice?: string, + maxGas?: number, + approximateIfNoExactOut: boolean = true, +): Promise { + // Step 1: Get a fresh firm quote using the quoteSwap function + const quoteResult = await quoteSwap( + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + false, // indicativePrice = false for firm quote + walletAddress, // destination for the swap output + approximateIfNoExactOut, + ); + + // Step 2: Execute the quote immediately using executeQuote function + const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId, gasPrice, maxGas); + + return executeResult; +} + +export { executeSwap }; + +export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: ExecuteSwapRequestType; + Reply: SwapExecuteResponseType; + }>( + '/execute-swap', + { + schema: { + description: 'Quote and execute a token swap on Fibrous in one step', + tags: ['/connector/fibrous'], + body: FibrousExecuteSwapRequest, + response: { 200: SwapExecuteResponse }, + }, + }, + async (request) => { + try { + const { + walletAddress, + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + gasPrice, + maxGas, + approximateIfNoExactOut, + } = request.body as typeof FibrousExecuteSwapRequest._type; + + return await executeSwap( + walletAddress, + network, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + slippagePct, + gasPrice, + maxGas, + approximateIfNoExactOut ?? true, + ); + } catch (e) { + if (e.statusCode) throw e; + logger.error('Error executing Fibrous swap:', e); + throw httpErrors.internalServerError(e.message || 'Internal server error'); + } + }, + ); +}; + +export default executeSwapRoute; diff --git a/src/connectors/fibrous/router-routes/index.ts b/src/connectors/fibrous/router-routes/index.ts new file mode 100644 index 0000000000..a94c38241c --- /dev/null +++ b/src/connectors/fibrous/router-routes/index.ts @@ -0,0 +1,13 @@ +import { FastifyPluginAsync } from 'fastify'; + +import executeQuoteRoute from './executeQuote'; +import executeSwapRoute from './executeSwap'; +import quoteSwapRoute from './quoteSwap'; + +export const fibrousRouterRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(quoteSwapRoute); + await fastify.register(executeQuoteRoute); + await fastify.register(executeSwapRoute); +}; + +export default fibrousRouterRoutes; diff --git a/src/connectors/fibrous/router-routes/quoteSwap.ts b/src/connectors/fibrous/router-routes/quoteSwap.ts new file mode 100644 index 0000000000..f83ab19b61 --- /dev/null +++ b/src/connectors/fibrous/router-routes/quoteSwap.ts @@ -0,0 +1,235 @@ +import { Static } from '@sinclair/typebox'; +import { FastifyPluginAsync } from 'fastify'; +import { v4 as uuidv4 } from 'uuid'; + +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { QuoteSwapRequestType } from '../../../schemas/router-schema'; +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { quoteCache } from '../../../services/quote-cache'; +import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { approximateBuyViaSellLeg } from '../../router-utils'; +import { Fibrous, FibrousRouteSuccess } from '../fibrous'; +import { FibrousConfig } from '../fibrous.config'; +import { FibrousQuoteSwapRequest, FibrousQuoteSwapResponse } from '../schemas'; + +/** Firm quotes are executable for this long before they must be refreshed. */ +const QUOTE_TTL_MS = 30000; + +async function quoteSwap( + network: string, + baseToken: string, + quoteToken: string, + amount: number, + side: 'BUY' | 'SELL', + slippagePct: number = FibrousConfig.config.slippagePct, + indicativePrice: boolean = true, + takerAddress?: string, + approximateIfNoExactOut: boolean = true, +): Promise> { + const ethereum = await Ethereum.getInstance(network); + const fibrous = await Fibrous.getInstance(network); + + // Resolve token symbols/addresses to token objects from local token list + const baseTokenInfo = await ethereum.getToken(baseToken); + const quoteTokenInfo = await ethereum.getToken(quoteToken); + + if (!baseTokenInfo || !quoteTokenInfo) { + throw httpErrors.badRequest(sanitizeErrorMessage('Token not found: {}', !baseTokenInfo ? baseToken : quoteToken)); + } + + // Determine input/output based on side. The requested amount is always + // denominated in the base token. + const tokenInInfo = side === 'SELL' ? baseTokenInfo : quoteTokenInfo; + const tokenOutInfo = side === 'SELL' ? quoteTokenInfo : baseTokenInfo; + const baseAmount = fibrous.parseTokenAmount(amount, baseTokenInfo.decimals); + + // Destination for the swap output; falls back to an example address for quotes + const walletAddress = takerAddress || (await Ethereum.getWalletAddressExample()); + + logger.info( + `Getting ${indicativePrice ? 'indicative price' : 'firm quote'} for ${amount} ${baseToken} ${side === 'SELL' ? '->' : '<-'} ${quoteToken} on Fibrous/${network}`, + ); + + // The Fibrous EVM API is ExactIn-only, so a SELL maps directly onto the API + // while a BUY is served via the shared sell-leg approximation. + let route: FibrousRouteSuccess; + let isApproximation = false; + + if (side === 'SELL') { + route = await fibrous.getRoute({ + tokenInAddress: tokenInInfo.address, + tokenOutAddress: tokenOutInfo.address, + amount: baseAmount, + slippagePct, + }); + } else { + if (!approximateIfNoExactOut) { + throw httpErrors.badRequest( + 'Fibrous supports ExactIn only: BUY orders require approximateIfNoExactOut=true (approximated via a sell-leg quote) or use side=SELL', + ); + } + const approximated = await approximateBuyViaSellLeg({ + getExactInQuote: async (inputTokenInfo, outputTokenInfo, legAmountRaw) => { + const legRoute = await fibrous.getRoute({ + tokenInAddress: inputTokenInfo.address, + tokenOutAddress: outputTokenInfo.address, + amount: legAmountRaw, + slippagePct, + }); + return { inAmount: legRoute.inputAmount, outAmount: legRoute.outputAmount, quote: legRoute }; + }, + baseToken: baseTokenInfo, + quoteToken: quoteTokenInfo, + baseAmount: amount, + }); + route = approximated.forwardQuote.quote; + isApproximation = true; + } + + const estimatedAmountIn = parseFloat(fibrous.formatTokenAmount(route.inputAmount, tokenInInfo.decimals)); + const estimatedAmountOut = parseFloat(fibrous.formatTokenAmount(route.outputAmount, tokenOutInfo.decimals)); + + // SELL and approximated BUY are both ExactIn: input fixed, slippage applies to output + const minAmountOut = estimatedAmountOut * (1 - slippagePct / 100); + const maxAmountIn = estimatedAmountIn; + + // Price is always expressed as quote token per base token + const price = side === 'SELL' ? estimatedAmountOut / estimatedAmountIn : estimatedAmountIn / estimatedAmountOut; + + const priceImpactPct = await fibrous.getPriceImpactPct(route); + const gasEstimate = fibrous.getGasEstimate(route); + + // Indicative prices are pure price discovery: no calldata, no cached quote. + if (indicativePrice) { + return { + quoteId: 'indicative-price', + tokenIn: tokenInInfo.address, + tokenOut: tokenOutInfo.address, + amountIn: estimatedAmountIn, + amountOut: estimatedAmountOut, + price, + priceImpactPct, + minAmountOut, + maxAmountIn, + gasEstimate, + routeId: route.routeId, + route: route.route, + ...(isApproximation ? { approximation: true } : {}), + }; + } + + // Firm quote: build the router calldata so it can be executed as-is later. + const calldata = await fibrous.getCalldata(route, slippagePct, walletAddress); + const transaction = fibrous.buildSwapTransaction(calldata); + + const quoteId = uuidv4(); + const expirationTime = Date.now() + QUOTE_TTL_MS; + + quoteCache.set( + quoteId, + { + network, + to: transaction.to, + data: transaction.data, + value: transaction.value, + gasEstimate, + routerAddress: transaction.to, + tokenIn: tokenInInfo, + tokenOut: tokenOutInfo, + amountIn: route.inputAmount, + amountOut: route.outputAmount, + minReceived: calldata.route.min_received, + expectedAmountIn: estimatedAmountIn, + expectedAmountOut: estimatedAmountOut, + }, + { + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + walletAddress, + }, + ); + + return { + quoteId, + tokenIn: tokenInInfo.address, + tokenOut: tokenOutInfo.address, + amountIn: estimatedAmountIn, + amountOut: estimatedAmountOut, + price, + priceImpactPct, + minAmountOut, + maxAmountIn, + expirationTime, + gasEstimate, + routeId: route.routeId, + route: route.route, + ...(isApproximation ? { approximation: true } : {}), + allowanceTarget: transaction.to, + to: transaction.to, + data: transaction.data, + value: transaction.value, + }; +} + +export { quoteSwap }; + +export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: QuoteSwapRequestType; + Reply: Static; + }>( + '/quote-swap', + { + schema: { + description: + 'Get a swap quote from Fibrous. Use indicativePrice=true for price discovery only, or false/undefined for executable quotes', + tags: ['/connector/fibrous'], + querystring: FibrousQuoteSwapRequest, + response: { 200: FibrousQuoteSwapResponse }, + }, + }, + async (request) => { + try { + const { + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + indicativePrice, + takerAddress, + approximateIfNoExactOut, + } = request.query as typeof FibrousQuoteSwapRequest._type; + + return await quoteSwap( + network, + baseToken, + quoteToken, + amount, + side as 'BUY' | 'SELL', + slippagePct, + indicativePrice ?? true, + takerAddress, + approximateIfNoExactOut ?? true, + ); + } catch (e: any) { + if (e.statusCode) throw e; + logger.error('Error getting Fibrous quote:', e.message || e); + + if (e.message?.includes('Fibrous API Error')) { + throw httpErrors.badRequest(e.message); + } + + throw httpErrors.internalServerError(e.message || 'Failed to get quote'); + } + }, + ); +}; + +export default quoteSwapRoute; diff --git a/src/connectors/fibrous/schemas.ts b/src/connectors/fibrous/schemas.ts new file mode 100644 index 0000000000..a599c190e6 --- /dev/null +++ b/src/connectors/fibrous/schemas.ts @@ -0,0 +1,238 @@ +import { Type } from '@sinclair/typebox'; + +import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; + +import { FibrousConfig } from './fibrous.config'; + +// Get chain config for defaults +const ethereumChainConfig = getEthereumChainConfig(); + +// Constants for examples +const BASE_TOKEN = 'WETH'; +const QUOTE_TOKEN = 'USDC'; +const SWAP_AMOUNT = 1; + +// Fibrous-specific quote-swap request (superset of base QuoteSwapRequest) +export const FibrousQuoteSwapRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'The EVM network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...FibrousConfig.networks], + }), + ), + baseToken: Type.String({ + description: 'First token in the trading pair', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'Second token in the trading pair', + examples: [QUOTE_TOKEN], + }), + amount: Type.Number({ + description: 'Amount of base token to trade', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + description: + 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + examples: [1], + }), + ), + indicativePrice: Type.Optional( + Type.Boolean({ + description: + 'If true, returns indicative pricing only (no commitment). If false, returns firm quote ready for execution', + default: true, + }), + ), + takerAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will receive the output token (optional for quotes)', + }), + ), + approximateIfNoExactOut: Type.Optional( + Type.Boolean({ + description: + 'For BUY orders: Fibrous is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', + default: true, + }), + ), +}); + +// Fibrous-specific quote-swap response (superset of base QuoteSwapResponse) +export const FibrousQuoteSwapResponse = Type.Object({ + quoteId: Type.String({ + description: 'Unique identifier for this quote', + }), + tokenIn: Type.String({ + description: 'Address of the token being swapped from', + }), + tokenOut: Type.String({ + description: 'Address of the token being swapped to', + }), + amountIn: Type.Number({ + description: 'Amount of tokenIn to be swapped', + }), + amountOut: Type.Number({ + description: 'Expected amount of tokenOut to receive', + }), + price: Type.Number({ + description: 'Exchange rate between tokenIn and tokenOut', + }), + priceImpactPct: Type.Number({ + description: 'Estimated price impact percentage (0-100)', + }), + minAmountOut: Type.Number({ + description: 'Minimum amount of tokenOut that will be accepted', + }), + maxAmountIn: Type.Number({ + description: 'Maximum amount of tokenIn that will be spent', + }), + approximation: Type.Optional( + Type.Boolean({ + description: + 'True when a BUY was approximated via a sell-leg ExactIn quote (Fibrous is ExactIn-only); amountOut is an estimate', + }), + ), + expirationTime: Type.Optional( + Type.Number({ + description: 'Unix timestamp when this quote expires (only for firm quotes)', + }), + ), + gasEstimate: Type.String({ + description: 'Estimated gas required for the swap', + }), + routeId: Type.Optional( + Type.String({ + description: 'Fibrous route identifier for this quote', + }), + ), + route: Type.Optional( + Type.Array(Type.Any(), { + description: 'Liquidity sources and pool splits used for this route', + }), + ), + allowanceTarget: Type.Optional( + Type.String({ + description: 'Router contract address that needs token approval', + }), + ), + to: Type.Optional( + Type.String({ + description: 'Contract address to send transaction to', + }), + ), + data: Type.Optional( + Type.String({ + description: 'Encoded transaction data', + }), + ), + value: Type.Optional( + Type.String({ + description: 'Native coin value to send with transaction', + }), + ), +}); + +// Fibrous-specific execute-quote request (superset of base ExecuteQuoteRequest) +export const FibrousExecuteQuoteRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will execute the swap', + default: ethereumChainConfig.defaultWallet, + }), + ), + network: Type.Optional( + Type.String({ + description: 'The blockchain network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...FibrousConfig.networks], + examples: [...FibrousConfig.networks], + }), + ), + quoteId: Type.String({ + description: 'ID of the quote to execute', + examples: ['123e4567-e89b-12d3-a456-426614174000'], + }), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [1000000], + }), + ), +}); + +// Fibrous-specific execute-swap request (superset of base ExecuteSwapRequest) +export const FibrousExecuteSwapRequest = Type.Object({ + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address that will execute the swap', + default: ethereumChainConfig.defaultWallet, + examples: [ethereumChainConfig.defaultWallet], + }), + ), + network: Type.Optional( + Type.String({ + description: 'The blockchain network to use', + default: ethereumChainConfig.defaultNetwork, + enum: [...FibrousConfig.networks], + examples: [...FibrousConfig.networks], + }), + ), + baseToken: Type.String({ + description: 'Token to determine swap direction', + examples: [BASE_TOKEN], + }), + quoteToken: Type.String({ + description: 'The other token in the pair', + examples: [QUOTE_TOKEN], + }), + amount: Type.Number({ + description: 'Amount of base token to trade', + examples: [SWAP_AMOUNT], + }), + side: Type.String({ + description: + 'Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token', + enum: ['BUY', 'SELL'], + }), + slippagePct: Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: 'Maximum acceptable slippage percentage', + examples: [1], + }), + ), + approximateIfNoExactOut: Type.Optional( + Type.Boolean({ + description: + 'For BUY orders: Fibrous is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', + default: true, + }), + ), + gasPrice: Type.Optional( + Type.String({ + description: 'Gas price in wei for the transaction', + }), + ), + maxGas: Type.Optional( + Type.Number({ + description: 'Maximum gas limit for the transaction', + examples: [500000], + }), + ), +}); diff --git a/src/templates/chains/ethereum/hyperevm.yml b/src/templates/chains/ethereum/hyperevm.yml new file mode 100644 index 0000000000..428464c2e8 --- /dev/null +++ b/src/templates/chains/ethereum/hyperevm.yml @@ -0,0 +1,12 @@ +chainID: 999 +nodeURL: https://rpc.hyperliquid.xyz/evm +nativeCurrencySymbol: HYPE +geckoId: hyperevm +transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) +swapProvider: fibrous/router + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +baseFee: +baseFeeMultiplier: 1.2 +priorityFee: 0.001 diff --git a/src/templates/chains/ethereum/monad.yml b/src/templates/chains/ethereum/monad.yml new file mode 100644 index 0000000000..bcec92e631 --- /dev/null +++ b/src/templates/chains/ethereum/monad.yml @@ -0,0 +1,12 @@ +chainID: 143 +nodeURL: https://rpc.monad.xyz +nativeCurrencySymbol: MON +geckoId: monad +transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) +swapProvider: fibrous/router + +# EIP-1559 gas parameters (in GWEI) +# If not set, will fetch from Etherscan API (if etherscanAPIKey is set in ethereum.yml) or network RPC +baseFee: +baseFeeMultiplier: 1.2 +priorityFee: 0.001 diff --git a/src/templates/connectors/fibrous.yml b/src/templates/connectors/fibrous.yml new file mode 100644 index 0000000000..39f926d527 --- /dev/null +++ b/src/templates/connectors/fibrous.yml @@ -0,0 +1,10 @@ +# Configuration for Fibrous DEX Aggregator + +# Allowed slippage for swap transactions (as a percentage) +# Default: 1 (1%) +slippagePct: 1 + +# API key for the Fibrous API (optional) +# Unlocks integrator features and higher rate limits. +# Get your API key from https://portal.fibrous.finance +apiKey: '' diff --git a/src/templates/namespace/ethereum-network-schema.json b/src/templates/namespace/ethereum-network-schema.json index c5fa12d2d2..cc674bfc92 100644 --- a/src/templates/namespace/ethereum-network-schema.json +++ b/src/templates/namespace/ethereum-network-schema.json @@ -15,6 +15,7 @@ "enum": [ "uniswap/router", "0x/router", + "fibrous/router", "pancakeswap/router", "uniswap/amm", "uniswap/clmm", diff --git a/src/templates/namespace/fibrous-schema.json b/src/templates/namespace/fibrous-schema.json new file mode 100644 index 0000000000..42586d0e94 --- /dev/null +++ b/src/templates/namespace/fibrous-schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "slippagePct": { + "type": "number", + "description": "Allowed slippage for swap transactions (as a percentage)", + "minimum": 0, + "maximum": 100 + }, + "apiKey": { + "type": "string", + "description": "API key for the Fibrous API (optional)" + }, + "requestTimeout": { + "type": "integer", + "description": "Request timeout for API calls (ms)", + "minimum": 0 + }, + "enableLogging": { + "type": "boolean", + "description": "Enable/disable request logging" + } + }, + "required": ["slippagePct"], + "additionalProperties": false +} diff --git a/src/templates/root.yml b/src/templates/root.yml index 05c0702ba0..ad9e343a41 100644 --- a/src/templates/root.yml +++ b/src/templates/root.yml @@ -62,6 +62,14 @@ configurations: configurationPath: chains/ethereum/unichain.yml schemaPath: ethereum-network-schema.json + $namespace ethereum-hyperevm: + configurationPath: chains/ethereum/hyperevm.yml + schemaPath: ethereum-network-schema.json + + $namespace ethereum-monad: + configurationPath: chains/ethereum/monad.yml + schemaPath: ethereum-network-schema.json + # Solana networks $namespace solana-mainnet-beta: configurationPath: chains/solana/mainnet-beta.yml @@ -96,6 +104,10 @@ configurations: configurationPath: connectors/0x.yml schemaPath: 0x-schema.json + $namespace fibrous: + configurationPath: connectors/fibrous.yml + schemaPath: fibrous-schema.json + $namespace pancakeswap: configurationPath: connectors/pancakeswap.yml schemaPath: pancakeswap-schema.json diff --git a/src/templates/tokens/ethereum/hyperevm.json b/src/templates/tokens/ethereum/hyperevm.json new file mode 100644 index 0000000000..3ded52327f --- /dev/null +++ b/src/templates/tokens/ethereum/hyperevm.json @@ -0,0 +1,44 @@ +[ + { + "chainId": 999, + "name": "Wrapped HYPE", + "symbol": "WHYPE", + "address": "0x5555555555555555555555555555555555555555", + "decimals": 18 + }, + { + "chainId": 999, + "name": "USDC", + "symbol": "USDC", + "address": "0xb88339CB7199b77E23DB6E890353E22632Ba630f", + "decimals": 6 + }, + { + "chainId": 999, + "name": "USDeOFT", + "symbol": "USDe", + "address": "0x5d3a1Ff2b6BAb83b63cd9AD0787074081a52ef34", + "decimals": 18 + }, + { + "chainId": 999, + "name": "Unit Bitcoin", + "symbol": "UBTC", + "address": "0x9FDBdA0A5e284c32744D2f17Ee5c74B284993463", + "decimals": 8 + }, + { + "chainId": 999, + "name": "Unit Ethereum", + "symbol": "UETH", + "address": "0xBe6727B535545C67d5cAa73dEa54865B92CF7907", + "decimals": 18 + }, + { + "chainId": 999, + "name": "Staked HYPE", + "symbol": "stHYPE", + "address": "0xfFaa4a3D97fE9107Cef8a3F48c069F577Ff76cC1", + "decimals": 18 + } +] diff --git a/src/templates/tokens/ethereum/monad.json b/src/templates/tokens/ethereum/monad.json new file mode 100644 index 0000000000..8e56e31a2b --- /dev/null +++ b/src/templates/tokens/ethereum/monad.json @@ -0,0 +1,44 @@ +[ + { + "chainId": 143, + "name": "Wrapped MON", + "symbol": "WMON", + "address": "0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A", + "decimals": 18 + }, + { + "chainId": 143, + "name": "USDC", + "symbol": "USDC", + "address": "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", + "decimals": 6 + }, + { + "chainId": 143, + "name": "USDT0", + "symbol": "USDT0", + "address": "0xe7cd86e13AC4309349F30B3435a9d337750fC82D", + "decimals": 6 + }, + { + "chainId": 143, + "name": "Wrapped Ether", + "symbol": "WETH", + "address": "0xEE8c0E9f1BFFb4Eb878d8f15f368A02a35481242", + "decimals": 18 + }, + { + "chainId": 143, + "name": "Wrapped BTC", + "symbol": "WBTC", + "address": "0x0555E30da8f98308EdB960aa94C0Db47230d2B9c", + "decimals": 8 + }, + { + "chainId": 143, + "name": "ShMonad", + "symbol": "shMON", + "address": "0x1B68626dCa36c7fE922fD2d55E4f631d962dE19c", + "decimals": 18 + } +] diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts index c3ca61c938..6bab12a093 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -6,6 +6,7 @@ import { getEthereumChainConfig, getEthereumNetworkConfig } from '../../chains/e import { getSolanaChainConfig, getSolanaNetworkConfig } from '../../chains/solana/solana.config'; import { executeSwap as zeroXRouterExecuteSwap } from '../../connectors/0x/router-routes/executeSwap'; import { executeSwap as dflowRouterExecuteSwap } from '../../connectors/dflow/router-routes/executeSwap'; +import { executeSwap as fibrousRouterExecuteSwap } from '../../connectors/fibrous/router-routes/executeSwap'; import { executeSwap as jupiterRouterExecuteSwap } from '../../connectors/jupiter/router-routes/executeSwap'; import { executeSwap as meteoraClmmExecuteSwap } from '../../connectors/meteora/clmm-routes/executeSwap'; import { executeSwap as okxRouterExecuteSwap } from '../../connectors/okx/router-routes/executeSwap'; @@ -304,6 +305,8 @@ async function executeEthereumSwap( return await pancakeswapClmmExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === '0x/router') { return await zeroXRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + } else if (providerKey === 'fibrous/router') { + return await fibrousRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); } throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts index df15993b2a..436e592b29 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -6,6 +6,7 @@ import { getEthereumNetworkConfig } from '../../chains/ethereum/ethereum.config' import { getSolanaNetworkConfig } from '../../chains/solana/solana.config'; import { quoteSwap as zeroXRouterQuoteSwap } from '../../connectors/0x/router-routes/quoteSwap'; import { quoteSwap as dflowRouterQuoteSwap } from '../../connectors/dflow/router-routes/quoteSwap'; +import { quoteSwap as fibrousRouterQuoteSwap } from '../../connectors/fibrous/router-routes/quoteSwap'; import { quoteSwap as jupiterRouterQuoteSwap } from '../../connectors/jupiter/router-routes/quoteSwap'; import { quoteSwap as meteoraClmmQuoteSwap } from '../../connectors/meteora/clmm-routes/quoteSwap'; import { quoteSwap as okxRouterQuoteSwap } from '../../connectors/okx/router-routes/quoteSwap'; @@ -234,6 +235,8 @@ async function getEthereumQuoteSwap( return await pancakeswapClmmQuoteSwap(network, poolAddress!, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === '0x/router') { return await zeroXRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct || 1); + } else if (providerKey === 'fibrous/router') { + return await fibrousRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); } throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); diff --git a/test/connectors/fibrous/fibrous.test.ts b/test/connectors/fibrous/fibrous.test.ts new file mode 100644 index 0000000000..e9fc3a3883 --- /dev/null +++ b/test/connectors/fibrous/fibrous.test.ts @@ -0,0 +1,167 @@ +import { utils } from 'ethers'; + +import { Ethereum } from '../../../src/chains/ethereum/ethereum'; +import { Fibrous } from '../../../src/connectors/fibrous/fibrous'; +import { FibrousRouterABI } from '../../../src/connectors/fibrous/fibrous.abi'; +import { createHttpClient } from '../../../src/services/http-client'; +import { + buildCalldataResponse, + buildRouteResponse, + FIBROUS_ROUTER_ADDRESS, + mockUSDC, + mockWETH, +} from '../../mocks/fibrous/route.mock'; + +jest.mock('../../../src/chains/ethereum/ethereum'); +jest.mock('../../../src/services/http-client', () => ({ + ...jest.requireActual('../../../src/services/http-client'), + createHttpClient: jest.fn(), +})); + +const DESTINATION = '0x1234567890123456789012345678901234567890'; + +const mockClient = { + get: jest.fn(), + post: jest.fn(), +}; + +const getFibrous = async (network = 'base') => { + // Reset the connector singleton so each test gets a fresh client + (Fibrous as any).instances.clear(); + (createHttpClient as jest.Mock).mockReturnValue(mockClient); + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ chainId: 8453 }); + return Fibrous.getInstance(network); +}; + +describe('Fibrous connector', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getRoute', () => { + it('requests an exact-input route and returns it', async () => { + const fibrous = await getFibrous(); + const route = buildRouteResponse('1000000000000000000', '1888000000'); + mockClient.get.mockResolvedValue({ data: route }); + + const result = await fibrous.getRoute({ + tokenInAddress: mockWETH.address, + tokenOutAddress: mockUSDC.address, + amount: '1000000000000000000', + slippagePct: 0.5, + }); + + expect(result.outputAmount).toBe('1888000000'); + expect(mockClient.get).toHaveBeenCalledWith('/route', { + params: { + amount: '1000000000000000000', + tokenInAddress: mockWETH.address, + tokenOutAddress: mockUSDC.address, + slippage: 0.5, + }, + }); + }); + + it('throws a readable error when the API reports no route', async () => { + const fibrous = await getFibrous(); + mockClient.get.mockResolvedValue({ data: { success: false, errorMessage: 'No result found' } }); + + await expect( + fibrous.getRoute({ + tokenInAddress: mockWETH.address, + tokenOutAddress: mockUSDC.address, + amount: '1', + }), + ).rejects.toThrow('Fibrous API Error: No result found'); + }); + }); + + describe('buildSwapTransaction', () => { + it('ABI-encodes the router swap call from the calldata response', async () => { + const fibrous = await getFibrous(); + const calldata = buildCalldataResponse('1000000000000000000', '1888000000', '1878560000', DESTINATION); + + const tx = fibrous.buildSwapTransaction(calldata as any); + + expect(tx.to).toBe(utils.getAddress(FIBROUS_ROUTER_ADDRESS)); + expect(tx.value).toBe('0'); + + // Decoding the calldata must reproduce the API's swap arguments + const iface = new utils.Interface(FibrousRouterABI as any); + const decoded = iface.decodeFunctionData('swap', tx.data); + expect(decoded.route.token_in).toBe(utils.getAddress(mockWETH.address)); + expect(decoded.route.amount_in.toString()).toBe('1000000000000000000'); + expect(decoded.route.min_received.toString()).toBe('1878560000'); + expect(decoded.route.destination).toBe(utils.getAddress(DESTINATION)); + expect(decoded.swap_parameters).toHaveLength(1); + expect(decoded.swap_parameters[0].protocol_id).toBe(63); + }); + + it('forwards the input amount as transaction value for native-coin swaps', async () => { + const fibrous = await getFibrous(); + const calldata = buildCalldataResponse( + '1000000000000000000', + '1888000000', + '1878560000', + DESTINATION, + '0x0000000000000000000000000000000000000000', + ); + + const tx = fibrous.buildSwapTransaction(calldata as any); + + expect(tx.value).toBe('1000000000000000000'); + }); + }); + + describe('getPriceImpactPct', () => { + it('compares the execution rate against a smaller reference trade', async () => { + const fibrous = await getFibrous(); + // Executed: 100 WETH -> 188000 USDC (rate 1880) + const executed = buildRouteResponse('100000000000000000000', '188000000000'); + // Reference: 1 WETH -> 1888 USDC (rate 1888), so impact is ~0.4237% + mockClient.get.mockResolvedValue({ data: buildRouteResponse('1000000000000000000', '1888000000') }); + + const impact = await fibrous.getPriceImpactPct(executed as any); + + expect(impact).toBeCloseTo(0.4237, 3); + }); + + it('returns 0 when the reference trade cannot be routed', async () => { + const fibrous = await getFibrous(); + const executed = buildRouteResponse('100000000000000000000', '188000000000'); + mockClient.get.mockResolvedValue({ data: { success: false, errorMessage: 'No result found' } }); + + await expect(fibrous.getPriceImpactPct(executed as any)).resolves.toBe(0); + }); + + it('returns 0 rather than a negative impact', async () => { + const fibrous = await getFibrous(); + // Executed rate is better than the reference rate + const executed = buildRouteResponse('100000000000000000000', '190000000000'); + mockClient.get.mockResolvedValue({ data: buildRouteResponse('1000000000000000000', '1888000000') }); + + await expect(fibrous.getPriceImpactPct(executed as any)).resolves.toBe(0); + }); + }); + + describe('getGasEstimate', () => { + it('ignores the API gas field, which is not denominated in gas units', async () => { + const fibrous = await getFibrous(); + // HyperEVM and Monad report a fee in native wei here, not a gas unit count + const route = { ...buildRouteResponse('1', '1'), estimatedGasUsed: '169452577555850000' }; + + expect(fibrous.getGasEstimate(route as any)).toBe('500000'); + }); + }); + + describe('amount conversion', () => { + it('round-trips between decimal and smallest-unit amounts', async () => { + const fibrous = await getFibrous(); + + expect(fibrous.parseTokenAmount(1.5, 18)).toBe('1500000000000000000'); + expect(fibrous.parseTokenAmount(150, 6)).toBe('150000000'); + expect(fibrous.formatTokenAmount('1500000000000000000', 18)).toBe('1.5'); + expect(fibrous.formatTokenAmount('150000000', 6)).toBe('150.0'); + }); + }); +}); diff --git a/test/connectors/fibrous/router-routes/executeQuote.test.ts b/test/connectors/fibrous/router-routes/executeQuote.test.ts new file mode 100644 index 0000000000..5123559077 --- /dev/null +++ b/test/connectors/fibrous/router-routes/executeQuote.test.ts @@ -0,0 +1,208 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Fibrous } from '../../../../src/connectors/fibrous/fibrous'; +import { quoteCache } from '../../../../src/services/quote-cache'; +import { FIBROUS_ROUTER_ADDRESS, mockUSDC, mockWETH } from '../../../mocks/fibrous/route.mock'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/fibrous/fibrous'); + +const WALLET = '0x1234567890123456789012345678901234567890'; +const TX_HASH = '0xabc123'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { executeQuoteRoute } = await import('../../../../src/connectors/fibrous/router-routes/executeQuote'); + await server.register(executeQuoteRoute); + return server; +}; + +const cachedQuote = (overrides: Record = {}) => ({ + network: 'base', + to: FIBROUS_ROUTER_ADDRESS, + data: '0xdeadbeef', + value: '0', + gasEstimate: '500000', + routerAddress: FIBROUS_ROUTER_ADDRESS, + tokenIn: mockWETH, + tokenOut: mockUSDC, + amountIn: '1000000000000000000', + amountOut: '1888000000', + minReceived: '1869120000', + expectedAmountIn: 1, + expectedAmountOut: 1888, + ...overrides, +}); + +const mockEthereumInstance = (overrides: Record = {}) => { + const sendTransaction = jest.fn().mockResolvedValue({ hash: TX_HASH }); + const instance = { + getWallet: jest.fn().mockResolvedValue({ sendTransaction }), + getContract: jest.fn().mockReturnValue({}), + getERC20Allowance: jest.fn().mockResolvedValue({ value: BigNumber.from('10000000000000000000'), decimals: 18 }), + handleTransactionExecution: jest.fn().mockResolvedValue({ status: 1, transactionHash: TX_HASH }), + handleExecuteQuoteTransactionConfirmation: jest.fn().mockReturnValue({ signature: TX_HASH, status: 1 }), + ...overrides, + }; + (Ethereum.getInstance as jest.Mock).mockResolvedValue(instance); + return { instance, sendTransaction }; +}; + +describe('POST /execute-quote (fibrous)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + quoteCache.clear(); + (Fibrous.getInstance as jest.Mock).mockResolvedValue({ + formatTokenAmount: jest.fn((amount: string, decimals: number) => (Number(amount) / 10 ** decimals).toString()), + }); + }); + + it('sends the cached calldata and clears the quote on success', async () => { + const { sendTransaction } = mockEthereumInstance(); + quoteCache.set('quote-1', cachedQuote()); + + const response = await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-1' }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ signature: TX_HASH, status: 1 }); + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: FIBROUS_ROUTER_ADDRESS, + data: '0xdeadbeef', + gasLimit: 500000, + }), + ); + // Confirmed quotes are single-use + expect(quoteCache.get('quote-1')).toBeNull(); + }); + + it('rejects an unknown or expired quote', async () => { + mockEthereumInstance(); + + const response = await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'missing' }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('Quote not found'); + }); + + it('rejects when the router allowance is insufficient', async () => { + mockEthereumInstance({ + getERC20Allowance: jest.fn().mockResolvedValue({ value: BigNumber.from('1'), decimals: 18 }), + }); + quoteCache.set('quote-2', cachedQuote()); + + const response = await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-2' }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('Insufficient allowance for WETH'); + }); + + it('checks allowance against the router address', async () => { + const { instance } = mockEthereumInstance(); + quoteCache.set('quote-3', cachedQuote()); + + await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-3' }, + }); + + expect(instance.getERC20Allowance).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + FIBROUS_ROUTER_ADDRESS, + mockWETH.decimals, + ); + }); + + it('skips the allowance check for native-coin inputs', async () => { + const { instance } = mockEthereumInstance(); + quoteCache.set( + 'quote-4', + cachedQuote({ + tokenIn: { ...mockWETH, symbol: 'ETH', address: '0x0000000000000000000000000000000000000000' }, + value: '1000000000000000000', + }), + ); + + const response = await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-4' }, + }); + + expect(response.statusCode).toBe(200); + expect(instance.getERC20Allowance).not.toHaveBeenCalled(); + }); + + it('honours a maxGas override', async () => { + const { sendTransaction } = mockEthereumInstance(); + quoteCache.set('quote-5', cachedQuote()); + + await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-5', maxGas: 900000 }, + }); + + expect(sendTransaction).toHaveBeenCalledWith(expect.objectContaining({ gasLimit: 900000 })); + }); + + it('keeps the quote cached while the transaction is pending', async () => { + mockEthereumInstance({ + handleExecuteQuoteTransactionConfirmation: jest.fn().mockReturnValue({ signature: TX_HASH, status: 0 }), + }); + quoteCache.set('quote-6', cachedQuote()); + + const response = await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-6' }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).status).toBe(0); + expect(quoteCache.get('quote-6')).not.toBeNull(); + }); + + it('reports an on-chain failure as a server error', async () => { + mockEthereumInstance({ + handleExecuteQuoteTransactionConfirmation: jest.fn().mockReturnValue({ signature: TX_HASH, status: -1 }), + }); + quoteCache.set('quote-7', cachedQuote()); + + const response = await server.inject({ + method: 'POST', + url: '/execute-quote', + payload: { walletAddress: WALLET, network: 'base', quoteId: 'quote-7' }, + }); + + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body).message).toContain('Transaction failed on-chain'); + }); +}); diff --git a/test/connectors/fibrous/router-routes/executeSwap.test.ts b/test/connectors/fibrous/router-routes/executeSwap.test.ts new file mode 100644 index 0000000000..f2470db045 --- /dev/null +++ b/test/connectors/fibrous/router-routes/executeSwap.test.ts @@ -0,0 +1,108 @@ +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/connectors/fibrous/router-routes/quoteSwap', () => ({ + quoteSwap: jest.fn(), +})); +jest.mock('../../../../src/connectors/fibrous/router-routes/executeQuote', () => ({ + executeQuote: jest.fn(), +})); + +const WALLET = '0x1234567890123456789012345678901234567890'; +const TX_HASH = '0xabc123'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { executeSwapRoute } = await import('../../../../src/connectors/fibrous/router-routes/executeSwap'); + await server.register(executeSwapRoute); + return server; +}; + +describe('POST /execute-swap (fibrous)', () => { + let server: any; + let quoteSwap: jest.Mock; + let executeQuote: jest.Mock; + + beforeAll(async () => { + server = await buildApp(); + ({ quoteSwap } = require('../../../../src/connectors/fibrous/router-routes/quoteSwap')); + ({ executeQuote } = require('../../../../src/connectors/fibrous/router-routes/executeQuote')); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requests a firm quote and executes it in one step', async () => { + quoteSwap.mockResolvedValue({ quoteId: 'quote-1' }); + executeQuote.mockResolvedValue({ signature: TX_HASH, status: 1 }); + + const response = await server.inject({ + method: 'POST', + url: '/execute-swap', + payload: { + walletAddress: WALLET, + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: 1, + side: 'SELL', + slippagePct: 1, + }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ signature: TX_HASH, status: 1 }); + + // indicativePrice must be false, and the wallet is the swap destination + expect(quoteSwap).toHaveBeenCalledWith('base', 'WETH', 'USDC', 1, 'SELL', 1, false, WALLET, true); + expect(executeQuote).toHaveBeenCalledWith(WALLET, 'base', 'quote-1', undefined, undefined); + }); + + it('passes gas overrides through to execution', async () => { + quoteSwap.mockResolvedValue({ quoteId: 'quote-2' }); + executeQuote.mockResolvedValue({ signature: TX_HASH, status: 1 }); + + await server.inject({ + method: 'POST', + url: '/execute-swap', + payload: { + walletAddress: WALLET, + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: 1, + side: 'BUY', + gasPrice: '1000000000', + maxGas: 900000, + }, + }); + + expect(executeQuote).toHaveBeenCalledWith(WALLET, 'base', 'quote-2', '1000000000', 900000); + }); + + it('propagates quote failures', async () => { + quoteSwap.mockRejectedValue(new Error('Fibrous API Error: No result found')); + + const response = await server.inject({ + method: 'POST', + url: '/execute-swap', + payload: { + walletAddress: WALLET, + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: 1, + side: 'SELL', + }, + }); + + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body).message).toContain('No result found'); + expect(executeQuote).not.toHaveBeenCalled(); + }); +}); diff --git a/test/connectors/fibrous/router-routes/quoteSwap.test.ts b/test/connectors/fibrous/router-routes/quoteSwap.test.ts new file mode 100644 index 0000000000..8957d6984f --- /dev/null +++ b/test/connectors/fibrous/router-routes/quoteSwap.test.ts @@ -0,0 +1,264 @@ +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Fibrous } from '../../../../src/connectors/fibrous/fibrous'; +import { buildCalldataResponse, buildRouteResponse, mockUSDC, mockWETH } from '../../../mocks/fibrous/route.mock'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/fibrous/fibrous'); + +const WALLET = '0x1234567890123456789012345678901234567890'; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { quoteSwapRoute } = await import('../../../../src/connectors/fibrous/router-routes/quoteSwap'); + await server.register(quoteSwapRoute); + return server; +}; + +const mockEthereum = (baseToken: any = mockWETH, quoteToken: any = mockUSDC) => { + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn().mockResolvedValueOnce(baseToken).mockResolvedValueOnce(quoteToken), + }); + (Ethereum.getWalletAddressExample as jest.Mock).mockResolvedValue(WALLET); +}; + +/** Builds a Fibrous instance mock with the real amount-conversion behaviour. */ +const mockFibrous = (overrides: Record) => { + const instance = { + parseTokenAmount: jest.fn((amount: number, decimals: number) => + BigInt(Math.round(amount * 10 ** decimals)).toString(), + ), + formatTokenAmount: jest.fn((amount: string, decimals: number) => (Number(amount) / 10 ** decimals).toString()), + getPriceImpactPct: jest.fn().mockResolvedValue(0.05), + getGasEstimate: jest.fn().mockReturnValue('500000'), + getRoute: jest.fn(), + getRouteForExactOut: jest.fn(), + getCalldata: jest.fn(), + buildSwapTransaction: jest.fn(), + ...overrides, + }; + (Fibrous.getInstance as jest.Mock).mockResolvedValue(instance); + return instance; +}; + +describe('GET /quote-swap (fibrous)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns an indicative price without building calldata', async () => { + mockEthereum(); + const fibrous = mockFibrous({ + getRoute: jest.fn().mockResolvedValue(buildRouteResponse('1000000000000000000', '1888000000')), + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + slippagePct: '1', + indicativePrice: 'true', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toHaveProperty('quoteId', 'indicative-price'); + expect(body).toHaveProperty('amountIn', 1); + expect(body).toHaveProperty('amountOut', 1888); + expect(body).toHaveProperty('price', 1888); + expect(body).toHaveProperty('gasEstimate', '500000'); + expect(body).not.toHaveProperty('expirationTime'); + expect(body).not.toHaveProperty('data'); + expect(fibrous.getCalldata).not.toHaveBeenCalled(); + }); + + it('defaults to an indicative price when indicativePrice is omitted', async () => { + mockEthereum(); + const fibrous = mockFibrous({ + getRoute: jest.fn().mockResolvedValue(buildRouteResponse('1000000000000000000', '1888000000')), + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { network: 'base', baseToken: 'WETH', quoteToken: 'USDC', amount: '1', side: 'SELL' }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toHaveProperty('quoteId', 'indicative-price'); + expect(fibrous.getCalldata).not.toHaveBeenCalled(); + }); + + it('returns an executable quote with calldata for SELL side', async () => { + mockEthereum(); + const fibrous = mockFibrous({ + getRoute: jest.fn().mockResolvedValue(buildRouteResponse('1000000000000000000', '1888000000')), + getCalldata: jest + .fn() + .mockResolvedValue(buildCalldataResponse('1000000000000000000', '1888000000', '1869120000', WALLET)), + buildSwapTransaction: jest.fn().mockReturnValue({ to: '0xRouter', data: '0xdeadbeef', value: '0' }), + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + slippagePct: '1', + indicativePrice: 'false', + takerAddress: WALLET, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.quoteId).not.toBe('indicative-price'); + expect(body).toHaveProperty('expirationTime'); + expect(body).toHaveProperty('data', '0xdeadbeef'); + expect(body).toHaveProperty('to', '0xRouter'); + expect(body).toHaveProperty('allowanceTarget', '0xRouter'); + expect(body.minAmountOut).toBeCloseTo(1888 * 0.99, 6); + expect(fibrous.getCalldata).toHaveBeenCalledWith(expect.anything(), 1, WALLET); + }); + + it('approximates BUY side via a sell-leg quote and flags it', async () => { + mockEthereum(); + const fibrous = mockFibrous({ + getRoute: jest + .fn() + // sell leg: 1 WETH -> 1888 USDC, which sets the input for the forward leg + .mockResolvedValueOnce(buildRouteResponse('1000000000000000000', '1888000000', mockWETH, mockUSDC)) + // forward leg: 1888 USDC -> ~1 WETH, the executable quote + .mockResolvedValueOnce(buildRouteResponse('1888000000', '999000000000000000', mockUSDC, mockWETH)), + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: '1', + side: 'BUY', + slippagePct: '1', + indicativePrice: 'true', + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + // Sell leg first (base -> quote), then the forward leg (quote -> base) + expect(fibrous.getRoute).toHaveBeenCalledTimes(2); + expect(fibrous.getRoute.mock.calls[0][0]).toMatchObject({ + tokenInAddress: mockWETH.address, + tokenOutAddress: mockUSDC.address, + amount: '1000000000000000000', + }); + expect(fibrous.getRoute.mock.calls[1][0]).toMatchObject({ + tokenInAddress: mockUSDC.address, + tokenOutAddress: mockWETH.address, + amount: '1888000000', + }); + + expect(body).toHaveProperty('tokenIn', mockUSDC.address); + expect(body).toHaveProperty('tokenOut', mockWETH.address); + expect(body).toHaveProperty('amountIn', 1888); + // amountOut is an estimate, not exactly the requested amount + expect(body.amountOut).toBeCloseTo(0.999, 6); + expect(body).toHaveProperty('approximation', true); + // An approximated BUY is really ExactIn: the input is fixed + expect(body.maxAmountIn).toBe(1888); + expect(body.minAmountOut).toBeCloseTo(0.999 * 0.99, 6); + }); + + it('rejects BUY when approximation is disabled', async () => { + mockEthereum(); + const fibrous = mockFibrous({}); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { + network: 'base', + baseToken: 'WETH', + quoteToken: 'USDC', + amount: '1', + side: 'BUY', + approximateIfNoExactOut: 'false', + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('ExactIn only'); + expect(fibrous.getRoute).not.toHaveBeenCalled(); + }); + + it('does not flag a SELL as approximated', async () => { + mockEthereum(); + mockFibrous({ + getRoute: jest.fn().mockResolvedValue(buildRouteResponse('1000000000000000000', '1888000000')), + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { network: 'base', baseToken: 'WETH', quoteToken: 'USDC', amount: '1', side: 'SELL' }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).not.toHaveProperty('approximation'); + }); + + it('returns 400 when a token cannot be resolved', async () => { + mockEthereum(null, mockUSDC); + mockFibrous({}); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { network: 'base', baseToken: 'NOPE', quoteToken: 'USDC', amount: '1', side: 'SELL' }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body)).toHaveProperty('error'); + }); + + it('surfaces Fibrous API errors as 400', async () => { + mockEthereum(); + mockFibrous({ + getRoute: jest.fn().mockRejectedValue(new Error('Fibrous API Error: No result found')), + }); + + const response = await server.inject({ + method: 'GET', + url: '/quote-swap', + query: { network: 'base', baseToken: 'WETH', quoteToken: 'USDC', amount: '1', side: 'SELL' }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain('No result found'); + }); +}); diff --git a/test/mocks/fibrous/route.mock.ts b/test/mocks/fibrous/route.mock.ts new file mode 100644 index 0000000000..a375c553b9 --- /dev/null +++ b/test/mocks/fibrous/route.mock.ts @@ -0,0 +1,100 @@ +export const mockWETH = { + chainId: 8453, + symbol: 'WETH', + name: 'Wrapped Ether', + address: '0x4200000000000000000000000000000000000006', + decimals: 18, +}; + +export const mockUSDC = { + chainId: 8453, + symbol: 'USDC', + name: 'USD Coin', + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, +}; + +export const FIBROUS_ROUTER_ADDRESS = '0x274602a953847d807231d2370072f5f4e4594b44'; + +/** Builds a `/route` style response for a given input/output pair. */ +export const buildRouteResponse = ( + inputAmount: string, + outputAmount: string, + inputToken = mockWETH, + outputToken = mockUSDC, +) => ({ + success: true as const, + routeId: 'c0ffee00-dead-beef-cafe-000000000001', + inputToken: { + address: inputToken.address, + name: inputToken.name, + decimals: inputToken.decimals, + price: 1888.4, + extra_data: null, + }, + inputAmount, + outputToken: { + address: outputToken.address, + name: outputToken.name, + decimals: outputToken.decimals, + price: 0.9999, + extra_data: null, + }, + outputAmount, + estimatedGasUsed: '0', + estimatedGasUsedInUsd: 0, + route: [ + { + percent: '100%', + swaps: [ + [ + { + protocol: 63, + poolName: 'MockPool', + poolAddress: '0xaD4aDf89BC3A02B7B90D875fa3aB2091FF189452', + fromTokenAddress: inputToken.address, + toTokenAddress: outputToken.address, + percent: '100%', + extraData: null, + }, + ], + ], + }, + ], + time: 0.2, + meta: { apiVersion: '2.0', timestamp: '2026-07-28T12:00:00.000Z' }, +}); + +/** Builds a `/calldata` style response matching the router `swap` signature. */ +export const buildCalldataResponse = ( + amountIn: string, + amountOut: string, + minReceived: string, + destination: string, + tokenIn = mockWETH.address, + tokenOut = mockUSDC.address, +) => ({ + routeId: 'c0ffee00-dead-beef-cafe-000000000001', + route: { + token_in: tokenIn, + token_out: tokenOut, + amount_in: amountIn, + amount_out: amountOut, + min_received: minReceived, + destination, + swap_type: 2, + }, + swap_parameters: [ + { + token_in: tokenIn, + token_out: tokenOut, + rate: '1000000', + protocol_id: '63', + pool_address: '0xaD4aDf89BC3A02B7B90D875fa3aB2091FF189452', + swap_type: 2, + extra_data: '0x0000000000000000000000000000000000000000000000000000000000000000', + }, + ], + router_address: FIBROUS_ROUTER_ADDRESS, + meta: { apiVersion: '2.0', timestamp: '2026-07-28T12:00:00.000Z' }, +});