UNPKG

@openocean.finance/widget

Version:

Openocean Widget for cross-chain bridging and swapping. It will drive your multi-chain strategy and attract new users from everywhere.

549 lines (485 loc) 16.2 kB
import { ChainId, createConfig, getRoutes, getStatus, getStepTransaction, type LiFiStep, type Route, } from '@lifi/sdk' import { WalletAdapterProps } from '@solana/wallet-adapter-base' import { Connection, Transaction, VersionedTransaction } from '@solana/web3.js' import { WalletClient, formatUnits } from 'viem' import * as bitcoin from 'bitcoinjs-lib' import { CROSS_CHAIN_FEE_RECEIVER, ZERO_ADDRESS, Currency, SolanaToken, MAINNET_NETWORKS, } from '../constants/index.js' import { Quote } from '../registry.js' import { BaseSwapAdapter, Chain, NOT_SUPPORTED_CHAINS_PRICE_SERVICE, NonEvmChain, NormalizedQuote, NormalizedTxResponse, QuoteParams, SwapStatus, } from './BaseSwapAdapter.js' const LIFI_INTEGRATOR = 'openocean' type LifiRawQuote = { route: Route transactionRequest?: LiFiStep['transactionRequest'] } export class LifiAdapter extends BaseSwapAdapter { constructor() { super() createConfig({ integrator: LIFI_INTEGRATOR, }) } getName(): string { return 'LIFI' } getIcon(): string { return 'https://storage.googleapis.com/ks-setting-1d682dca/aed3a971-48be-4c3c-9597-5ab78073fbf11745552578218.png' } getSupportedChains(): Chain[] { return [NonEvmChain.Solana, NonEvmChain.Bitcoin, ...MAINNET_NETWORKS] } getSupportedTokens(_sourceChain: Chain, _destChain: Chain): Currency[] { return [] } async getQuote(params: QuoteParams): Promise<NormalizedQuote> { const routesRequest = this.buildRoutesRequest(params) const routesResponse = await getRoutes(routesRequest).catch((error) => { const message = error?.cause?.responseBody?.message || error?.message || 'Failed to fetch LiFi routes' throw new Error(message) }) if (!routesResponse.routes?.length) { const unavailableMessage = this.getUnavailableRoutesMessage( routesResponse.unavailableRoutes ) throw new Error( unavailableMessage || 'No available routes for the requested transfer' ) } const selectedRoute = this.selectBestRoute(routesResponse.routes) const firstStepWithTx = await this.resolveStepTransaction( selectedRoute.steps[0] ) const formattedOutputAmount = formatUnits( BigInt(selectedRoute.toAmount), params.toToken.decimals ) const formattedInputAmount = formatUnits( BigInt(params.amount), params.fromToken.decimals ) const inputUsd = NOT_SUPPORTED_CHAINS_PRICE_SERVICE.includes( params.fromChain ) ? Number(selectedRoute.fromAmountUSD) : params.tokenInUsd * +formattedInputAmount const outputUsd = NOT_SUPPORTED_CHAINS_PRICE_SERVICE.includes(params.toChain) ? Number(selectedRoute.toAmountUSD) : params.tokenOutUsd * +formattedOutputAmount const { protocolFee, gasFeeUsd } = this.aggregateRouteFees(selectedRoute) const timeEstimate = selectedRoute.steps.reduce( (acc, step) => acc + (step.estimate?.executionDuration || 0), 0 ) const rawQuote: LifiRawQuote = { route: selectedRoute, transactionRequest: firstStepWithTx.transactionRequest, } return { quoteParams: params, outputAmount: BigInt(selectedRoute.toAmount), formattedOutputAmount, inputUsd, outputUsd, priceImpact: !inputUsd || !outputUsd ? NaN : ((inputUsd - outputUsd) * 100) / inputUsd, rate: +formattedOutputAmount / +formattedInputAmount, gasFeeUsd, timeEstimate, contractAddress: firstStepWithTx.transactionRequest?.to || firstStepWithTx.estimate?.approvalAddress || '', rawQuote, protocolFee, platformFeePercent: (params.feeBps * 100) / 10_000, } } async executeSwap( { quote }: Quote, walletClient: WalletClient, _nearWalletClient?: any, _sendBtcFn?: (params: { recipient: string; amount: string | number }) => Promise<string>, sendTransaction?: WalletAdapterProps['sendTransaction'], connection?: Connection ): Promise<NormalizedTxResponse> { const rawQuote = quote.rawQuote as LifiRawQuote const route = rawQuote?.route if (!route?.steps?.length) { throw new Error('LiFi route is missing or has no steps') } if (quote.quoteParams.fromChain === NonEvmChain.Bitcoin) { const stepWithTx = await this.resolveStepTransaction(route.steps[0]) return this.executeBitcoinSwap(quote, stepWithTx, walletClient) } if (quote.quoteParams.fromChain === NonEvmChain.Solana) { const stepWithTx = await this.resolveStepTransaction(route.steps[0]) return this.executeSolanaSwap(quote, stepWithTx, walletClient) } return this.executeEvmRoute(quote, route, walletClient) } async getTransactionStatus(p: NormalizedTxResponse): Promise<SwapStatus> { const res = await getStatus({ fromChain: this.toLifiChainId(p.sourceChain), toChain: this.toLifiChainId(p.targetChain), txHash: p.sourceTxHash, }) return { txHash: (res as any)?.receiving?.txHash || '', status: res.status === 'DONE' ? 'Success' : res.status === 'FAILED' ? 'Failed' : 'Processing', } } private buildRoutesRequest(params: QuoteParams) { const fromChainId = this.toLifiChainId(params.fromChain) const toChainId = this.toLifiChainId(params.toChain) const fromTokenAddress = this.toLifiTokenAddress( params.fromChain, params.fromToken as any ) const toTokenAddress = this.toLifiTokenAddress( params.toChain, params.toToken as any ) const fromAddress = params.sender === ZERO_ADDRESS ? CROSS_CHAIN_FEE_RECEIVER : params.sender return { fromChainId, fromTokenAddress, fromAmount: params.amount, toChainId, toTokenAddress, fromAddress, toAddress: params.recipient, options: { integrator: LIFI_INTEGRATOR, slippage: params.slippage / 10_000, fee: params.feeBps / 10_000, order: 'CHEAPEST' as const, allowSwitchChain: true, }, } } private selectBestRoute(routes: Route[]): Route { return routes.reduce((best, route) => BigInt(route.toAmount) > BigInt(best.toAmount) ? route : best ) } private aggregateRouteFees(route: Route) { let protocolFee = 0 let gasFeeUsd = Number(route.gasCostUSD || 0) for (const step of route.steps) { protocolFee += step.estimate?.feeCosts?.reduce( (acc: number, curr: any) => acc + Number(curr.amountUSD), 0 ) || 0 if (!gasFeeUsd) { gasFeeUsd += step.estimate?.gasCosts?.reduce( (acc: number, curr: any) => acc + Number(curr.amountUSD), 0 ) || 0 } } return { protocolFee, gasFeeUsd } } private getUnavailableRoutesMessage(unavailableRoutes: any): string | undefined { if (!unavailableRoutes) return undefined const failed = unavailableRoutes.failed if (Array.isArray(failed) && failed.length > 0) { const first = failed[0] if (typeof first === 'object' && first !== null) { const subpathErrors = Object.values(first.subpaths || {}) const toolError = subpathErrors.find( (e: any) => e?.message || e?.code ) as any if (toolError?.message) return toolError.message if (toolError?.code) return toolError.code if (first.reason) return first.reason } } return undefined } private async resolveStepTransaction(step: LiFiStep): Promise<LiFiStep> { if (step.transactionRequest) { return step } return getStepTransaction(step) } private async executeEvmRoute( quote: NormalizedQuote, route: Route, walletClient: WalletClient ): Promise<NormalizedTxResponse> { const account = walletClient.account?.address if (!account) throw new Error('WalletClient account is not defined') let lastTxHash = '' for (const step of route.steps) { const stepWithTx = await this.resolveStepTransaction(step) const { transactionRequest } = stepWithTx if (!transactionRequest?.to) { continue } lastTxHash = await walletClient.sendTransaction({ chain: undefined, account, to: transactionRequest.to as `0x${string}`, value: BigInt(transactionRequest.value || '0'), data: (transactionRequest.data as `0x${string}`) || '0x', kzg: undefined, }) } if (!lastTxHash) { throw new Error('No LiFi EVM transaction was generated') } return { sender: quote.quoteParams.sender, id: lastTxHash, sourceTxHash: lastTxHash, adapter: this.getName(), sourceChain: quote.quoteParams.fromChain, targetChain: quote.quoteParams.toChain, inputAmount: quote.quoteParams.amount, outputAmount: quote.outputAmount.toString(), sourceToken: quote.quoteParams.fromToken, targetToken: quote.quoteParams.toToken, timestamp: Date.now(), } } private async executeSolanaSwap( quote: NormalizedQuote, step: LiFiStep, walletClient: WalletClient ): Promise<NormalizedTxResponse> { if (!walletClient.sendTransaction) { throw new Error('Connection is not defined for Solana swap') } if (!step.transactionRequest?.data) { throw new Error('LiFi Solana transaction data is missing') } const txBuffer = Buffer.from(step.transactionRequest.data, 'base64') let transaction try { transaction = VersionedTransaction.deserialize(txBuffer) } catch { transaction = Transaction.from(txBuffer) } const tx: any = await (walletClient as any).sendTransaction(transaction) const signature = tx.signature return { sender: quote.quoteParams.sender, id: signature, sourceTxHash: signature, adapter: this.getName(), sourceChain: quote.quoteParams.fromChain, targetChain: quote.quoteParams.toChain, inputAmount: quote.quoteParams.amount, outputAmount: quote.outputAmount.toString(), sourceToken: quote.quoteParams.fromToken, targetToken: quote.quoteParams.toToken, timestamp: Date.now(), } } private async executeBitcoinSwap( quote: NormalizedQuote, step: LiFiStep, walletClient: WalletClient ): Promise<NormalizedTxResponse> { const account = (walletClient as any).account?.address || quote.quoteParams.sender if (!account) throw new Error('WalletClient account is not defined') const transactionRequest = step.transactionRequest if (!transactionRequest?.data) { throw new Error('TransactionRequest data is missing') } let psbt: bitcoin.Psbt try { psbt = bitcoin.Psbt.fromBase64(transactionRequest.data, { network: bitcoin.networks.bitcoin, }) } catch { psbt = bitcoin.Psbt.fromHex(transactionRequest.data, { network: bitcoin.networks.bitcoin, }) } const anyWindow = typeof window !== 'undefined' ? (window as any) : undefined let connectorName: string | undefined if (anyWindow?.okxwallet?.bitcoin) connectorName = 'OKX Wallet' else if (anyWindow?.unisat) connectorName = 'Unisat' else if (anyWindow?.BitcoinProvider) connectorName = 'Xverse' else if (anyWindow?.phantom?.bitcoin) connectorName = 'Phantom' else throw new Error('No Bitcoin wallet found') const inputsToSign: any[] = [] for (let index = 0; index < psbt.data.inputs.length; index++) { const input = psbt.data.inputs[index] let inputAddress: string if (input.witnessUtxo) { inputAddress = bitcoin.address.fromOutputScript( input.witnessUtxo.script, bitcoin.networks.bitcoin ) } else if (input.nonWitnessUtxo) { inputAddress = account.toString() } else { inputAddress = account.toString() } if (inputAddress === account.toString()) { inputsToSign.push({ index, address: inputAddress }) } } if (inputsToSign.length === 0) { throw new Error('No inputs found to sign') } const psbtBase64 = psbt.toBase64() const psbtHex = psbt.toHex() let signedPsbtBase64: string switch (connectorName) { case 'OKX Wallet': { const response = await anyWindow.okxwallet.bitcoin.signPsbt(psbtHex, { autoFinalized: false, toSignInputs: inputsToSign.map((item) => ({ index: item.index, address: item.address, sighashTypes: [1], })), }) signedPsbtBase64 = this.convertHexToBase64( this.extractSignedPsbt(response) || '' ) break } case 'Unisat': { const response = await anyWindow.unisat.signPsbt(psbtHex, { autoFinalized: false, toSignInputs: inputsToSign.map((item) => ({ index: item.index, address: item.address, sighashTypes: [1], })), }) signedPsbtBase64 = this.convertHexToBase64( this.extractSignedPsbt(response) || '' ) break } case 'Xverse': { const response = await anyWindow.BitcoinProvider.request('signPsbt', { psbt: psbtHex, finalize: false, toSignInputs: inputsToSign.map((item) => ({ index: item.index, address: item.address, })), }) signedPsbtBase64 = this.extractSignedPsbt(response) || '' break } case 'Phantom': { const phantom = anyWindow.phantom.bitcoin if (!phantom?.signPSBT) { throw new Error('Phantom wallet does not support signPSBT') } const response = await phantom.signPSBT(psbtBase64, { autoFinalize: false, inputsToSign: inputsToSign.map((item) => ({ index: item.index, address: item.address, sighashTypes: [1], })), }) signedPsbtBase64 = this.extractSignedPsbt(response) || '' break } default: throw new Error(`Unsupported wallet: ${connectorName}`) } if (!signedPsbtBase64) { throw new Error('Failed to sign PSBT') } const signedPsbt = bitcoin.Psbt.fromBase64(signedPsbtBase64, { network: bitcoin.networks.bitcoin, }) signedPsbt.finalizeAllInputs() const rawTx = signedPsbt.extractTransaction().toHex() const txHash = await fetch('https://mempool.space/api/tx', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: rawTx, }).then((r) => r.text()) if (!txHash || txHash.startsWith('<')) { throw new Error(`Failed to broadcast transaction: ${txHash}`) } return { sender: quote.quoteParams.sender, id: txHash, sourceTxHash: txHash, adapter: this.getName(), sourceChain: quote.quoteParams.fromChain, targetChain: quote.quoteParams.toChain, inputAmount: quote.quoteParams.amount, outputAmount: quote.outputAmount.toString(), sourceToken: quote.quoteParams.fromToken, targetToken: quote.quoteParams.toToken, timestamp: Date.now(), } } private toLifiChainId(chain: Chain): number { if (chain === NonEvmChain.Solana) return ChainId.SOL if (chain === NonEvmChain.Bitcoin) return ChainId.BTC return Number(chain) } private toLifiTokenAddress(chain: Chain, token: any): string { if (chain === NonEvmChain.Solana || chain === NonEvmChain.Bitcoin) { return token.address } return token.isNative ? ZERO_ADDRESS : token.address } private convertHexToBase64(hexString: string): string { try { return Buffer.from(hexString, 'hex').toString('base64') } catch { return hexString } } private extractSignedPsbt(response: any): string | null { if (!response) return null if (typeof response === 'string') return response return ( response.signedPsbtHex || response.signedPsbtBase64 || response.signedPsbt || null ) } }