import { sdkLogger } from '@portal-hq/utils' import type { LifiPollStatusOptions, LifiQuoteRequest, LifiQuoteResponse, LifiRoutesRequest, LifiRoutesResponse, LifiStatusBridge, LifiStatusRawResponse, LifiStatusRequest, LifiStatusResponse, LifiStep, LifiStepTransactionRequest, LifiStepTransactionResponse, LifiTradeAssetOptions, LifiTradeAssetParams, LifiTradeAssetProgressData, LifiTradeAssetProgressStatus, LifiTradeAssetResult, LifiTransactionRequest, } from '../../../types' import { pollForEvmReceipt } from '../../yield/evmReceiptPoller' import { isYieldEvmNetwork } from '../../yield/yieldEvmNetwork' import type { IPortalLifiTradingApi } from './PortalLifiTradingApi' import { pollLifiStatusUntilTerminal } from './lifiStatusPoll' 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' } } /** Matches {@link LifiStatusBridge} literals for disambiguating LiFi status queries. */ const LIFI_STATUS_BRIDGE_TOOLS = new Set([ 'hop', 'cbridge', 'celercircle', 'optimism', 'polygon', 'arbitrum', 'avalanche', 'across', 'gnosis', 'omni', 'relay', 'celerim', 'symbiosis', 'thorswap', 'squid', 'allbridge', 'mayan', 'debridge', 'chainflip', ]) export function statusBridgeFromStepTool( tool: string, ): LifiStatusBridge | undefined { return LIFI_STATUS_BRIDGE_TOOLS.has(tool) ? (tool as LifiStatusBridge) : undefined } function isRecord(x: unknown): x is Record { return typeof x === 'object' && x !== null && !Array.isArray(x) } /** * Narrow `step.transactionRequest` to {@link LifiTransactionRequest} without unsafe casts. */ export function parseLifiTransactionRequestFromStep( step: LifiStep, ): LifiTransactionRequest | undefined { const tr = step.transactionRequest if (!isRecord(tr)) return undefined const to = tr.to if (typeof to !== 'string' || to.trim() === '') return undefined const dataRaw = tr.data if (dataRaw !== undefined && typeof dataRaw !== 'string') return undefined const out: LifiTransactionRequest = { to, data: typeof dataRaw === 'string' ? dataRaw : '0x', } if (typeof tr.from === 'string' && tr.from.length > 0) { out.from = tr.from } const chainId = tr.chainId if (typeof chainId === 'string') { out.chainId = chainId } else if (typeof chainId === 'number' && Number.isFinite(chainId)) { out.chainId = String(Math.trunc(chainId)) } for (const key of [ 'gasLimit', 'gasPrice', 'value', 'maxFeePerGas', 'maxPriorityFeePerGas', ] as const) { const v = tr[key] if (typeof v === 'string') { out[key] = v } } // Some providers return `gas` instead of `gasLimit`. if (out.gasLimit === undefined) { const gas = tr['gas'] if (typeof gas === 'string') { out.gasLimit = gas } } return out } export interface ILifi { getRoutes(request: LifiRoutesRequest): Promise getQuote(request: LifiQuoteRequest): Promise getStatus(request: LifiStatusRequest): Promise getRouteStep( request: LifiStepTransactionRequest, ): Promise /** * Poll LiFi transfer status until `DONE`, `FAILED`, timeout, or `onUpdate` returns `false`. */ pollStatus( request: Pick< LifiStatusRequest, 'txHash' | 'fromChain' | 'toChain' | 'bridge' >, onUpdate?: (data: LifiStatusRawResponse) => boolean | void, options?: LifiPollStatusOptions, ): Promise /** * High-level cross-chain swap via LiFi: discover a route, execute each step end-to-end * (sign, broadcast, wait for on-chain inclusion, poll LiFi transfer status until terminal). * * ## Key differences from ZeroX.tradeAsset: * - **Multi-step execution**: LiFi routes often require multiple transactions (e.g., bridge + swap). * Each step is signed, submitted, confirmed on-chain, then polled via LiFi status API until terminal. * - **Protocol-level status polling**: After on-chain confirmation, the SDK polls LiFi's indexer * to verify the cross-chain transfer completed (`DONE`) or failed (`FAILED`). * - **Richer progress events**: `fetching_routes`, `route_selected`, `preparing_step`, `signing`, * `submitted`, `confirming`, `lifi_pending`, `step_done`, `complete`, `failed`. * * ## Execution hooks (priority order): * 1. Per-call options (`signAndSendTransaction`, `waitForConfirmation`) * 2. Instance defaults (wired by Portal) * 3. Internal EVM receipt poller fallback when `evmRequestFn` is available * * @see {@link ZeroX.tradeAsset} for single-step swap flow without protocol-level polling. */ tradeAsset( params: LifiTradeAssetParams, options?: LifiTradeAssetOptions, ): Promise } export interface LifiOptions { api: IPortalLifiTradingApi signAndSendTransaction?: ( transaction: unknown, network: string, ) => Promise waitForConfirmation?: ( txHash: string, network: string, ) => Promise /** * Raw RPC function for the standalone/default EVM receipt poller fallback. */ evmRequestFn?: ( method: string, params: unknown[], network: string, ) => Promise } export class Lifi implements ILifi { private readonly api: IPortalLifiTradingApi private readonly signAndSendTransaction?: LifiOptions['signAndSendTransaction'] private readonly waitForConfirmation?: LifiOptions['waitForConfirmation'] private readonly options: LifiOptions constructor(options: LifiOptions) { this.api = options.api this.signAndSendTransaction = options.signAndSendTransaction this.waitForConfirmation = options.waitForConfirmation this.options = options } async getRoutes(request: LifiRoutesRequest) { return this.api.getRoutes(request) } async getQuote(request: LifiQuoteRequest) { return this.api.getQuote(request) } async getStatus(request: LifiStatusRequest) { return this.api.getStatus(request) } async getRouteStep(request: LifiStepTransactionRequest) { return this.api.getRouteStep(request) } async pollStatus( request: Pick< LifiStatusRequest, 'txHash' | 'fromChain' | 'toChain' | 'bridge' >, onUpdate?: (data: LifiStatusRawResponse) => boolean | void, options?: LifiPollStatusOptions, ): Promise { const merged = mergePollOptions(options, { everyMs: 10_000, initialDelayMs: 0, timeoutMs: 600_000, maxConsecutiveErrors: 10, backoff: { factor: 1.5, maxIntervalMs: 15_000 }, }) return pollLifiStatusUntilTerminal({ getStatus: (req) => this.api.getStatus(req), request, onUpdate, everyMs: merged.everyMs, initialDelayMs: merged.initialDelayMs, timeoutMs: merged.timeoutMs, maxConsecutiveErrors: merged.maxConsecutiveErrors, backoff: merged.backoff, }) } async tradeAsset( params: LifiTradeAssetParams, options?: LifiTradeAssetOptions, ): Promise { const onProgress = params.onProgress const report = ( status: LifiTradeAssetProgressStatus, data: LifiTradeAssetProgressData = {}, ) => { onProgress?.(status, data) } try { return await this.executeTradeAsset(params, options, report) } catch (error: unknown) { // Only report if the error wasn't already reported internally if (!(error instanceof LifiReportedError)) { const errorMessage = error instanceof Error ? error.message : String(error) report('failed', { errorMessage }) } throw error } } private async executeTradeAsset( params: LifiTradeAssetParams, options: LifiTradeAssetOptions | undefined, report: ( status: LifiTradeAssetProgressStatus, data?: LifiTradeAssetProgressData, ) => void, ): Promise { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransaction if (!signAndSend) { throw new Error( `${LOG_PREFIX} tradeAsset requires signAndSendTransaction (instance default or per-call option).`, ) } // Resolve waitForConfirmation: per-call override > instance-level default > internal fallback. const waitFromOptions = options?.waitForConfirmation ?? this.waitForConfirmation const evmRequestFn = options?.evmRequestFn ?? this.options.evmRequestFn const hasInternalEvmPoller = Boolean(evmRequestFn) const effectiveWaiter: | ((hash: string, network: string) => Promise) | undefined = waitFromOptions ?? (hasInternalEvmPoller && evmRequestFn ? (h: string, n: string) => { if (!isYieldEvmNetwork(n)) { sdkLogger.warn( `${LOG_PREFIX} Cannot verify confirmation for non-EVM network ${n}. ` + 'Provide waitForConfirmation or use Portal.waitForConfirmation.', ) return Promise.resolve(false) } return pollForEvmReceipt( h, n, evmRequestFn, options?.evmPollerOptions, ) } : undefined) if (!effectiveWaiter) { throw new Error( `${LOG_PREFIX} tradeAsset requires waitForConfirmation (instance default or per-call option), or evmRequestFn fallback.`, ) } sdkLogger.debug(`${LOG_PREFIX} tradeAsset: confirmation strategy`, { hasPerCallOverride: Boolean(waitFromOptions), hasEvmPoller: hasInternalEvmPoller, }) report('fetching_routes', {}) const routesRequest: LifiRoutesRequest = { fromChainId: params.fromChain, toChainId: params.toChain, fromTokenAddress: params.fromToken, toTokenAddress: params.toToken, fromAmount: params.amount, fromAddress: params.fromAddress, toAddress: params.toAddress, options: params.routeOptions, } const routesResponse = await this.api.getRoutes(routesRequest) const routes = routesResponse.data?.rawResponse?.routes ?? [] const routeIndex = params.routeIndex ?? 0 const route = routes[routeIndex] if (!route) { report('failed', { errorMessage: `No route at index ${routeIndex}` }) throw new LifiReportedError( `${LOG_PREFIX} no route available at index ${routeIndex}`, ) } const steps = route.steps ?? [] if (steps.length === 0) { report('failed', { errorMessage: 'Selected route has no steps' }) throw new LifiReportedError( `${LOG_PREFIX} selected route has no steps to execute`, ) } report('route_selected', { routeIndex, route, totalSteps: steps.length, }) const statusDefaults = mergePollOptions(params.statusPoll, { everyMs: 10_000, initialDelayMs: 10_000, timeoutMs: 600_000, maxConsecutiveErrors: 10, backoff: { factor: 1.5, maxIntervalMs: 15_000 }, }) const hashes: string[] = [] const executedSteps: LifiStep[] = [] const totalSteps = steps.length for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) { const step = steps[stepIndex] report('preparing_step', { routeIndex, route, stepIndex, totalSteps, step, }) const stepResponse = await this.api.getRouteStep(step) const rawStep = stepResponse.data?.rawResponse if (!rawStep) { const msg = stepResponse.error ?? 'getRouteStep returned no data' report('failed', { routeIndex, stepIndex, totalSteps, step, errorMessage: msg, }) throw new LifiReportedError(`${LOG_PREFIX} getRouteStep failed: ${msg}`) } executedSteps.push(rawStep) const tx = parseLifiTransactionRequestFromStep(rawStep) if (!tx) { report('failed', { routeIndex, stepIndex, totalSteps, step, errorMessage: 'Invalid or missing transactionRequest on route step', }) throw new LifiReportedError( `${LOG_PREFIX} step ${stepIndex + 1}: invalid transactionRequest (expected object with non-empty "to" and optional string "data")`, ) } const network = resolveLifiStepNetworkCaip2(rawStep, tx) const payloadToSend = this.prepareSequentialPayload( tx, network, steps.length > 1, ) report('signing', { routeIndex, stepIndex, totalSteps, route, step: rawStep, }) sdkLogger.debug(`${LOG_PREFIX} tradeAsset: signAndSend`, { stepIndex, network, }) const txHash = await signAndSend(payloadToSend, network) if (typeof txHash !== 'string' || txHash.trim().length === 0) { const msg = `Invalid transaction hash returned from signAndSendTransaction at step ${stepIndex} for network ${network}.` report('failed', { routeIndex, stepIndex, totalSteps, step: rawStep, errorMessage: msg, }) throw new LifiReportedError(`${LOG_PREFIX} ${msg}`) } hashes.push(txHash) report('submitted', { routeIndex, stepIndex, totalSteps, route, step: rawStep, txHash, }) report('confirming', { routeIndex, stepIndex, totalSteps, route, step: rawStep, txHash, }) const waiterResult = await effectiveWaiter(txHash, network) 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}` report('failed', { routeIndex, stepIndex, totalSteps, step: rawStep, txHash, errorMessage: msg, }) throw new LifiReportedError(msg) } let txHashForLifi = txHash if (isYieldEvmNetwork(network) && evmRequestFn) { try { const userOpReceipt = await evmRequestFn( 'eth_getUserOperationReceipt', [txHash], network, ) if ( userOpReceipt && typeof userOpReceipt === 'object' && userOpReceipt !== null ) { const receipt = (userOpReceipt as Record).receipt if ( receipt && typeof receipt === 'object' && receipt !== null && typeof (receipt as Record).transactionHash === 'string' ) { txHashForLifi = (receipt as Record) .transactionHash as string sdkLogger.debug( `${LOG_PREFIX} resolved AA transactionHash for LiFi`, { userOpHash: txHash, transactionHash: txHashForLifi, }, ) } } } catch (error) { sdkLogger.debug(`${LOG_PREFIX} not a UserOp or receipt unavailable`, { txHash, error: error instanceof Error ? error.message : String(error), }) } } report('lifi_pending', { routeIndex, stepIndex, totalSteps, route, step: rawStep, txHash, }) const fromChain = rawStep.action.fromChainId const toChain = rawStep.action.toChainId const bridge = statusBridgeFromStepTool(rawStep.tool) const terminal = await pollLifiStatusUntilTerminal({ getStatus: (req) => this.api.getStatus(req), request: { txHash: txHashForLifi, fromChain, toChain, ...(bridge !== undefined ? { bridge } : {}), }, everyMs: statusDefaults.everyMs, initialDelayMs: statusDefaults.initialDelayMs, timeoutMs: statusDefaults.timeoutMs, maxConsecutiveErrors: statusDefaults.maxConsecutiveErrors, backoff: statusDefaults.backoff, onUpdate: (raw) => { report('lifi_pending', { routeIndex, stepIndex, totalSteps, route, step: rawStep, txHash, lifiStatus: raw, }) return true }, }) report('step_done', { routeIndex, stepIndex, totalSteps, route, step: rawStep, txHash, lifiStatus: terminal, }) } report('complete', { routeIndex, route, totalSteps: steps.length, }) return { hashes, steps: executedSteps, route } } /** * Strip stale planning nonces for sequential EVM sends (same idea as Yield). */ private prepareSequentialPayload( tx: LifiTransactionRequest, network: string, multiStep: boolean, ): unknown { const isEvm = network.startsWith('eip155:') if (!isEvm || !multiStep) { return tx } return this.stripStalePlanningNonceIfJsonObject( tx as unknown as Record, ) } private unwrapSingleKeyTransactionObject( obj: Record, ): Record { const keys = Object.keys(obj) if ( keys.length === 1 && typeof obj[keys[0]] === 'object' && obj[keys[0]] !== null && !Array.isArray(obj[keys[0]]) ) { return obj[keys[0]] as Record } return obj } private stripStalePlanningNonceIfJsonObject( unsigned: Record, ): unknown { const spread = { ...unsigned } const inner = this.unwrapSingleKeyTransactionObject(spread) const { nonce: _staleNonce, ...rest } = inner return rest } } function mergePollOptions( override: LifiPollStatusOptions | undefined, defaults: Required< Pick< LifiPollStatusOptions, 'everyMs' | 'initialDelayMs' | 'timeoutMs' | 'maxConsecutiveErrors' > > & { backoff: NonNullable }, ): { everyMs: number initialDelayMs: number timeoutMs: number maxConsecutiveErrors: number backoff?: { factor: number; maxIntervalMs: number } } { return { everyMs: override?.everyMs ?? defaults.everyMs, initialDelayMs: override?.initialDelayMs ?? defaults.initialDelayMs, timeoutMs: override?.timeoutMs ?? defaults.timeoutMs, maxConsecutiveErrors: override?.maxConsecutiveErrors ?? defaults.maxConsecutiveErrors, backoff: override?.backoff ?? defaults.backoff, } } export function resolveLifiStepNetworkCaip2( step: LifiStep, tx: LifiTransactionRequest, ): string { const cand = tx.chainId ?? step.action?.fromChainId return normalizeChainToNetwork(cand) } function normalizeChainToNetwork(id: string | number | undefined): string { if ( id === undefined || id === null || (typeof id === 'string' && id === '') ) { throw new Error( `${LOG_PREFIX} Missing chain for LiFi transaction (no chainId on tx or fromChainId on step).`, ) } if (typeof id === 'number') { return `eip155:${id}` } const s = String(id).trim() if (s.startsWith('eip155:')) { return s } if (s.toLowerCase().startsWith('solana:')) { return s } if (s.startsWith('0x') || s.startsWith('0X')) { try { return `eip155:${BigInt(s).toString()}` } catch { throw new Error(`${LOG_PREFIX} Invalid hex chainId: ${s}`) } } if (/^\d+$/.test(s)) { return `eip155:${s}` } throw new Error(`${LOG_PREFIX} Unrecognized chain identifier: ${s}`) } export type { IPortalLifiTradingApi, PortalLifiTradingApiOptions, } from './PortalLifiTradingApi'