@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
text/typescript
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',
}
}
}
}