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.

575 lines (501 loc) 14.9 kB
import { ChainId } from '@openocean.finance/widget-sdk' import { type WalletClient, formatUnits, parseUnits } from 'viem' import type { Quote } from '../registry.js' import { BaseSwapAdapter, type Chain, type NormalizedQuote, type NormalizedTxResponse, type QuoteParams, type SwapStatus, } from './BaseSwapAdapter.js' // const DEFAULT_PEGASUS_API_BASE = 'https://stagenet-api.pegasusfi.xyz' const DEFAULT_PEGASUS_API_BASE = 'https://pegasusfi.openocean.finance' const PEGASUS_API_KEY = ''//'pegasus-b13023448e2d40c691001548' const CHAIN_ID_TO_PEGASUS: Record<string, string> = { [ChainId.ETH]: 'ETH', [ChainId.BSC]: 'BSC', [ChainId.POL]: 'POL', [ChainId.ARB]: 'ARB', [ChainId.OPT]: 'OPT', [ChainId.AVA]: 'AVA', [ChainId.BAS]: 'BASE', [ChainId.FTM]: 'FTM', [ChainId.MNT]: 'MNT', [ChainId.SCL]: 'SCROLL', [ChainId.BLS]: 'BLAST', [ChainId.SON]: 'SONIC', [ChainId.UNI]: 'UNI', [ChainId.MAM]: 'METIS', } const NATIVE_TOKEN_SYMBOL: Record<string, string> = { [ChainId.ETH]: 'ETH', [ChainId.BSC]: 'BNB', [ChainId.POL]: 'POL', [ChainId.ARB]: 'ETH', [ChainId.OPT]: 'ETH', [ChainId.AVA]: 'AVAX', [ChainId.BAS]: 'ETH', [ChainId.FTM]: 'FTM', [ChainId.MNT]: 'MNT', } const erc20ApproveAbi = [ { inputs: [ { type: 'address', name: 'spender' }, { type: 'uint256', name: 'amount' }, ], name: 'approve', outputs: [{ type: 'bool', name: '' }], stateMutability: 'nonpayable', type: 'function', }, ] as const type PegasusRoute = { provider: string providerType: string expectedOutput: string estimatedTimeSeconds: number fees?: { affiliate?: string liquidity?: string outbound?: string total?: string totalBps?: number slippageBps?: number } inboundAddress?: string | null memo?: string | null router?: string | null } type PegasusQuoteResponse = { quoteId: string expiresAt: string routes: PegasusRoute[] warnings?: Array<{ provider: string code: string message: string userMessage: string }> } type PegasusTxParams = { to?: string data?: string value?: string gasLimit?: string memo?: string chainId?: number router?: string approvalSpender?: string providerReferenceId?: string instaswapSwapLite?: { txid?: string depositAddress?: string depositTokenSymbol?: string instructions?: string } } type PegasusSwapResponse = { transactionId: string status: string providerType: string route: PegasusRoute txParams?: PegasusTxParams approvalTx?: { spender: string tokenAddress: string amount: string } } type PegasusRawQuote = PegasusQuoteResponse & { selectedRoute: PegasusRoute } type PegasusChainInfo = { chain: string chainId?: number providers?: string[] assets: string[] } type PegasusChainsResponse = { chains: PegasusChainInfo[] } type PegasusApiError = { error?: { code?: string message?: string userMessage?: string } } const CHAINS_CACHE_TTL_MS = 5 * 60 * 1000 const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' const NATIVE_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' function parsePegasusAsset(asset: string): { symbol: string; address?: string } { const dashIndex = asset.indexOf('-') if (dashIndex === -1) { return { symbol: asset } } return { symbol: asset.slice(0, dashIndex), address: asset.slice(dashIndex + 1), } } function readEnvVar(name: string): string | undefined { try { if (typeof process !== 'undefined' && process.env?.[name]) { return process.env[name] } } catch { // ignore } try { const metaEnv = (import.meta as { env?: Record<string, string> }).env if (metaEnv?.[name]) { return metaEnv[name] } if (metaEnv?.[`VITE_${name}`]) { return metaEnv[`VITE_${name}`] } } catch { // ignore } return undefined } function getPegasusApiBase(): string { return ( readEnvVar('PEGASUS_API_BASE')?.replace(/\/$/, '') || DEFAULT_PEGASUS_API_BASE ) } function getPegasusApiKey(): string { return PEGASUS_API_KEY } export class PegasusAdapter extends BaseSwapAdapter { private chainsCache: { fetchedAt: number data: PegasusChainsResponse } | null = null getName(): string { return 'Pegasus' } getIcon(): string { return 'https://pegasusfi.openocean.finance/favicon.ico' } getSupportedChains(): Chain[] { return Object.keys(CHAIN_ID_TO_PEGASUS).map(Number) as Chain[] } getSupportedTokens(_sourceChain: Chain, _destChain: Chain): any[] { return [] } private async pegasusRequest<T>( path: string, init?: RequestInit ): Promise<T> { const response = await fetch(`${getPegasusApiBase()}${path}`, { ...init, headers: { // 'X-API-Key': getPegasusApiKey(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...(init?.headers || {}), }, }) const data = (await response.json()) as T & PegasusApiError if (!response.ok || data.error) { throw new Error( data.error?.userMessage || data.error?.message || `Pegasus API request failed (${response.status})` ) } return data } private async getChains(): Promise<PegasusChainsResponse> { if ( this.chainsCache && Date.now() - this.chainsCache.fetchedAt < CHAINS_CACHE_TTL_MS ) { return this.chainsCache.data } const data = await this.pegasusRequest<PegasusChainsResponse>('/chains', { method: 'GET', }) this.chainsCache = { fetchedAt: Date.now(), data, } return data } private async getChainAssets(pegasusChain: string): Promise<string[]> { const { chains } = await this.getChains() const chainData = chains.find((chain) => chain.chain === pegasusChain) if (!chainData?.assets?.length) { throw new Error(`No Pegasus assets found for chain ${pegasusChain}`) } return chainData.assets } private toPegasusChain(chain: Chain): string { const pegasusChain = CHAIN_ID_TO_PEGASUS[chain] if (!pegasusChain) { throw new Error(`Pegasus does not support chain ${chain}`) } return pegasusChain } private async resolvePegasusToken(chain: Chain, token: any): Promise<string> { const pegasusChain = this.toPegasusChain(chain) const assets = await this.getChainAssets(pegasusChain) const address = token.address?.toLowerCase?.() || '' const isNative = token.isNative || address === ZERO_ADDRESS || address === NATIVE_ADDRESS if (isNative) { const nativeCandidates = [ NATIVE_TOKEN_SYMBOL[chain], token.symbol, ] .filter(Boolean) .map((symbol) => symbol!.toUpperCase()) for (const symbol of nativeCandidates) { const nativeAsset = assets.find( (asset) => !asset.includes('-') && asset.toUpperCase() === symbol ) if (nativeAsset) { return nativeAsset } } throw new Error( `Native token not supported on Pegasus chain ${pegasusChain}` ) } const erc20Asset = assets.find((asset) => { const parsed = parsePegasusAsset(asset) return parsed.address?.toLowerCase() === address }) if (erc20Asset) { return erc20Asset } const symbolAsset = assets.find((asset) => { const parsed = parsePegasusAsset(asset) return ( parsed.address && parsed.symbol.toUpperCase() === token.symbol?.toUpperCase() ) }) if (symbolAsset) { return symbolAsset } throw new Error( `Token ${token.symbol || address} is not supported on Pegasus chain ${pegasusChain}` ) } private selectBestRoute( routes: PegasusRoute[], warnings?: PegasusQuoteResponse['warnings'] ): PegasusRoute { if (!routes.length) { throw new Error('No Pegasus routes found') } const blockedProviders = new Set( (warnings || []) .filter((warning) => ['UNSUPPORTED_PAIR', 'CHAIN_HALTED'].includes(warning.code) ) .map((warning) => warning.provider.toLowerCase()) ) const eligibleRoutes = routes.filter( (route) => !blockedProviders.has(route.provider.toLowerCase()) ) const targetRoutes = eligibleRoutes.length > 0 ? eligibleRoutes : routes return targetRoutes.reduce((best, current) => Number.parseFloat(current.expectedOutput) > Number.parseFloat(best.expectedOutput) ? current : best ) } async getQuote(params: QuoteParams): Promise<NormalizedQuote> { try { const fromChain = this.toPegasusChain(params.fromChain) const toChain = this.toPegasusChain(params.toChain) const fromToken = await this.resolvePegasusToken( params.fromChain, params.fromToken ) const toToken = await this.resolvePegasusToken( params.toChain, params.toToken ) const amount = formatUnits( BigInt(params.amount), params.fromToken.decimals ) const searchParams = new URLSearchParams({ fromChain, fromToken, toChain, toToken, amount, }) if (params.sender) { searchParams.set('fromAddress', params.sender) } if (params.recipient) { searchParams.set('toAddress', params.recipient) } if (params.slippage > 0) { searchParams.set('slippageBps', params.slippage.toString()) } const quoteResponse = await this.pegasusRequest<PegasusQuoteResponse>( `/quote?${searchParams.toString()}`, { method: 'GET' } ) const selectedRoute = this.selectBestRoute( quoteResponse.routes, quoteResponse.warnings ) const formattedInputAmount = amount const formattedOutputAmount = selectedRoute.expectedOutput const outputAmount = parseUnits( formattedOutputAmount, params.toToken.decimals ) const inputUsd = params.tokenInUsd * Number.parseFloat(formattedInputAmount) const outputUsd = params.tokenOutUsd * Number.parseFloat(formattedOutputAmount) const priceImpact = !inputUsd || !outputUsd ? Number.NaN : ((inputUsd - outputUsd) * 100) / inputUsd const rate = Number.parseFloat(formattedInputAmount) > 0 ? Number.parseFloat(formattedOutputAmount) / Number.parseFloat(formattedInputAmount) : 0 const rawQuote: PegasusRawQuote = { ...quoteResponse, selectedRoute, } return { quoteParams: params, outputAmount, formattedOutputAmount, inputUsd, outputUsd, rate, timeEstimate: selectedRoute.estimatedTimeSeconds || 0, priceImpact, gasFeeUsd: 0, contractAddress: selectedRoute.router || selectedRoute.inboundAddress || '', rawQuote, protocolFee: Number.parseFloat(selectedRoute.fees?.total || '0'), platformFeePercent: (params.feeBps * 100) / 10_000, } } catch (error: any) { return this.handleError(error) } } async executeSwap( { quote }: Quote, walletClient: WalletClient ): Promise<NormalizedTxResponse> { const rawQuote = quote.rawQuote as PegasusRawQuote if (!rawQuote?.quoteId || !rawQuote?.selectedRoute?.provider) { throw new Error('Pegasus quote is missing quoteId or provider') } const account = walletClient.account?.address if (!account) { throw new Error('Wallet client account is not defined') } const swapResponse = await this.pegasusRequest<PegasusSwapResponse>( '/swap', { method: 'POST', body: JSON.stringify({ quoteId: rawQuote.quoteId, provider: rawQuote.selectedRoute.provider, fromAddress: quote.quoteParams.sender || account, toAddress: quote.quoteParams.recipient || account, slippageBps: quote.quoteParams.slippage, }), } ) if (swapResponse.approvalTx) { await walletClient.writeContract({ chain: undefined, account, address: swapResponse.approvalTx.tokenAddress as `0x${string}`, abi: erc20ApproveAbi, functionName: 'approve', args: [ swapResponse.approvalTx.spender as `0x${string}`, BigInt(swapResponse.approvalTx.amount), ], }) } const txParams = swapResponse.txParams let sourceTxHash = swapResponse.transactionId if (txParams?.to) { sourceTxHash = await walletClient.sendTransaction({ chain: undefined, account, to: txParams.to as `0x${string}`, data: (txParams.data as `0x${string}`) || '0x', value: BigInt(txParams.value || '0'), gas: txParams.gasLimit ? BigInt(txParams.gasLimit) : undefined, kzg: undefined, }) } else if (txParams?.instaswapSwapLite?.depositAddress) { throw new Error( `Pegasus route requires deposit to ${txParams.instaswapSwapLite.depositAddress}. Manual deposit flow is not implemented yet.` ) } else if (rawQuote.selectedRoute.inboundAddress) { throw new Error( `Pegasus route requires deposit to ${rawQuote.selectedRoute.inboundAddress}. Manual deposit flow is not implemented yet.` ) } return { sender: quote.quoteParams.sender, id: swapResponse.transactionId, sourceTxHash, 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(), } } async getTransactionStatus(p: NormalizedTxResponse): Promise<SwapStatus> { try { const data = await this.pegasusRequest<{ status?: string destinationTxHash?: string txHash?: string }>(`/swap/${encodeURIComponent(p.id)}`, { method: 'GET' }) const status = data.status?.toLowerCase() let finalStatus: SwapStatus['status'] = 'Processing' if (status === 'completed' || status === 'success' || status === 'done') { finalStatus = 'Success' } else if (status === 'failed' || status === 'reverted') { finalStatus = 'Failed' } else if (status === 'refunded') { finalStatus = 'Refunded' } return { txHash: data.destinationTxHash || data.txHash || '', status: finalStatus, } } catch (error) { console.error('Failed to get Pegasus transaction status:', error) return { txHash: '', status: 'Processing', } } } }