import type { IPortalApi } from '@portal-hq/utils' import { sdkLogger } from '@portal-hq/utils' import type { ZeroExPriceRequest, ZeroExPriceResponse, ZeroExQuoteRequest, ZeroExQuoteResponse, ZeroExSourcesResponse, ZeroXTradeAssetOptions, ZeroXTradeAssetParams, ZeroXTradeAssetProgressData, ZeroXTradeAssetProgressStatus, ZeroXTradeAssetResult, } from '../../../types' import { type IPortalZeroXApi, PortalZeroXApi, type ZeroXApiKeyOptions, } from './PortalZeroXApi' const LOG_PREFIX = '[ZeroX]' function executionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } export interface IZeroX { getSources( chainId: string, options?: ZeroXApiKeyOptions, ): Promise getQuote( request: ZeroExQuoteRequest, options?: ZeroXApiKeyOptions, ): Promise getPrice( request: ZeroExPriceRequest, options?: ZeroXApiKeyOptions, ): Promise /** * Fetch a 0x quote and execute the returned transaction: sign, broadcast, wait for EVM receipt * (same strict confirmation policy as LiFi/Yield high-level flows). */ tradeAsset( params: ZeroXTradeAssetParams, options?: ZeroXTradeAssetOptions, ): Promise } export interface ZeroXOptions { api: IPortalApi /** * Instance-level signer fallback for tradeAsset. */ signAndSendTransaction?: ( transaction: unknown, network: string, ) => Promise /** * Instance-level confirmation waiter fallback for tradeAsset. */ waitForConfirmation?: ( txHash: string, network: string, ) => Promise } /** * 0x quotes/prices via Portal API. `tradeAsset` follows Yield-style execution wiring: * per-call hooks override instance defaults, and Portal wires instance defaults automatically. */ export class ZeroX implements IZeroX { private readonly zeroXApi: IPortalZeroXApi private readonly signAndSendTransaction?: ZeroXOptions['signAndSendTransaction'] private readonly waitForConfirmation?: ZeroXOptions['waitForConfirmation'] constructor(options: ZeroXOptions) { this.zeroXApi = new PortalZeroXApi({ api: options.api }) this.signAndSendTransaction = options.signAndSendTransaction this.waitForConfirmation = options.waitForConfirmation } async getSources( chainId: string, options?: ZeroXApiKeyOptions, ): Promise { return this.zeroXApi.getSources(chainId, options) } async getQuote( request: ZeroExQuoteRequest, options?: ZeroXApiKeyOptions, ): Promise { return this.zeroXApi.getQuote(request, options) } async getPrice( request: ZeroExPriceRequest, options?: ZeroXApiKeyOptions, ): Promise { return this.zeroXApi.getPrice(request, options) } async tradeAsset( params: ZeroXTradeAssetParams, options?: ZeroXTradeAssetOptions, ): Promise { const signAndSend = options?.signAndSendTransaction ?? this.signAndSendTransaction const waitForConfirmation = options?.waitForConfirmation ?? this.waitForConfirmation if (!signAndSend) { throw new Error( `${LOG_PREFIX} tradeAsset requires signAndSendTransaction (instance default or per-call option).`, ) } if (!waitForConfirmation) { throw new Error( `${LOG_PREFIX} tradeAsset requires waitForConfirmation (instance default or per-call option).`, ) } const { onProgress, zeroXApiKey, ...quoteRequest } = params const report = ( status: ZeroXTradeAssetProgressStatus, data: ZeroXTradeAssetProgressData = {}, ) => { onProgress?.(status, data) } report('fetching_quote', {}) let quote: ZeroExQuoteResponse try { quote = await this.zeroXApi.getQuote( quoteRequest, zeroXApiKey !== undefined ? { zeroXApiKey } : undefined, ) } catch (error: unknown) { const msg = `getQuote failed: ${executionErrorMessage(error)}` report('failed', { errorMessage: msg }) throw error } /** * Quote safety: if the API sets `error`, do not execute — `data` may still be present. * (Mutual exclusivity is not guaranteed by the wire type; we fail closed on any non-empty error.) */ if (quote.error !== undefined && String(quote.error).trim() !== '') { const msg = `Quote error: ${quote.error}` report('failed', { errorMessage: msg }) throw new Error(`${LOG_PREFIX} ${msg}`) } if (!quote.data?.rawResponse) { const msg = 'Quote response missing data.rawResponse' report('failed', { errorMessage: msg }) throw new Error(`${LOG_PREFIX} ${msg}`) } const raw = quote.data.rawResponse const transaction = raw.transaction const to = transaction !== null && transaction !== undefined && typeof transaction === 'object' && 'to' in transaction ? (transaction as { to?: unknown }).to : undefined if ( typeof to !== 'string' || to.trim() === '' || transaction === null || transaction === undefined || typeof transaction !== 'object' ) { const msg = 'Quote response missing valid transaction (expected object with non-empty string "to")' report('failed', { errorMessage: msg }) throw new Error(`${LOG_PREFIX} ${msg}`) } const network = params.chainId report('signing', { buyAmount: raw.buyAmount, sellAmount: raw.sellAmount, transaction, }) sdkLogger.debug(`${LOG_PREFIX} tradeAsset: signAndSend`, { network }) let txHash: string try { txHash = await signAndSend(transaction, network) } catch (error: unknown) { const msg = `signAndSendTransaction failed: ${executionErrorMessage(error)}` report('failed', { errorMessage: msg }) throw error } if (typeof txHash !== 'string' || txHash.trim() === '') { const msg = 'signAndSendTransaction returned empty or invalid transaction hash' report('failed', { errorMessage: msg }) throw new Error(`${LOG_PREFIX} ${msg}`) } report('submitted', { txHash, buyAmount: raw.buyAmount, sellAmount: raw.sellAmount, transaction, }) report('confirming', { txHash, buyAmount: raw.buyAmount, sellAmount: raw.sellAmount, transaction, }) let waiterResult: boolean | void try { waiterResult = await waitForConfirmation(txHash, network) } catch (error: unknown) { const msg = `waitForConfirmation failed: ${executionErrorMessage(error)}` report('failed', { txHash, errorMessage: msg, }) throw error } 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', { txHash, errorMessage: msg, }) throw new Error(msg) } report('confirmed', { txHash }) return { hashes: [txHash] } } }