import Mpc from '../../../mpc' import { sdkLogger } from '../../../logger' import { waitForEvmTxConfirmation } from '../../../internal/waitForEvmTxConfirmation' import { stripStalePlanningNonceIfJsonObject } from '../../../internal/stripStalePlanningNonce' import { pollLifiStatusUntilTerminal, } from './lifiStatusPoll' import type { LifiPollStatusOptions, LifiRoutesRequest, LifiRoutesResponse, LifiQuoteRequest, LifiQuoteResponse, LifiStatusRequest, LifiStatusResponse, LifiStatusRawResponse, LifiStep, LifiStepTransactionRequest, LifiStepTransactionResponse, LifiTradeAssetOptions, LifiTradeAssetParams, LifiTradeAssetResult, } from '../../../shared/types' const LOG_PREFIX = '[LiFi]' /** * Internal sentinel error used to avoid emitting duplicate `failed` progress events. */ class LifiReportedError extends Error { constructor(message: string) { super(message) this.name = 'LifiReportedError' } } export interface LifiOptions extends LifiTradeAssetOptions {} export interface ILiFi { getRoutes(data: LifiRoutesRequest): Promise getQuote(data: LifiQuoteRequest): Promise getStatus(data: LifiStatusRequest): Promise getRouteStep( data: LifiStepTransactionRequest, ): Promise tradeAsset( params: LifiTradeAssetParams, options?: LifiTradeAssetOptions, ): Promise pollStatus( request: Pick, options?: LifiPollStatusOptions & { onUpdate?: (raw: LifiStatusRawResponse) => boolean | void }, ): Promise } /** Normalize LiFi chain key / id to CAIP-2 `eip155:*` for the Portal provider. */ export function normalizeChainToNetwork(chain: string): string { const c = chain.trim() if (c.startsWith('eip155:')) { return c } if (/^0x[0-9a-fA-F]+$/.test(c)) { return `eip155:${parseInt(c, 16)}` } if (/^\d+$/.test(c)) { return `eip155:${c}` } return c } export function resolveLifiStepNetworkCaip2(step: LifiStep): string { const fromTx = step.transactionRequest?.chainId if (fromTx) { return normalizeChainToNetwork(fromTx) } return normalizeChainToNetwork(step.action.fromChainId) } export function parseLifiTransactionRequestFromStep( step: LifiStep, ): Record { const tr = step.transactionRequest if (!tr) { throw new Error( `${LOG_PREFIX} Step has no transactionRequest; call getRouteStep first.`, ) } const base: Record = { data: tr.data, to: tr.to, from: tr.from, value: tr.value ?? '0x0', gas: tr.gasLimit, gasPrice: tr.gasPrice, } if (tr.maxFeePerGas != null) { base.maxFeePerGas = tr.maxFeePerGas } if (tr.maxPriorityFeePerGas != null) { base.maxPriorityFeePerGas = tr.maxPriorityFeePerGas } return base } export function statusBridgeFromStepTool( tool: string | undefined, ): LifiStatusRequest['bridge'] { if (!tool) { return undefined } return tool.toLowerCase() as LifiStatusRequest['bridge'] } function mergeLifiOptions( instance: LifiTradeAssetOptions | undefined, perCall: LifiTradeAssetOptions | undefined, ): LifiTradeAssetOptions { return { ...instance, ...perCall, } } export default class LiFi implements ILiFi { private mpc: Mpc private readonly defaultTradeOptions: LifiTradeAssetOptions constructor({ mpc, ...tradeDefaults }: { mpc: Mpc } & LifiOptions) { this.mpc = mpc this.defaultTradeOptions = tradeDefaults } public async getRoutes(data: LifiRoutesRequest): Promise { return this.mpc?.getLifiRoutes(data) } public async getQuote(data: LifiQuoteRequest): Promise { return this.mpc?.getLifiQuote(data) } public async getStatus(data: LifiStatusRequest): Promise { return this.mpc?.getLifiStatus(data) } public async getRouteStep( data: LifiStepTransactionRequest, ): Promise { return this.mpc?.getLifiRouteStep(data) } public async pollStatus( request: Pick< LifiStatusRequest, 'txHash' | 'fromChain' | 'toChain' | 'bridge' >, options?: LifiPollStatusOptions & { onUpdate?: (raw: LifiStatusRawResponse) => boolean | void }, ): Promise { const { everyMs = 10_000, initialDelayMs = 10_000, timeoutMs = 600_000, maxConsecutiveErrors = 10, backoff = { factor: 1.5, maxIntervalMs: 15_000 }, onUpdate, } = options ?? {} return pollLifiStatusUntilTerminal({ getStatus: (r) => this.mpc.getLifiStatus(r), request, onUpdate, everyMs, initialDelayMs, timeoutMs, maxConsecutiveErrors, backoff, }) } public async tradeAsset( params: LifiTradeAssetParams, options?: LifiTradeAssetOptions, ): Promise { try { const o = mergeLifiOptions(this.defaultTradeOptions, options) const signAndSend = o.signAndSendTransaction if (!signAndSend) { throw new Error(`${LOG_PREFIX} tradeAsset requires signAndSendTransaction`) } const effectiveWait = async ( txHash: string, network: string, ): Promise => { if (o.waitForConfirmation) { return o.waitForConfirmation(txHash, network) } if (o.evmRequestFn) { if (!network.startsWith('eip155:')) { sdkLogger.warn( `${LOG_PREFIX} Cannot verify confirmation for non-EVM network ${network}. ` + 'Provide waitForConfirmation or use Portal.waitForConfirmation.', ) return Promise.resolve(false) } return waitForEvmTxConfirmation(txHash, network, o.evmRequestFn, { pollIntervalMs: o.evmPollerOptions?.pollIntervalMs, timeoutMs: o.evmPollerOptions?.timeoutMs, // Intentionally differs from Yield's optimistic `resolve_false` timeout // handling: LiFi must abort here so we do not continue into bridge-status // polling after source-chain confirmation has already timed out. onTimeout: 'throw', }) } throw new Error( `${LOG_PREFIX} tradeAsset requires waitForConfirmation or evmRequestFn fallback.`, ) } params.onProgress?.('fetching_routes') const routesRes = await this.getRoutes({ fromChainId: normalizeChainToNetwork(params.fromChain), toChainId: normalizeChainToNetwork(params.toChain), fromTokenAddress: params.fromToken, toTokenAddress: params.toToken, fromAmount: params.amount, fromAddress: params.fromAddress, toAddress: params.toAddress, options: params.routeOptions, }) if (routesRes.error) { throw new Error(`${LOG_PREFIX} getRoutes: ${routesRes.error}`) } const routes = routesRes.data?.rawResponse.routes ?? [] if (routes.length === 0) { throw new Error(`${LOG_PREFIX} No routes returned from LiFi`) } const idx = params.routeIndex ?? 0 const route = routes[idx] if (!route) { throw new Error(`${LOG_PREFIX} no route available at index ${idx}`) } params.onProgress?.('route_selected', { route, routeIndex: idx }) const hashes: string[] = [] const stepsOut: LifiStep[] = [] const totalSteps = route.steps.length for (let stepIndex = 0; stepIndex < route.steps.length; stepIndex++) { const step = route.steps[stepIndex] // Validate step exists (defensive check for sparse arrays) if (!step) { throw new Error( `${LOG_PREFIX} Step at index ${stepIndex} is undefined or null. This indicates a malformed route response.`, ) } params.onProgress?.('preparing_step', { route, routeIndex: idx, step, stepIndex, totalSteps, }) const stepRes = await this.getRouteStep(step) if (stepRes.error) { throw new Error(`${LOG_PREFIX} getRouteStep: ${stepRes.error}`) } const populated = stepRes.data?.rawResponse if (!populated) { throw new Error(`${LOG_PREFIX} getRouteStep returned no step data`) } const network = resolveLifiStepNetworkCaip2(populated) let tx = parseLifiTransactionRequestFromStep(populated) if (network.startsWith('eip155:')) { const stripped = stripStalePlanningNonceIfJsonObject(tx) // Validate stripped result is still an object if ( typeof stripped !== 'object' || stripped === null || Array.isArray(stripped) ) { throw new Error( `${LOG_PREFIX} stripStalePlanningNonce returned invalid type: ${typeof stripped}`, ) } tx = stripped as Record } params.onProgress?.('signing', { route, routeIndex: idx, step: populated, stepIndex, totalSteps, transaction: tx, }) const txHash = await signAndSend(tx, network) if (typeof txHash !== 'string' || txHash.trim().length === 0) { const msg = `Invalid transaction hash returned from signAndSendTransaction at step ${stepIndex} for network ${network}.` params.onProgress?.('failed', { errorMessage: msg, txHash, routeIndex: idx, stepIndex, totalSteps, step: populated, }) throw new LifiReportedError(`${LOG_PREFIX} ${msg}`) } hashes.push(txHash) params.onProgress?.('submitted', { route, routeIndex: idx, step: populated, stepIndex, totalSteps, txHash, }) params.onProgress?.('confirming', { route, routeIndex: idx, step: populated, stepIndex, totalSteps, txHash, }) const waiterResult = (await effectiveWait(txHash, network)) as | boolean | void const ok = waiterResult === true if (!ok) { const msg = `${LOG_PREFIX} on-chain confirmation did not complete (waitForConfirmation did not return true) for ${txHash} on ${network}` params.onProgress?.('failed', { errorMessage: msg, txHash, routeIndex: idx, stepIndex, totalSteps, step: populated, }) throw new LifiReportedError(msg) } let lifiTxHash = txHash if (network.startsWith('eip155:') && o.evmRequestFn) { try { const userOpReceipt = await o.evmRequestFn( 'eth_getUserOperationReceipt', [txHash], network, ) if ( userOpReceipt && typeof userOpReceipt === 'object' && 'receipt' in userOpReceipt && userOpReceipt.receipt && typeof userOpReceipt.receipt === 'object' && 'transactionHash' in userOpReceipt.receipt ) { const bundleHash = ( userOpReceipt.receipt as { transactionHash?: string } ).transactionHash if (bundleHash && typeof bundleHash === 'string') { lifiTxHash = bundleHash sdkLogger.debug( `${LOG_PREFIX} Resolved AA bundle tx hash for LiFi: ${lifiTxHash}`, ) } } } catch (error) { sdkLogger.debug( `${LOG_PREFIX} Could not resolve bundle hash (likely EOA tx), using original hash`, ) } } const bridgeTool = statusBridgeFromStepTool(populated.tool) const crossLike = populated.type === 'cross' || populated.action.fromChainId !== populated.action.toChainId if (crossLike && bridgeTool) { params.onProgress?.('lifi_pending', { route, routeIndex: idx, step: populated, stepIndex, totalSteps, txHash, }) const pollOpts = params.statusPoll ?? {} const terminal = await this.pollStatus( { txHash: lifiTxHash, fromChain: params.fromChain, toChain: params.toChain, bridge: bridgeTool, }, pollOpts, ) params.onProgress?.('step_done', { route, routeIndex: idx, step: populated, stepIndex, totalSteps, txHash, lifiStatus: terminal, }) } else { params.onProgress?.('step_done', { route, routeIndex: idx, step: populated, stepIndex, totalSteps, txHash, }) } stepsOut.push(populated) } params.onProgress?.('complete', { route, routeIndex: idx }) return { hashes, steps: stepsOut, route } } catch (error: unknown) { if (!(error instanceof LifiReportedError)) { const errorMessage = error instanceof Error ? error.message : String(error) params.onProgress?.('failed', { errorMessage }) } throw error } } }