diff --git a/apps/kyberswap-interface/.env.production b/apps/kyberswap-interface/.env.production index e06626ba22..66e27cadfd 100644 --- a/apps/kyberswap-interface/.env.production +++ b/apps/kyberswap-interface/.env.production @@ -55,5 +55,7 @@ VITE_AFFILIATE_SERVICE=https://affiliate-service.kyberswap.com/api VITE_SOLANA_RPC=https://solana-rpc.kyberswap.com VITE_SMART_EXIT_API_URL=https://conditional-order.kyberswap.com/api -VITE_CROSSCHAIN_AGGREGATOR_API=https://crosschain-aggregator.kyberswap.com -# VITE_CROSSCHAIN_AGGREGATOR_API=https://pre-crosschain-aggregator.kyberengineering.io +# VITE_CROSSCHAIN_AGGREGATOR_API=https://crosschain-aggregator.kyberswap.com +VITE_CROSSCHAIN_AGGREGATOR_API=https://pre-crosschain-aggregator.kyberengineering.io + +VITE_CROSSCHAIN_KYBERCROSS_API=https://pre-kybercross.kyberengineering.io diff --git a/apps/kyberswap-interface/src/constants/env.ts b/apps/kyberswap-interface/src/constants/env.ts index 9ff164ce82..79726e7a28 100644 --- a/apps/kyberswap-interface/src/constants/env.ts +++ b/apps/kyberswap-interface/src/constants/env.ts @@ -57,6 +57,7 @@ export const AFFILIATE_SERVICE_URL = required('AFFILIATE_SERVICE') export const SOLANA_RPC = required('SOLANA_RPC') export const SMART_EXIT_API_URL = required('SMART_EXIT_API_URL') export const CROSSCHAIN_AGGREGATOR_API = required('CROSSCHAIN_AGGREGATOR_API') +export const CROSSCHAIN_KYBERCROSS_API = required('CROSSCHAIN_KYBERCROSS_API') type FirebaseConfig = { apiKey: string diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/api.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/api.ts new file mode 100644 index 0000000000..c7af3eb0c8 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/api.ts @@ -0,0 +1,18 @@ +import axios from 'axios' + +import { AcrossDepositStatusResponse } from 'pages/CrossChainSwap/adapters/AcrossAdapter/types' + +const ACROSS_API_BASE_URL = 'https://app.across.to/api' + +export const getAcrossDepositStatus = async (depositTxnRef: string): Promise => { + const { data, status } = await axios.get(`${ACROSS_API_BASE_URL}/deposit/status`, { + params: { depositTxnRef }, + validateStatus: status => status < 500, + }) + + if (status >= 400 && !data?.error) { + throw new Error(`Across deposit status failed with HTTP ${status}`) + } + + return data +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/index.ts similarity index 68% rename from apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter.ts rename to apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/index.ts index e38d426252..a2d849d0ba 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter.ts +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/index.ts @@ -2,7 +2,8 @@ import { AcrossClient, createAcrossClient } from '@across-protocol/app-sdk' import { ChainId, Currency, Token } from '@kyberswap/ks-sdk-core' import { WalletAdapterProps } from '@solana/wallet-adapter-base' import { Connection } from '@solana/web3.js' -import { WalletClient, formatUnits } from 'viem' +import axios from 'axios' +import { type Address, WalletClient, formatUnits } from 'viem' import { arbitrum, base, @@ -21,6 +22,13 @@ import { import { CROSS_CHAIN_FEE_RECEIVER, ZERO_ADDRESS } from 'constants/index' import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import { getAcrossDepositStatus } from 'pages/CrossChainSwap/adapters/AcrossAdapter/api' +import { + AcrossSuggestedFeesQuote, + AcrossSwapQuote, + AcrossWalletClient, +} from 'pages/CrossChainSwap/adapters/AcrossAdapter/types' +import { getAcrossFillTxHash, mapAcrossDepositStatus } from 'pages/CrossChainSwap/adapters/AcrossAdapter/utils' import { BaseSwapAdapter, Chain, @@ -39,6 +47,9 @@ import { isEvmChain } from 'utils' const API_URL = 'https://app.across.to/api/suggested-fees' +const getAcrossTokenAddress = (token: Currency): Address => + (token.isNative ? ZERO_ADDRESS : token.wrapped.address) as Address + export class AcrossAdapter extends BaseSwapAdapter { private acrossClient: AcrossClient @@ -119,12 +130,19 @@ export class AcrossAdapter extends BaseSwapAdapter { async getQuote(params: QuoteParams): Promise { try { - let res const isFromSol = params.fromChain === NonEvmChain.Solana + let rawQuote: AcrossSuggestedFeesQuote | AcrossSwapQuote + let outputAmount: bigint + let contractAddress: string + let timeEstimate: number + let gasFeeUsd = 0 + if (isFromSol && isEvmChain(params.toChain)) { + const fromToken = params.fromToken as SolanaToken + const toToken = params.toToken as Token const reqParams = new URLSearchParams({ - inputToken: (params.fromToken as SolanaToken).id, - outputToken: (params.toToken as Token).wrapped.address, + inputToken: fromToken.id, + outputToken: toToken.wrapped.address, destinationChainId: params.toChain.toString(), originChainId: '34268394551451', amount: params.amount, @@ -132,15 +150,19 @@ export class AcrossAdapter extends BaseSwapAdapter { allowUnmatchedDecimals: 'true', }) - res = await fetch(`${API_URL}?${reqParams}`).then(res => res.json()) + const { data } = await axios.get(API_URL, { params: reqParams }) + rawQuote = data + outputAmount = BigInt(data.outputAmount) + contractAddress = ZERO_ADDRESS + timeEstimate = data.estimatedFillTimeSec } else { - const p = params as EvmQuoteParams - res = await this.acrossClient.getSwapQuote({ + const quoteParams = params as EvmQuoteParams + const swapQuote = await this.acrossClient.getSwapQuote({ route: { originChainId: +params.fromChain, destinationChainId: +params.toChain, - inputToken: (p.fromToken.isNative ? ZERO_ADDRESS : p.fromToken.wrapped.address) as `0x${string}`, - outputToken: (p.toToken.isNative ? ZERO_ADDRESS : p.toToken.wrapped.address) as `0x${string}`, + inputToken: getAcrossTokenAddress(quoteParams.fromToken), + outputToken: getAcrossTokenAddress(quoteParams.toToken), }, amount: params.amount, appFee: params.feeBps / 10_000, @@ -148,6 +170,12 @@ export class AcrossAdapter extends BaseSwapAdapter { slippage: params.slippage / 10_000, // https://docs.across.to/reference/api-reference#get-swap-approval depositor: params.sender, }) + + rawQuote = swapQuote + outputAmount = BigInt(swapQuote.expectedOutputAmount) + contractAddress = swapQuote.checks.allowance.spender + timeEstimate = swapQuote.expectedFillTime + gasFeeUsd = Number(swapQuote.fees.originGas.amountUsd || 0) } // across only have bridge then we can treat token in and out price usd are the same in case price service is not supported @@ -161,7 +189,6 @@ export class AcrossAdapter extends BaseSwapAdapter { ? params.tokenInUsd : params.tokenOutUsd - const outputAmount = BigInt(isFromSol ? res.outputAmount : res.expectedOutputAmount) const formattedOutputAmount = formatUnits(outputAmount, params.toToken.decimals) const formattedInputAmount = formatUnits(BigInt(params.amount), params.fromToken.decimals) @@ -172,57 +199,59 @@ export class AcrossAdapter extends BaseSwapAdapter { quoteParams: params, outputAmount, formattedOutputAmount, - inputUsd: tokenInUsd * +formatUnits(BigInt(params.amount), params.fromToken.decimals), - outputUsd: tokenOutUsd * +formattedOutputAmount, + inputUsd, + outputUsd, rate: +formattedOutputAmount / +formattedInputAmount, - timeEstimate: isFromSol ? res.estimatedFillTimeSec : res.expectedFillTime, + timeEstimate, priceImpact: !inputUsd || !outputUsd ? NaN : ((inputUsd - outputUsd) * 100) / inputUsd, - // TODO: what is gas fee for across - gasFeeUsd: 0, - contractAddress: isFromSol ? ZERO_ADDRESS : res.checks.allowance.spender, - rawQuote: res, + gasFeeUsd, + contractAddress, + rawQuote, protocolFee: 0, platformFeePercent: (params.feeBps * 100) / 10_000, } - } catch (e) { - console.log('Across getQuote error', e) - throw e + } catch (error) { + console.log('Across getQuote error', error) + throw error } } async executeSwap( quote: Quote, walletClient: WalletClient, - _nearWalletClient?: any, + _nearWalletClient?: unknown, _sendBtcFn?: (params: { recipient: string; amount: string | number }) => Promise, _sendTransaction?: WalletAdapterProps['sendTransaction'], _connection?: Connection, ): Promise { + const normalizedQuote = quote.quote + const quoteParams = normalizedQuote.quoteParams + // For EVM chains, use the original implementation return new Promise((resolve, reject) => { this.acrossClient .executeSwapQuote({ - walletClient: walletClient as any, - swapQuote: quote.quote.rawQuote as any, + walletClient: walletClient as AcrossWalletClient, + swapQuote: normalizedQuote.rawQuote as AcrossSwapQuote, onProgress: progress => { if (progress.step === 'swap' && 'txHash' in progress) { resolve({ - sender: quote.quote.quoteParams.sender, + sender: quoteParams.sender, sourceTxHash: progress.txHash, adapter: this.getName(), id: progress.txHash, - sourceChain: quote.quote.quoteParams.fromChain, - targetChain: quote.quote.quoteParams.toChain, - inputAmount: quote.quote.quoteParams.amount, - outputAmount: quote.quote.outputAmount.toString(), - sourceToken: quote.quote.quoteParams.fromToken, - targetToken: quote.quote.quoteParams.toToken, + sourceChain: quoteParams.fromChain, + targetChain: quoteParams.toChain, + inputAmount: quoteParams.amount, + outputAmount: normalizedQuote.outputAmount.toString(), + sourceToken: quoteParams.fromToken, + targetToken: quoteParams.toToken, timestamp: new Date().getTime(), - amountInUsd: quote.quote.inputUsd, - amountOutUsd: quote.quote.outputUsd, - platformFeePercent: quote.quote.platformFeePercent, - recipient: quote.quote.quoteParams.recipient, + amountInUsd: normalizedQuote.inputUsd, + amountOutUsd: normalizedQuote.outputUsd, + platformFeePercent: normalizedQuote.platformFeePercent, + recipient: quoteParams.recipient, }) } }, @@ -232,12 +261,11 @@ export class AcrossAdapter extends BaseSwapAdapter { } async getTransactionStatus(params: NormalizedTxResponse): Promise { try { - const res = await fetch(`https://app.across.to/api/deposit/status?depositTxHash=${params.sourceTxHash}`).then( - res => res.json(), - ) + const res = await getAcrossDepositStatus(params.sourceTxHash) + return { - txHash: res.fillTx || '', - status: res.status === 'refunded' ? 'Refunded' : res.status === 'filled' ? 'Success' : 'Processing', + txHash: getAcrossFillTxHash(res), + status: mapAcrossDepositStatus(res, { txTimestamp: params.timestamp }), } } catch (error) { console.error('Error fetching transaction status:', error) diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/types.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/types.ts new file mode 100644 index 0000000000..e8186a3177 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/types.ts @@ -0,0 +1,27 @@ +import type { AcrossClient, ExecuteQuoteParams, ExecuteSwapQuoteParams } from '@across-protocol/app-sdk' + +export type AcrossDepositStatus = 'pending' | 'filled' | 'expired' | 'refunded' | 'slowFillRequested' + +export interface AcrossDepositStatusResponse { + status?: AcrossDepositStatus + fillTxnRef?: string + fillTx?: string + originChainId?: number + destinationChainId?: number + depositId?: string | number + depositTxnRef?: string + depositRefundTxnRef?: string + actionsSucceeded?: boolean + error?: string + message?: string +} + +export interface AcrossSuggestedFeesQuote { + outputAmount: string + estimatedFillTimeSec: number +} + +export type AcrossSwapQuote = Awaited> +export type AcrossWalletClient = ExecuteQuoteParams['walletClient'] +export type AcrossDeposit = ExecuteQuoteParams['deposit'] +export type AcrossSwapExecutionProgress = Parameters>[0] diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/utils.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/utils.ts new file mode 100644 index 0000000000..17b30a908d --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/AcrossAdapter/utils.ts @@ -0,0 +1,42 @@ +import { AcrossDepositStatusResponse } from 'pages/CrossChainSwap/adapters/AcrossAdapter/types' +import { SwapStatus } from 'pages/CrossChainSwap/adapters/BaseSwapAdapter' + +const ACROSS_STATUS_ERROR_GRACE_PERIOD = 2 * 60 * 60 * 1_000 + +interface AcrossDepositStatusOptions { + txTimestamp?: number + now?: number +} + +export const getAcrossFillTxHash = (statusResponse: AcrossDepositStatusResponse): string => { + return statusResponse.fillTxnRef || statusResponse.fillTx || '' +} + +export const mapAcrossDepositStatus = ( + statusResponse: AcrossDepositStatusResponse, + options: AcrossDepositStatusOptions = {}, +): SwapStatus['status'] => { + if (statusResponse.error) { + const { txTimestamp, now = Date.now() } = options + const isWithinIndexingGracePeriod = txTimestamp ? now - txTimestamp < ACROSS_STATUS_ERROR_GRACE_PERIOD : false + + if (isWithinIndexingGracePeriod) { + return 'Processing' + } + + return 'Failed' + } + + switch (statusResponse.status) { + case 'filled': + return 'Success' + case 'refunded': + return 'Refunded' + case 'expired': + return 'Failed' + case 'pending': + case 'slowFillRequested': + default: + return 'Processing' + } +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter.ts deleted file mode 100644 index 99f74c30c6..0000000000 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter.ts +++ /dev/null @@ -1,790 +0,0 @@ -import { - AcrossClient, - createAcrossClient, - getIntegratorDataSuffix, - parseDepositLogs, - parseFillLogs, - waitForDepositTx, - waitForFillTx, -} from '@across-protocol/app-sdk' -import { ChainId, Currency } from '@kyberswap/ks-sdk-core' -import { WalletAdapterProps } from '@solana/wallet-adapter-base' -import { Connection } from '@solana/web3.js' -import { - type Address, - type Hash, - type Hex, - type TransactionReceipt, - type Chain as ViemChain, - WalletClient, - encodeFunctionData, - maxUint256, - parseAbi, -} from 'viem' -import { - arbitrum, - base, - blast, - bsc, - linea, - mainnet, - monad, - optimism, - plasma, - polygon, - scroll, - unichain, - zksync, -} from 'viem/chains' - -import { NETWORKS_INFO } from 'hooks/useChainsConfig' - -import { Quote } from '../registry' -import { - BaseSwapAdapter, - Chain, - NormalizedQuote, - NormalizedTxResponse, - QuoteParams, - SwapStatus, -} from './BaseSwapAdapter' - -// Integrator ID for Across tracking -const KYBERSWAP_INTEGRATOR_ID: Hex = '0x008a' - -// Chain ID to viem Chain mapping -const chainIdToViemChain: Record = { - [ChainId.MAINNET]: mainnet, - [ChainId.ARBITRUM]: arbitrum, - [ChainId.BSCMAINNET]: bsc, - [ChainId.OPTIMISM]: optimism, - [ChainId.LINEA]: linea, - [ChainId.MATIC]: polygon, - [ChainId.ZKSYNC]: zksync, - [ChainId.BASE]: base, - [ChainId.SCROLL]: scroll, - [ChainId.BLAST]: blast, - [ChainId.UNICHAIN]: unichain, - [ChainId.PLASMA]: plasma, - [ChainId.MONAD]: monad, -} - -// TransferType enum -export enum TransferType { - Approval = 0, - Transfer = 1, - Permit2Approval = 2, -} - -// Type definitions for SwapAndDepositData -export interface Fees { - amount: bigint - recipient: Address -} - -export interface BaseDepositData { - inputToken: Address - outputToken: `0x${string}` // bytes32 - outputAmount: bigint - depositor: Address - recipient: `0x${string}` // bytes32 - destinationChainId: bigint - exclusiveRelayer: `0x${string}` // bytes32 - quoteTimestamp: number - fillDeadline: number - exclusivityParameter: number - message: `0x${string}` -} - -export interface SwapAndDepositData { - submissionFees: Fees - depositData: BaseDepositData - swapToken: Address - exchange: Address - transferType: TransferType - swapTokenAmount: bigint - minExpectedInputTokenAmount: bigint - routerCalldata: `0x${string}` - enableProportionalAdjustment: boolean - spokePool: Address - nonce: bigint -} - -// ABI for SpokePoolPeriphery contract -export const spokePoolPeripheryAbi = [ - { - inputs: [{ internalType: 'contract IPermit2', name: '_permit2', type: 'address' }], - stateMutability: 'nonpayable', - type: 'constructor', - }, - { inputs: [], name: 'InvalidMinExpectedInputAmount', type: 'error' }, - { inputs: [], name: 'InvalidMsgValue', type: 'error' }, - { inputs: [], name: 'InvalidNonce', type: 'error' }, - { inputs: [], name: 'InvalidShortString', type: 'error' }, - { inputs: [], name: 'InvalidSignature', type: 'error' }, - { inputs: [], name: 'MinimumExpectedInputAmount', type: 'error' }, - { inputs: [{ internalType: 'string', name: 'str', type: 'string' }], name: 'StringTooLong', type: 'error' }, - { anonymous: false, inputs: [], name: 'EIP712DomainChanged', type: 'event' }, - { - anonymous: false, - inputs: [ - { indexed: false, internalType: 'address', name: 'exchange', type: 'address' }, - { indexed: false, internalType: 'bytes', name: 'exchangeCalldata', type: 'bytes' }, - { indexed: true, internalType: 'address', name: 'swapToken', type: 'address' }, - { indexed: true, internalType: 'address', name: 'acrossInputToken', type: 'address' }, - { indexed: false, internalType: 'uint256', name: 'swapTokenAmount', type: 'uint256' }, - { indexed: false, internalType: 'uint256', name: 'acrossInputAmount', type: 'uint256' }, - { indexed: true, internalType: 'bytes32', name: 'acrossOutputToken', type: 'bytes32' }, - { indexed: false, internalType: 'uint256', name: 'acrossOutputAmount', type: 'uint256' }, - ], - name: 'SwapBeforeBridge', - type: 'event', - }, - { - inputs: [ - { - components: [ - { - components: [ - { internalType: 'uint256', name: 'amount', type: 'uint256' }, - { internalType: 'address', name: 'recipient', type: 'address' }, - ], - internalType: 'struct SpokePoolPeripheryInterface.Fees', - name: 'submissionFees', - type: 'tuple', - }, - { - components: [ - { internalType: 'address', name: 'inputToken', type: 'address' }, - { internalType: 'bytes32', name: 'outputToken', type: 'bytes32' }, - { internalType: 'uint256', name: 'outputAmount', type: 'uint256' }, - { internalType: 'address', name: 'depositor', type: 'address' }, - { internalType: 'bytes32', name: 'recipient', type: 'bytes32' }, - { internalType: 'uint256', name: 'destinationChainId', type: 'uint256' }, - { internalType: 'bytes32', name: 'exclusiveRelayer', type: 'bytes32' }, - { internalType: 'uint32', name: 'quoteTimestamp', type: 'uint32' }, - { internalType: 'uint32', name: 'fillDeadline', type: 'uint32' }, - { internalType: 'uint32', name: 'exclusivityParameter', type: 'uint32' }, - { internalType: 'bytes', name: 'message', type: 'bytes' }, - ], - internalType: 'struct SpokePoolPeripheryInterface.BaseDepositData', - name: 'depositData', - type: 'tuple', - }, - { internalType: 'address', name: 'swapToken', type: 'address' }, - { internalType: 'address', name: 'exchange', type: 'address' }, - { internalType: 'enum SpokePoolPeripheryInterface.TransferType', name: 'transferType', type: 'uint8' }, - { internalType: 'uint256', name: 'swapTokenAmount', type: 'uint256' }, - { internalType: 'uint256', name: 'minExpectedInputTokenAmount', type: 'uint256' }, - { internalType: 'bytes', name: 'routerCalldata', type: 'bytes' }, - { internalType: 'bool', name: 'enableProportionalAdjustment', type: 'bool' }, - { internalType: 'address', name: 'spokePool', type: 'address' }, - { internalType: 'uint256', name: 'nonce', type: 'uint256' }, - ], - internalType: 'struct SpokePoolPeripheryInterface.SwapAndDepositData', - name: 'swapAndDepositData', - type: 'tuple', - }, - ], - name: 'swapAndBridge', - outputs: [], - stateMutability: 'payable', - type: 'function', - }, -] as const - -// Progress tracking types -type ProgressMeta = ApproveMeta | SwapAndBridgeMeta | FillMeta | undefined - -type ApproveMeta = { - approvalAmount: bigint - spender: Address -} - -type SwapAndBridgeMeta = { - swapAndDepositData: SwapAndDepositData -} - -type FillMeta = { - depositId: bigint -} - -export type SwapAndBridgeProgress = - | { - step: 'approve' - status: 'idle' - } - | { - step: 'approve' - status: 'txPending' - txHash: Hash - meta: ApproveMeta - } - | { - step: 'approve' - status: 'txSuccess' - txReceipt: TransactionReceipt - meta: ApproveMeta - } - | { - step: 'swapAndBridge' - status: 'simulationPending' - meta: SwapAndBridgeMeta - } - | { - step: 'swapAndBridge' - status: 'simulationSuccess' - txRequest: any - meta: SwapAndBridgeMeta - } - | { - step: 'swapAndBridge' - status: 'txPending' - txHash: Hash - txRequest?: any - meta: SwapAndBridgeMeta - } - | { - step: 'swapAndBridge' - status: 'txSuccess' - txReceipt: TransactionReceipt - depositId: bigint - depositLog: ReturnType - meta: SwapAndBridgeMeta - } - | { - step: 'fill' - status: 'pending' - meta: FillMeta - } - | { - step: 'fill' - status: 'txSuccess' - txReceipt: TransactionReceipt - fillTxTimestamp: bigint - actionSuccess: boolean | undefined - fillLog: ReturnType - meta: FillMeta - } - | { - step: 'approve' | 'swapAndBridge' | 'fill' - status: 'error' - error: Error - meta: ProgressMeta - } - -export interface ExecuteSwapAndBridgeParams { - // Wallet and clients - walletClient: WalletClient - originChain: ViemChain - destinationChain: ViemChain - // User address - userAddress: Address - // Swap and bridge data - swapAndDepositData: SwapAndDepositData - // Contract addresses - spokePoolPeripheryAddress: Address - destinationSpokePoolAddress: Address - // Options - isNative?: boolean - infiniteApproval?: boolean - skipAllowanceCheck?: boolean - throwOnError?: boolean - // Progress handler - onProgress?: (progress: SwapAndBridgeProgress) => void -} - -export interface ExecuteSwapAndBridgeResponse { - depositId?: bigint - swapAndBridgeTxReceipt?: TransactionReceipt - fillTxReceipt?: TransactionReceipt - error?: Error -} - -/** - * Transforms raw API quote data (with string values) to properly typed SwapAndDepositData (with bigint values) - * The API returns numeric values as strings, but the contract expects bigint types - */ -function transformSwapAndDepositData(raw: any): SwapAndDepositData { - return { - submissionFees: { - amount: BigInt(raw.submissionFees?.amount || '0'), - recipient: raw.submissionFees?.recipient as Address, - }, - depositData: { - inputToken: raw.depositData?.inputToken as Address, - outputToken: raw.depositData?.outputToken as `0x${string}`, - outputAmount: BigInt(raw.depositData?.outputAmount || '0'), - depositor: raw.depositData?.depositor as Address, - recipient: raw.depositData?.recipient as `0x${string}`, - destinationChainId: BigInt(raw.depositData?.destinationChainId || '0'), - exclusiveRelayer: raw.depositData?.exclusiveRelayer as `0x${string}`, - quoteTimestamp: Number(raw.depositData?.quoteTimestamp || 0), - fillDeadline: Number(raw.depositData?.fillDeadline || 0), - exclusivityParameter: Number(raw.depositData?.exclusivityParameter || 0), - message: raw.depositData?.message as `0x${string}`, - }, - swapToken: raw.swapToken as Address, - exchange: raw.exchange as Address, - transferType: Number(raw.transferType) as TransferType, - swapTokenAmount: BigInt(raw.swapTokenAmount || '0'), - minExpectedInputTokenAmount: BigInt(raw.minExpectedInputTokenAmount || '0'), - routerCalldata: raw.routerCalldata as `0x${string}`, - enableProportionalAdjustment: Boolean(raw.enableProportionalAdjustment), - spokePool: raw.spokePool as Address, - nonce: BigInt(raw.nonce || '0'), - } -} - -export class KyberAcrossAdapter extends BaseSwapAdapter { - private acrossClient: AcrossClient - - constructor() { - super() - this.acrossClient = createAcrossClient({ - integratorId: KYBERSWAP_INTEGRATOR_ID, - chains: [mainnet, arbitrum, bsc, optimism, linea, polygon, zksync, base, scroll, blast, unichain, plasma, monad], - rpcUrls: [ - ChainId.MAINNET, - ChainId.ARBITRUM, - ChainId.BSCMAINNET, - ChainId.OPTIMISM, - ChainId.LINEA, - ChainId.MATIC, - ChainId.ZKSYNC, - ChainId.BASE, - ChainId.SCROLL, - ChainId.BLAST, - ChainId.UNICHAIN, - ChainId.MONAD, - ].reduce((acc, cur) => { - return { ...acc, [cur]: NETWORKS_INFO[cur].defaultRpcUrl } - }, {}), - }) - } - - getName(): string { - return 'KyberAcross' - } - - getIcon(): string { - return 'https://i.ibb.co/fVLsZryT/kyberacross.jpg' - } - - // canSupport returns true for all cases - uses default implementation from BaseSwapAdapter - - getSupportedChains(): Chain[] { - return [ - ChainId.MAINNET, - ChainId.ARBITRUM, - ChainId.OPTIMISM, - ChainId.LINEA, - ChainId.MATIC, - ChainId.ZKSYNC, - ChainId.BASE, - ChainId.SCROLL, - ChainId.BLAST, - ChainId.UNICHAIN, - ChainId.BSCMAINNET, - ChainId.PLASMA, - ChainId.MONAD, - ] - } - - getSupportedTokens(_sourceChain: Chain, _destChain: Chain): Currency[] { - return [] - } - - // getQuote is empty - we use the stream API response for this provider - async getQuote(_params: QuoteParams): Promise { - throw new Error('KyberAcross does not support direct quote fetching. Use stream API response instead.') - } - - async executeSwap( - quote: Quote, - walletClient: WalletClient, - _nearWalletClient?: any, - _sendBtcFn?: (params: { recipient: string; amount: string | number }) => Promise, - _sendTransaction?: WalletAdapterProps['sendTransaction'], - _connection?: Connection, - ): Promise { - const rawQuote = quote.quote.rawQuote - - console.log('rawQuote ======== ', rawQuote) - - // Check if sourceSwap is null - if so, use executeQuote directly to SpokePool - if (!rawQuote.sourceSwap) { - return new Promise((resolve, reject) => { - this.acrossClient - .executeQuote({ - walletClient: walletClient as any, - deposit: rawQuote.bridge.deposit, - onProgress: progress => { - if (progress.step === 'deposit' && 'txHash' in progress) { - resolve({ - sender: quote.quote.quoteParams.sender, - sourceTxHash: progress.txHash, - adapter: this.getName(), - id: progress.txHash, - sourceChain: quote.quote.quoteParams.fromChain, - targetChain: quote.quote.quoteParams.toChain, - inputAmount: quote.quote.quoteParams.amount, - outputAmount: quote.quote.outputAmount.toString(), - sourceToken: quote.quote.quoteParams.fromToken, - targetToken: quote.quote.quoteParams.toToken, - timestamp: new Date().getTime(), - amountInUsd: quote.quote.inputUsd, - amountOutUsd: quote.quote.outputUsd, - platformFeePercent: quote.quote.platformFeePercent, - recipient: quote.quote.quoteParams.recipient, - }) - } - }, - }) - .catch(reject) - }) - } - - // Extract and transform swapAndDepositData from rawQuote - // The API returns numeric values as strings, so we need to convert them to bigints - const swapAndDepositData: SwapAndDepositData = transformSwapAndDepositData(rawQuote.swapAndDepositData) - - // Determine if this is a native token swap, explicitly set in rawQuote - const isNative: boolean = rawQuote.swapAndDepositData?.isNative - - // Get origin chain from quoteParams - const originChainId = quote.quote.quoteParams.fromChain as ChainId - const originChain = chainIdToViemChain[originChainId] - - if (!originChain) { - throw new Error(`Unsupported chain: ${originChainId}`) - } - - // Get destination chain from quoteParams - const destinationChainId = quote.quote.quoteParams.toChain as ChainId - const destinationChain = chainIdToViemChain[destinationChainId] - - if (!destinationChain) { - throw new Error(`Unsupported destination chain: ${destinationChainId}`) - } - - // Get spokePoolPeripheryAddress from rawQuote - const spokePoolPeripheryAddress: Address = rawQuote.spokePoolPeripheryAddress - if (!spokePoolPeripheryAddress) { - throw new Error(`No SpokePoolPeriphery address found for chain: ${originChainId}`) - } - - // Get destinationSpokePoolAddress from rawQuote - const destinationSpokePoolAddress: Address = rawQuote.destinationSpokePoolAddress - if (!destinationSpokePoolAddress) { - throw new Error(`No SpokePool address found for destination chain: ${destinationChainId}`) - } - - // Get user address from quote params - const userAddress = quote.quote.quoteParams.sender as Address - - // Get RPC URL for the origin chain - const rpcUrl = NETWORKS_INFO[originChainId]?.defaultRpcUrl - - if (!rpcUrl) { - throw new Error(`No RPC URL found for chain: ${originChainId}`) - } - - return new Promise((resolve, reject) => { - this.executeSwapAndBridge({ - walletClient, - originChain, - destinationChain, - userAddress, - swapAndDepositData, - spokePoolPeripheryAddress, - destinationSpokePoolAddress, - isNative, - infiniteApproval: false, - skipAllowanceCheck: false, - throwOnError: true, - onProgress: progress => { - if (progress.step === 'swapAndBridge' && 'txHash' in progress) { - resolve({ - sender: quote.quote.quoteParams.sender, - sourceTxHash: progress.txHash, - adapter: this.getName(), - id: progress.txHash, - sourceChain: quote.quote.quoteParams.fromChain, - targetChain: quote.quote.quoteParams.toChain, - inputAmount: quote.quote.quoteParams.amount, - outputAmount: quote.quote.outputAmount.toString(), - sourceToken: quote.quote.quoteParams.fromToken, - targetToken: quote.quote.quoteParams.toToken, - timestamp: new Date().getTime(), - amountInUsd: quote.quote.inputUsd, - amountOutUsd: quote.quote.outputUsd, - platformFeePercent: quote.quote.platformFeePercent, - recipient: quote.quote.quoteParams.recipient, - }) - } - }, - }).catch(reject) - }) - } - - /** - * Executes a swap-and-bridge transaction by: - * 1. Approving the SpokePoolPeriphery contract if necessary - * 2. Executing the swapAndBridge transaction - * 3. Parsing the deposit ID from transaction logs - */ - async executeSwapAndBridge(params: ExecuteSwapAndBridgeParams): Promise { - const { - walletClient, - originChain, - destinationChain, - userAddress, - swapAndDepositData, - spokePoolPeripheryAddress, - destinationSpokePoolAddress, - isNative = false, - infiniteApproval = false, - skipAllowanceCheck = false, - throwOnError = true, - onProgress, - } = params - - const onProgressHandler = onProgress || ((progress: SwapAndBridgeProgress) => console.log('Progress:', progress)) - - let currentProgress: SwapAndBridgeProgress = { - status: 'idle', - step: 'approve', - } - let currentProgressMeta: ProgressMeta - - try { - // Create public clients for reading blockchain state - const originClient = this.acrossClient.getPublicClient(originChain.id) - const destinationClient = this.acrossClient.getPublicClient(destinationChain.id) - - // Get user's nonce for replay protection - const nonce = await originClient.getTransactionCount({ - address: userAddress, - }) - - // Step 1: Check and handle approval if necessary (skip for native ETH) - if (!skipAllowanceCheck && !isNative) { - const allowance = await originClient.readContract({ - address: swapAndDepositData.swapToken, - abi: parseAbi(['function allowance(address owner, address spender) public view returns (uint256)']), - functionName: 'allowance', - args: [userAddress, spokePoolPeripheryAddress], - }) - - if (swapAndDepositData.swapTokenAmount > allowance) { - const approvalAmount = infiniteApproval ? maxUint256 : swapAndDepositData.swapTokenAmount - - currentProgressMeta = { - approvalAmount, - spender: spokePoolPeripheryAddress, - } - - // Execute approval - const approveCalldata = encodeFunctionData({ - abi: parseAbi(['function approve(address spender, uint256 value)']), - args: [spokePoolPeripheryAddress, approvalAmount], - }) - - const approveTxHash = await walletClient.sendTransaction({ - account: walletClient.account || ('0x0' as Address), - chain: originChain, - to: swapAndDepositData.swapToken, - data: approveCalldata, - }) - - currentProgress = { - step: 'approve', - status: 'txPending', - txHash: approveTxHash, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - // Wait for approval confirmation - const approveTxReceipt = await originClient.waitForTransactionReceipt({ - hash: approveTxHash, - }) - - currentProgress = { - step: 'approve', - status: 'txSuccess', - txReceipt: approveTxReceipt, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - } - } - - // Step 2: Execute swapAndBridge - // 1. Simulate the swapAndBridge transaction - // 2. If successful, execute the swapAndBridge transaction - // 3. Wait for the transaction to be mined - currentProgressMeta = { - swapAndDepositData, - } - - // Report simulation pending status - currentProgress = { - step: 'swapAndBridge', - status: 'simulationPending', - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - // Prepare the swapAndBridge args with updated nonce - const swapAndBridgeArgs = { ...swapAndDepositData, nonce: BigInt(nonce) } - - // Encode calldata for Tenderly simulation - const calldata = encodeFunctionData({ - abi: spokePoolPeripheryAbi, - functionName: 'swapAndBridge', - args: [{ ...swapAndBridgeArgs }] as any, - }) - const dataSuffix = getIntegratorDataSuffix(KYBERSWAP_INTEGRATOR_ID) - const fullCalldata = `${calldata}${dataSuffix.slice(2)}` as Hex // Remove 0x from suffix before concatenating - - // Log for Tenderly simulation - console.log('🔵 🔵 🔵 🔵 🔵 🔵 🔵') - console.log('Contract Address:', spokePoolPeripheryAddress) - console.log('Sender (from):', userAddress) - console.log('Value (wei):', isNative ? swapAndDepositData.swapTokenAmount.toString() : '0') - console.log('Calldata:', fullCalldata) - console.log('Chain ID:', originChain.id) - - // Simulate the transaction to catch revert errors with proper decoding - // and get the request object for execution - const { request: txRequest } = await originClient.simulateContract({ - address: spokePoolPeripheryAddress, - abi: spokePoolPeripheryAbi, - functionName: 'swapAndBridge', - args: [{ ...swapAndBridgeArgs }] as any, - account: walletClient.account, - value: isNative ? swapAndDepositData.swapTokenAmount : undefined, - dataSuffix: getIntegratorDataSuffix(KYBERSWAP_INTEGRATOR_ID), - }) - - // Report simulation success status - currentProgress = { - step: 'swapAndBridge', - status: 'simulationSuccess', - txRequest, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - // Execute the transaction using writeContract with the simulated request - const swapAndBridgeTxHash = await walletClient.writeContract(txRequest) - - currentProgress = { - step: 'swapAndBridge', - status: 'txPending', - txHash: swapAndBridgeTxHash, - txRequest, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - // Wait for deposit transaction and parse logs using SDK - const { depositId, depositTxReceipt } = await waitForDepositTx({ - originChainId: originChain.id, - transactionHash: swapAndBridgeTxHash, - publicClient: originClient, - }) - const depositLog = parseDepositLogs(depositTxReceipt.logs) - - currentProgress = { - step: 'swapAndBridge', - status: 'txSuccess', - txReceipt: depositTxReceipt, - depositId, - depositLog, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - // Step 3: Wait for fill on destination chain - currentProgressMeta = { - depositId, - } - currentProgress = { - step: 'fill', - status: 'pending', - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - const destinationBlock = await destinationClient.getBlockNumber() - - const { fillTxReceipt, fillTxTimestamp, actionSuccess } = await waitForFillTx({ - deposit: { - originChainId: originChain.id, - destinationChainId: destinationChain.id, - destinationSpokePoolAddress: destinationSpokePoolAddress, - message: swapAndDepositData.depositData.message, - }, - depositId, - depositTxHash: depositTxReceipt.transactionHash, - destinationChainClient: destinationClient, - fromBlock: destinationBlock - 100n, - }) - - const fillLog = parseFillLogs(fillTxReceipt.logs) - - currentProgress = { - step: 'fill', - status: 'txSuccess', - txReceipt: fillTxReceipt, - fillTxTimestamp, - actionSuccess, - fillLog, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - return { - depositId, - swapAndBridgeTxReceipt: depositTxReceipt, - fillTxReceipt, - } - } catch (error) { - currentProgress = { - ...currentProgress, - status: 'error', - error: error as Error, - meta: currentProgressMeta, - } - onProgressHandler(currentProgress) - - if (!throwOnError) { - return { error: error as Error } - } - - throw error - } - } - - // getTransactionStatus is empty for now - will be added later - async getTransactionStatus(params: NormalizedTxResponse): Promise { - try { - const res = await fetch(`https://app.across.to/api/deposit/status?depositTxHash=${params.sourceTxHash}`).then( - res => res.json(), - ) - return { - txHash: res.fillTx || '', - status: res.status === 'refunded' ? 'Refunded' : res.status === 'filled' ? 'Success' : 'Processing', - } - } catch (error) { - console.error('Error fetching transaction status:', error) - return { - txHash: '', - status: 'Processing', - } - } - } -} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/abi.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/abi.ts new file mode 100644 index 0000000000..c08f2b9735 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/abi.ts @@ -0,0 +1,81 @@ +export const spokePoolPeripheryAbi = [ + { + inputs: [{ internalType: 'contract IPermit2', name: '_permit2', type: 'address' }], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { inputs: [], name: 'InvalidMinExpectedInputAmount', type: 'error' }, + { inputs: [], name: 'InvalidMsgValue', type: 'error' }, + { inputs: [], name: 'InvalidNonce', type: 'error' }, + { inputs: [], name: 'InvalidShortString', type: 'error' }, + { inputs: [], name: 'InvalidSignature', type: 'error' }, + { inputs: [], name: 'MinimumExpectedInputAmount', type: 'error' }, + { inputs: [{ internalType: 'string', name: 'str', type: 'string' }], name: 'StringTooLong', type: 'error' }, + { anonymous: false, inputs: [], name: 'EIP712DomainChanged', type: 'event' }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'address', name: 'exchange', type: 'address' }, + { indexed: false, internalType: 'bytes', name: 'exchangeCalldata', type: 'bytes' }, + { indexed: true, internalType: 'address', name: 'swapToken', type: 'address' }, + { indexed: true, internalType: 'address', name: 'acrossInputToken', type: 'address' }, + { indexed: false, internalType: 'uint256', name: 'swapTokenAmount', type: 'uint256' }, + { indexed: false, internalType: 'uint256', name: 'acrossInputAmount', type: 'uint256' }, + { indexed: true, internalType: 'bytes32', name: 'acrossOutputToken', type: 'bytes32' }, + { indexed: false, internalType: 'uint256', name: 'acrossOutputAmount', type: 'uint256' }, + ], + name: 'SwapBeforeBridge', + type: 'event', + }, + { + inputs: [ + { + components: [ + { + components: [ + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'address', name: 'recipient', type: 'address' }, + ], + internalType: 'struct SpokePoolPeripheryInterface.Fees', + name: 'submissionFees', + type: 'tuple', + }, + { + components: [ + { internalType: 'address', name: 'inputToken', type: 'address' }, + { internalType: 'bytes32', name: 'outputToken', type: 'bytes32' }, + { internalType: 'uint256', name: 'outputAmount', type: 'uint256' }, + { internalType: 'address', name: 'depositor', type: 'address' }, + { internalType: 'bytes32', name: 'recipient', type: 'bytes32' }, + { internalType: 'uint256', name: 'destinationChainId', type: 'uint256' }, + { internalType: 'bytes32', name: 'exclusiveRelayer', type: 'bytes32' }, + { internalType: 'uint32', name: 'quoteTimestamp', type: 'uint32' }, + { internalType: 'uint32', name: 'fillDeadline', type: 'uint32' }, + { internalType: 'uint32', name: 'exclusivityParameter', type: 'uint32' }, + { internalType: 'bytes', name: 'message', type: 'bytes' }, + ], + internalType: 'struct SpokePoolPeripheryInterface.BaseDepositData', + name: 'depositData', + type: 'tuple', + }, + { internalType: 'address', name: 'swapToken', type: 'address' }, + { internalType: 'address', name: 'exchange', type: 'address' }, + { internalType: 'enum SpokePoolPeripheryInterface.TransferType', name: 'transferType', type: 'uint8' }, + { internalType: 'uint256', name: 'swapTokenAmount', type: 'uint256' }, + { internalType: 'uint256', name: 'minExpectedInputTokenAmount', type: 'uint256' }, + { internalType: 'bytes', name: 'routerCalldata', type: 'bytes' }, + { internalType: 'bool', name: 'enableProportionalAdjustment', type: 'bool' }, + { internalType: 'address', name: 'spokePool', type: 'address' }, + { internalType: 'uint256', name: 'nonce', type: 'uint256' }, + ], + internalType: 'struct SpokePoolPeripheryInterface.SwapAndDepositData', + name: 'swapAndDepositData', + type: 'tuple', + }, + ], + name: 'swapAndBridge', + outputs: [], + stateMutability: 'payable', + type: 'function', + }, +] as const diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/constants.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/constants.ts new file mode 100644 index 0000000000..733807f3f3 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/constants.ts @@ -0,0 +1,67 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { type Hex, type Chain as ViemChain } from 'viem' +import { + arbitrum, + base, + blast, + bsc, + linea, + mainnet, + monad, + optimism, + plasma, + polygon, + scroll, + unichain, + zksync, +} from 'viem/chains' + +export const KYBERSWAP_INTEGRATOR_ID: Hex = '0x008a' + +export const kyberAcrossSupportedChains = [ + ChainId.MAINNET, + ChainId.ARBITRUM, + ChainId.OPTIMISM, + ChainId.LINEA, + ChainId.MATIC, + ChainId.ZKSYNC, + ChainId.BASE, + ChainId.SCROLL, + ChainId.BLAST, + ChainId.UNICHAIN, + ChainId.BSCMAINNET, + ChainId.PLASMA, + ChainId.MONAD, +] + +export const kyberAcrossViemChains = [ + mainnet, + arbitrum, + bsc, + optimism, + linea, + polygon, + zksync, + base, + scroll, + blast, + unichain, + plasma, + monad, +] + +export const chainIdToViemChain: Record = { + [ChainId.MAINNET]: mainnet, + [ChainId.ARBITRUM]: arbitrum, + [ChainId.BSCMAINNET]: bsc, + [ChainId.OPTIMISM]: optimism, + [ChainId.LINEA]: linea, + [ChainId.MATIC]: polygon, + [ChainId.ZKSYNC]: zksync, + [ChainId.BASE]: base, + [ChainId.SCROLL]: scroll, + [ChainId.BLAST]: blast, + [ChainId.UNICHAIN]: unichain, + [ChainId.PLASMA]: plasma, + [ChainId.MONAD]: monad, +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/index.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/index.ts new file mode 100644 index 0000000000..f51004a4c9 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/index.ts @@ -0,0 +1,243 @@ +import { AcrossClient, createAcrossClient } from '@across-protocol/app-sdk' +import { ChainId, Currency } from '@kyberswap/ks-sdk-core' +import { WalletAdapterProps } from '@solana/wallet-adapter-base' +import { Connection } from '@solana/web3.js' +import { type Address, type Hash, WalletClient } from 'viem' + +import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import { getAcrossDepositStatus } from 'pages/CrossChainSwap/adapters/AcrossAdapter/api' +import { AcrossWalletClient } from 'pages/CrossChainSwap/adapters/AcrossAdapter/types' +import { getAcrossFillTxHash, mapAcrossDepositStatus } from 'pages/CrossChainSwap/adapters/AcrossAdapter/utils' +import { + BaseSwapAdapter, + Chain, + NormalizedQuote, + NormalizedTxResponse, + QuoteParams, + SwapStatus, +} from 'pages/CrossChainSwap/adapters/BaseSwapAdapter' +import { + KYBERSWAP_INTEGRATOR_ID, + chainIdToViemChain, + kyberAcrossSupportedChains, + kyberAcrossViemChains, +} from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/constants' +import { executeSwapAndBridge } from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/service' +import { + ExecuteSwapAndBridgeParams, + ExecuteSwapAndBridgeResponse, + KyberAcrossRawQuote, + SwapAndDepositData, +} from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/types' +import { transformSwapAndDepositData } from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/utils' +import { Quote } from 'pages/CrossChainSwap/registry' + +export class KyberAcrossAdapter extends BaseSwapAdapter { + private acrossClient: AcrossClient + + constructor() { + super() + this.acrossClient = createAcrossClient({ + integratorId: KYBERSWAP_INTEGRATOR_ID, + chains: kyberAcrossViemChains, + rpcUrls: [ + ChainId.MAINNET, + ChainId.ARBITRUM, + ChainId.BSCMAINNET, + ChainId.OPTIMISM, + ChainId.LINEA, + ChainId.MATIC, + ChainId.ZKSYNC, + ChainId.BASE, + ChainId.SCROLL, + ChainId.BLAST, + ChainId.UNICHAIN, + ChainId.MONAD, + ].reduce((acc, cur) => { + return { ...acc, [cur]: NETWORKS_INFO[cur].defaultRpcUrl } + }, {}), + }) + } + + getName(): string { + return 'KyberAcross' + } + + getIcon(): string { + return 'https://i.ibb.co/fVLsZryT/kyberacross.jpg' + } + + // canSupport returns true for all cases - uses default implementation from BaseSwapAdapter + + getSupportedChains(): Chain[] { + return kyberAcrossSupportedChains + } + + getSupportedTokens(_sourceChain: Chain, _destChain: Chain): Currency[] { + return [] + } + + // getQuote is empty - we use the stream API response for this provider + async getQuote(_params: QuoteParams): Promise { + throw new Error('KyberAcross does not support direct quote fetching. Use stream API response instead.') + } + + async executeSwap( + quote: Quote, + walletClient: WalletClient, + _nearWalletClient?: unknown, + _sendBtcFn?: (params: { recipient: string; amount: string | number }) => Promise, + _sendTransaction?: WalletAdapterProps['sendTransaction'], + _connection?: Connection, + ): Promise { + const normalizedQuote = quote.quote + const quoteParams = normalizedQuote.quoteParams + const rawQuote = normalizedQuote.rawQuote as KyberAcrossRawQuote + + // Check if sourceSwap is null - if so, use executeQuote directly to SpokePool + if (!rawQuote.sourceSwap) { + return new Promise((resolve, reject) => { + this.acrossClient + .executeQuote({ + walletClient: walletClient as AcrossWalletClient, + deposit: rawQuote.bridge.deposit, + onProgress: progress => { + if (progress.step === 'deposit' && 'txHash' in progress) { + resolve({ + sender: quoteParams.sender, + sourceTxHash: progress.txHash, + adapter: this.getName(), + id: progress.txHash, + sourceChain: quoteParams.fromChain, + targetChain: quoteParams.toChain, + inputAmount: quoteParams.amount, + outputAmount: normalizedQuote.outputAmount.toString(), + sourceToken: quoteParams.fromToken, + targetToken: quoteParams.toToken, + timestamp: new Date().getTime(), + amountInUsd: normalizedQuote.inputUsd, + amountOutUsd: normalizedQuote.outputUsd, + platformFeePercent: normalizedQuote.platformFeePercent, + recipient: quoteParams.recipient, + }) + } + }, + }) + .catch(reject) + }) + } + + // Extract and transform swapAndDepositData from rawQuote + // The API returns numeric values as strings, so we need to convert them to bigints + if (!rawQuote.swapAndDepositData) { + throw new Error('No swapAndDepositData found in KyberAcross quote') + } + + const swapAndDepositData: SwapAndDepositData = transformSwapAndDepositData(rawQuote.swapAndDepositData) + + const isNative = rawQuote.swapAndDepositData.isNative || false + + const originChainId = quoteParams.fromChain as ChainId + const originChain = chainIdToViemChain[originChainId] + + if (!originChain) { + throw new Error(`Unsupported chain: ${originChainId}`) + } + + const destinationChainId = quoteParams.toChain as ChainId + const destinationChain = chainIdToViemChain[destinationChainId] + + if (!destinationChain) { + throw new Error(`Unsupported destination chain: ${destinationChainId}`) + } + + const spokePoolPeripheryAddress = rawQuote.spokePoolPeripheryAddress + if (!spokePoolPeripheryAddress) { + throw new Error(`No SpokePoolPeriphery address found for chain: ${originChainId}`) + } + + const destinationSpokePoolAddress = rawQuote.destinationSpokePoolAddress + if (!destinationSpokePoolAddress) { + throw new Error(`No SpokePool address found for destination chain: ${destinationChainId}`) + } + + const userAddress = quoteParams.sender as Address + + const rpcUrl = NETWORKS_INFO[originChainId]?.defaultRpcUrl + + if (!rpcUrl) { + throw new Error(`No RPC URL found for chain: ${originChainId}`) + } + + return new Promise((resolve, reject) => { + this.executeSwapAndBridge({ + walletClient, + originChain, + destinationChain, + userAddress, + swapAndDepositData, + spokePoolPeripheryAddress, + destinationSpokePoolAddress, + isNative, + infiniteApproval: false, + skipAllowanceCheck: false, + throwOnError: true, + onProgress: progress => { + if (progress.step === 'swapAndBridge' && 'txHash' in progress) { + resolve({ + sender: quoteParams.sender, + sourceTxHash: progress.txHash, + adapter: this.getName(), + id: progress.txHash, + sourceChain: quoteParams.fromChain, + targetChain: quoteParams.toChain, + inputAmount: quoteParams.amount, + outputAmount: normalizedQuote.outputAmount.toString(), + sourceToken: quoteParams.fromToken, + targetToken: quoteParams.toToken, + timestamp: new Date().getTime(), + amountInUsd: normalizedQuote.inputUsd, + amountOutUsd: normalizedQuote.outputUsd, + platformFeePercent: normalizedQuote.platformFeePercent, + recipient: quoteParams.recipient, + }) + } + }, + }).catch(reject) + }) + } + + async executeSwapAndBridge(params: ExecuteSwapAndBridgeParams): Promise { + return executeSwapAndBridge(this.acrossClient, params) + } + + async getTransactionStatus(params: NormalizedTxResponse): Promise { + try { + const res = await getAcrossDepositStatus(params.sourceTxHash) + + return { + txHash: getAcrossFillTxHash(res), + status: mapAcrossDepositStatus(res, { txTimestamp: params.timestamp }), + } + } catch (error) { + console.error('Error fetching transaction status:', error) + + const publicClient = this.acrossClient.getPublicClient(params.sourceChain as number) + const receipt = await publicClient.getTransactionReceipt({ + hash: params.sourceTxHash as Hash, + }) + + if (receipt?.status === 'reverted') { + return { + txHash: '', + status: 'Failed', + } + } + + return { + txHash: '', + status: 'Processing', + } + } + } +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/service.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/service.ts new file mode 100644 index 0000000000..7d875fb655 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/service.ts @@ -0,0 +1,245 @@ +import { + AcrossClient, + getIntegratorDataSuffix, + parseDepositLogs, + parseFillLogs, + waitForDepositTx, + waitForFillTx, +} from '@across-protocol/app-sdk' +import { type Hex, encodeFunctionData, maxUint256, parseAbi } from 'viem' + +import { spokePoolPeripheryAbi } from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/abi' +import { KYBERSWAP_INTEGRATOR_ID } from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/constants' +import { + ExecuteSwapAndBridgeParams, + ExecuteSwapAndBridgeResponse, + ProgressMeta, + SwapAndBridgeProgress, +} from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/types' + +export async function executeSwapAndBridge( + acrossClient: AcrossClient, + params: ExecuteSwapAndBridgeParams, +): Promise { + const { + walletClient, + originChain, + destinationChain, + userAddress, + swapAndDepositData, + spokePoolPeripheryAddress, + destinationSpokePoolAddress, + isNative = false, + infiniteApproval = false, + skipAllowanceCheck = false, + throwOnError = true, + onProgress, + } = params + + const onProgressHandler = onProgress || ((progress: SwapAndBridgeProgress) => console.log('Progress:', progress)) + + let currentProgress: SwapAndBridgeProgress = { + status: 'idle', + step: 'approve', + } + let currentProgressMeta: ProgressMeta + + try { + const originClient = acrossClient.getPublicClient(originChain.id) + const destinationClient = acrossClient.getPublicClient(destinationChain.id) + + const nonce = await originClient.getTransactionCount({ + address: userAddress, + }) + + if (!skipAllowanceCheck && !isNative) { + const allowance = await originClient.readContract({ + address: swapAndDepositData.swapToken, + abi: parseAbi(['function allowance(address owner, address spender) public view returns (uint256)']), + functionName: 'allowance', + args: [userAddress, spokePoolPeripheryAddress], + }) + + if (swapAndDepositData.swapTokenAmount > allowance) { + const approvalAmount = infiniteApproval ? maxUint256 : swapAndDepositData.swapTokenAmount + + currentProgressMeta = { + approvalAmount, + spender: spokePoolPeripheryAddress, + } + + const approveCalldata = encodeFunctionData({ + abi: parseAbi(['function approve(address spender, uint256 value)']), + args: [spokePoolPeripheryAddress, approvalAmount], + }) + + if (!walletClient.account) { + throw new Error('Wallet account not connected') + } + + const approveTxHash = await walletClient.sendTransaction({ + account: walletClient.account, + chain: originChain, + to: swapAndDepositData.swapToken, + data: approveCalldata, + }) + + currentProgress = { + step: 'approve', + status: 'txPending', + txHash: approveTxHash, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + const approveTxReceipt = await originClient.waitForTransactionReceipt({ + hash: approveTxHash, + }) + + currentProgress = { + step: 'approve', + status: 'txSuccess', + txReceipt: approveTxReceipt, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + } + } + + currentProgressMeta = { + swapAndDepositData, + } + + currentProgress = { + step: 'swapAndBridge', + status: 'simulationPending', + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + const swapAndBridgeArgs = { ...swapAndDepositData, nonce: BigInt(nonce) } + + // Encode calldata for Tenderly simulation + const calldata = encodeFunctionData({ + abi: spokePoolPeripheryAbi, + functionName: 'swapAndBridge', + args: [{ ...swapAndBridgeArgs }], + }) + const dataSuffix = getIntegratorDataSuffix(KYBERSWAP_INTEGRATOR_ID) + const fullCalldata = `${calldata}${dataSuffix.slice(2)}` as Hex // Remove 0x from suffix before concatenating + + // Log for Tenderly simulation + console.log('🔵 🔵 🔵 🔵 🔵 🔵 🔵') + console.log('Contract Address:', spokePoolPeripheryAddress) + console.log('Sender (from):', userAddress) + console.log('Value (wei):', isNative ? swapAndDepositData.swapTokenAmount.toString() : '0') + console.log('Calldata:', fullCalldata) + console.log('Chain ID:', originChain.id) + + const { request: txRequest } = await originClient.simulateContract({ + address: spokePoolPeripheryAddress, + abi: spokePoolPeripheryAbi, + functionName: 'swapAndBridge', + args: [{ ...swapAndBridgeArgs }], + account: walletClient.account, + value: isNative ? swapAndDepositData.swapTokenAmount : undefined, + dataSuffix: getIntegratorDataSuffix(KYBERSWAP_INTEGRATOR_ID), + }) + + currentProgress = { + step: 'swapAndBridge', + status: 'simulationSuccess', + txRequest, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + const swapAndBridgeTxHash = await walletClient.writeContract(txRequest) + + currentProgress = { + step: 'swapAndBridge', + status: 'txPending', + txHash: swapAndBridgeTxHash, + txRequest, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + const { depositId, depositTxReceipt } = await waitForDepositTx({ + originChainId: originChain.id, + transactionHash: swapAndBridgeTxHash, + publicClient: originClient, + }) + const depositLog = parseDepositLogs(depositTxReceipt.logs) + + currentProgress = { + step: 'swapAndBridge', + status: 'txSuccess', + txReceipt: depositTxReceipt, + depositId, + depositLog, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + currentProgressMeta = { + depositId, + } + currentProgress = { + step: 'fill', + status: 'pending', + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + const destinationBlock = await destinationClient.getBlockNumber() + + const { fillTxReceipt, fillTxTimestamp, actionSuccess } = await waitForFillTx({ + deposit: { + originChainId: originChain.id, + destinationChainId: destinationChain.id, + destinationSpokePoolAddress: destinationSpokePoolAddress, + message: swapAndDepositData.depositData.message, + }, + depositId, + depositTxHash: depositTxReceipt.transactionHash, + destinationChainClient: destinationClient, + fromBlock: destinationBlock - 100n, + }) + + const fillLog = parseFillLogs(fillTxReceipt.logs) + + currentProgress = { + step: 'fill', + status: 'txSuccess', + txReceipt: fillTxReceipt, + fillTxTimestamp, + actionSuccess, + fillLog, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + return { + depositId, + swapAndBridgeTxReceipt: depositTxReceipt, + fillTxReceipt, + } + } catch (error) { + const executeError = error instanceof Error ? error : new Error(String(error)) + + currentProgress = { + ...currentProgress, + status: 'error', + error: executeError, + meta: currentProgressMeta, + } + onProgressHandler(currentProgress) + + if (!throwOnError) { + return { error: executeError } + } + + throw error + } +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/types.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/types.ts new file mode 100644 index 0000000000..8b6942e4a8 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/types.ts @@ -0,0 +1,186 @@ +import { parseDepositLogs, parseFillLogs } from '@across-protocol/app-sdk' +import { type Address, type Hash, type Hex, type TransactionReceipt, type Chain as ViemChain, WalletClient } from 'viem' + +import { AcrossDeposit } from 'pages/CrossChainSwap/adapters/AcrossAdapter/types' + +export enum TransferType { + Approval = 0, + Transfer = 1, + Permit2Approval = 2, +} + +export interface Fees { + amount: bigint + recipient: Address +} + +export interface BaseDepositData { + inputToken: Address + outputToken: Hex + outputAmount: bigint + depositor: Address + recipient: Hex + destinationChainId: bigint + exclusiveRelayer: Hex + quoteTimestamp: number + fillDeadline: number + exclusivityParameter: number + message: Hex +} + +export interface SwapAndDepositData { + submissionFees: Fees + depositData: BaseDepositData + swapToken: Address + exchange: Address + transferType: TransferType + swapTokenAmount: bigint + minExpectedInputTokenAmount: bigint + routerCalldata: Hex + enableProportionalAdjustment: boolean + spokePool: Address + nonce: bigint +} + +export interface RawBaseDepositData { + inputToken?: Address + outputToken?: Hex + outputAmount?: string + depositor?: Address + recipient?: Hex + destinationChainId?: string + exclusiveRelayer?: Hex + quoteTimestamp?: string | number + fillDeadline?: string | number + exclusivityParameter?: string | number + message?: Hex +} + +export interface RawSwapAndDepositData { + submissionFees?: { + amount?: string + recipient?: Address + } + depositData?: RawBaseDepositData + swapToken?: Address + exchange?: Address + transferType?: TransferType | string | number + swapTokenAmount?: string + minExpectedInputTokenAmount?: string + routerCalldata?: Hex + enableProportionalAdjustment?: boolean + spokePool?: Address + nonce?: string + isNative?: boolean +} + +export interface KyberAcrossRawQuote { + sourceSwap?: unknown | null + bridge: { + deposit: AcrossDeposit + } + swapAndDepositData?: RawSwapAndDepositData + spokePoolPeripheryAddress?: Address + destinationSpokePoolAddress?: Address +} + +export type ProgressMeta = ApproveMeta | SwapAndBridgeMeta | FillMeta | undefined + +export type ApproveMeta = { + approvalAmount: bigint + spender: Address +} + +export type SwapAndBridgeMeta = { + swapAndDepositData: SwapAndDepositData +} + +export type FillMeta = { + depositId: bigint +} + +export type SwapAndBridgeProgress = + | { + step: 'approve' + status: 'idle' + } + | { + step: 'approve' + status: 'txPending' + txHash: Hash + meta: ApproveMeta + } + | { + step: 'approve' + status: 'txSuccess' + txReceipt: TransactionReceipt + meta: ApproveMeta + } + | { + step: 'swapAndBridge' + status: 'simulationPending' + meta: SwapAndBridgeMeta + } + | { + step: 'swapAndBridge' + status: 'simulationSuccess' + txRequest: unknown + meta: SwapAndBridgeMeta + } + | { + step: 'swapAndBridge' + status: 'txPending' + txHash: Hash + txRequest?: unknown + meta: SwapAndBridgeMeta + } + | { + step: 'swapAndBridge' + status: 'txSuccess' + txReceipt: TransactionReceipt + depositId: bigint + depositLog: ReturnType + meta: SwapAndBridgeMeta + } + | { + step: 'fill' + status: 'pending' + meta: FillMeta + } + | { + step: 'fill' + status: 'txSuccess' + txReceipt: TransactionReceipt + fillTxTimestamp: bigint + actionSuccess: boolean | undefined + fillLog: ReturnType + meta: FillMeta + } + | { + step: 'approve' | 'swapAndBridge' | 'fill' + status: 'error' + error: Error + meta: ProgressMeta + } + +export interface ExecuteSwapAndBridgeParams { + walletClient: WalletClient + originChain: ViemChain + destinationChain: ViemChain + userAddress: Address + swapAndDepositData: SwapAndDepositData + spokePoolPeripheryAddress: Address + destinationSpokePoolAddress: Address + isNative?: boolean + infiniteApproval?: boolean + skipAllowanceCheck?: boolean + throwOnError?: boolean + onProgress?: (progress: SwapAndBridgeProgress) => void +} + +export interface ExecuteSwapAndBridgeResponse { + depositId?: bigint + swapAndBridgeTxReceipt?: TransactionReceipt + fillTxReceipt?: TransactionReceipt + error?: Error +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/utils.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/utils.ts new file mode 100644 index 0000000000..4239f39420 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberAcrossAdapter/utils.ts @@ -0,0 +1,38 @@ +import { type Address, type Hex } from 'viem' + +import { + RawSwapAndDepositData, + SwapAndDepositData, + TransferType, +} from 'pages/CrossChainSwap/adapters/KyberAcrossAdapter/types' + +export function transformSwapAndDepositData(raw: RawSwapAndDepositData): SwapAndDepositData { + return { + submissionFees: { + amount: BigInt(raw.submissionFees?.amount || '0'), + recipient: raw.submissionFees?.recipient as Address, + }, + depositData: { + inputToken: raw.depositData?.inputToken as Address, + outputToken: raw.depositData?.outputToken as Hex, + outputAmount: BigInt(raw.depositData?.outputAmount || '0'), + depositor: raw.depositData?.depositor as Address, + recipient: raw.depositData?.recipient as Hex, + destinationChainId: BigInt(raw.depositData?.destinationChainId || '0'), + exclusiveRelayer: raw.depositData?.exclusiveRelayer as Hex, + quoteTimestamp: Number(raw.depositData?.quoteTimestamp || 0), + fillDeadline: Number(raw.depositData?.fillDeadline || 0), + exclusivityParameter: Number(raw.depositData?.exclusivityParameter || 0), + message: raw.depositData?.message as Hex, + }, + swapToken: raw.swapToken as Address, + exchange: raw.exchange as Address, + transferType: Number(raw.transferType) as TransferType, + swapTokenAmount: BigInt(raw.swapTokenAmount || '0'), + minExpectedInputTokenAmount: BigInt(raw.minExpectedInputTokenAmount || '0'), + routerCalldata: raw.routerCalldata as Hex, + enableProportionalAdjustment: Boolean(raw.enableProportionalAdjustment), + spokePool: raw.spokePool as Address, + nonce: BigInt(raw.nonce || '0'), + } +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/api.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/api.ts new file mode 100644 index 0000000000..672c4f3efd --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/api.ts @@ -0,0 +1,288 @@ +import axios, { type AxiosRequestConfig } from 'axios' +import { type Address, type Hash, type Hex } from 'viem' + +import { CROSSCHAIN_KYBERCROSS_API } from 'constants/env' + +type RequestId = string +type UIntString = string +type TokenReference = string +type JsonObject = Record + +export type ChainName = 'ethereum' | 'arbitrum' | 'base' | 'bsc' +export type BridgeProvider = 'across' | 'relay' | 'mayan' | 'near_intents' +type FlowType = 'bridge_only' | 'swap_then_bridge' +type RouteStatus = + | 'built' + | 'source_pending' + | 'source_confirmed' + | 'bridge_pending' + | 'destination_pending' + | 'completed' + | 'refunded' + | 'failed' +type ActionType = 'wrap_native' | 'unwrap_native' | 'transfer' + +export type QuoteRequest = { + from_chain: ChainName + from_token: Address + from_token_decimals: number + from_address: Address + to_chain: ChainName + to_token: Address + to_token_decimals: number + amount: UIntString + to_address?: Address + refund_address?: Address + slippage_bps?: number + client_fee_recipient?: Address + client_fee_bps?: number + include_bridges?: BridgeProvider[] + exclude_bridges?: BridgeProvider[] +} + +type RoutePlanRequestSnapshot = { + from_chain: ChainName + from_token: Address + from_token_decimals: number + from_address: Address + to_chain: ChainName + to_token: Address + to_token_decimals: number + to_address: Address + amount: UIntString + slippage_bps: number + refund_address?: Address + client_fee_recipient?: Address + client_fee_bps?: number +} + +type FeePlan = { + type: 'client' + chain: ChainName + token: TokenReference + recipient: Address + rate_bps: number + charged_on: 'bridge_input' + expected_amount?: UIntString + min_amount?: UIntString +} + +type SwapPlan = { + token_in: TokenReference + token_out: TokenReference + input_amount: UIntString + expected_output_amount: UIntString + min_output_amount: UIntString + metadata: { + route_id: string + route_summary: JsonObject + } +} + +type ActionPlan = { + type: ActionType + token_in: TokenReference + token_out: TokenReference + recipient?: Address +} + +type AcrossSpokePoolBridgeMetadata = { + settlement?: 'spoke_pool' + spoke_pool: Address + input_amount: UIntString + output_amount: UIntString + destination_chain_id: number + depositor: Address + recipient: Address + exclusive_relayer?: Address + quote_timestamp?: number + fill_deadline?: number + exclusivity_parameter?: number + message?: string | null + quote_expiry_timestamp?: number +} + +type AcrossExecutionBridgeMetadata = { + settlement: 'cctp' | 'oft' + input_amount: UIntString + output_amount: UIntString + destination_chain_id: number + execution_target: Address + execution_data: string + quote_expiry_timestamp?: number + execution_value?: UIntString | null +} + +type MayanBridgeMetadata = { + mayan_forwarder: Address + mayan_protocol: Address + protocol_data: string +} + +export type NearIntentsBridgeMetadata = { + deposit_address: string +} + +export type BridgeMetadata = + | AcrossSpokePoolBridgeMetadata + | AcrossExecutionBridgeMetadata + | MayanBridgeMetadata + | NearIntentsBridgeMetadata + +type BridgePlan = { + lane_id: string + provider: BridgeProvider + asset_group: string + token_in: TokenReference + token_out: TokenReference + input_amount: UIntString + expected_output_amount: UIntString + min_output_amount: UIntString + metadata: BridgeMetadata + provider_fee?: UIntString + expected_fill_time_sec?: number +} + +export type RoutePlan = { + route_id: string + request: RoutePlanRequestSnapshot + flow_type: FlowType + expected_output_amount: UIntString + min_output_amount: UIntString + expires_at: string + bridge: BridgePlan + provider?: string + status?: RouteStatus + updated_at?: string + fees?: FeePlan[] + source_swap?: SwapPlan + pre_bridge?: ActionPlan[] + post_bridge?: ActionPlan[] +} + +type SwapDetails = { + token_in: Address + token_out: Address + amount_in: UIntString + amount_out: UIntString +} + +type OnChainBridgeDetails = { + tx_hash: Hash + token: Address + amount: UIntString +} + +type BridgeDetails = { + source: OnChainBridgeDetails + destination?: OnChainBridgeDetails +} + +type RouteExecutionDetails = { + source_swap?: SwapDetails + bridge?: BridgeDetails + dest_swap?: SwapDetails +} + +export type TrackingExecution = { + route_id: string + sender: Address + receiver: Address + source_chain: ChainName + dest_chain: ChainName + flow_type: FlowType + source_tx_hash: Hash + route_state: RouteStatus + route_state_details: RouteExecutionDetails + created_at: string + updated_at: string + dest_tx_hash?: Hash | null + route_plan?: RoutePlan +} + +export type ExecutionTx = { + to: Address + data: Hex + value: UIntString + gas?: UIntString +} + +export type BuildResult = { + tx: ExecutionTx + expires_at?: string +} + +type ErrorBody = { + code: string + message: string + details?: JsonObject +} + +export type SuccessResponse = { + request_id: RequestId + success: true + data: TData +} + +type ErrorResponse = { + request_id: RequestId + success: false + error: ErrorBody +} + +export type ApiResponse = SuccessResponse | ErrorResponse + +export type QuoteResponse = SuccessResponse +export type BuildResponse = SuccessResponse +export type ScanTxStatusResponse = SuccessResponse + +export type ScanTxStatusParams = { + include_route_plan?: boolean +} + +const kyberCrossApiClient = axios.create({ + baseURL: CROSSCHAIN_KYBERCROSS_API, + headers: { + 'Content-Type': 'application/json', + }, +}) + +const call = async (config: AxiosRequestConfig): Promise> => { + const { data, status } = await kyberCrossApiClient.request>({ + validateStatus: () => true, + ...config, + }) + + if (status < 200 || status >= 300 || !data.success) { + throw new Error(data.success ? `KyberCross API failed with HTTP ${status}` : data.error.message) + } + + return data +} + +const getQuote = (data: QuoteRequest): Promise => + call({ + method: 'POST', + url: '/api/v1/quotes', + data, + }) + +const build = (data: RoutePlan): Promise => + call({ + method: 'POST', + url: '/api/v1/builds', + data, + }) + +const scanTxStatus = (txHash: Hash, params?: ScanTxStatusParams): Promise => + call({ + method: 'GET', + url: `/api/v1/scan/tx/${txHash}`, + params, + }) + +export const kyberCrossApi = { + getQuote, + build, + scanTxStatus, +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/index.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/index.ts new file mode 100644 index 0000000000..8fac0b436d --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/index.ts @@ -0,0 +1,226 @@ +import { ChainId, Currency } from '@kyberswap/ks-sdk-core' +import { WalletAdapterProps } from '@solana/wallet-adapter-base' +import { Connection } from '@solana/web3.js' +import { type Address, type Hash, WalletClient, formatUnits } from 'viem' + +import kyberswapIcon from 'assets/images/kyberswap.ico' +import { CROSS_CHAIN_FEE_RECEIVER, ETHER_ADDRESS, ZERO_ADDRESS } from 'constants/index' +import { + BaseSwapAdapter, + Chain, + NormalizedQuote, + NormalizedTxResponse, + QuoteParams, + SwapProvider, + SwapStatus, +} from 'pages/CrossChainSwap/adapters/BaseSwapAdapter' +import { + type NearIntentsBridgeMetadata, + type QuoteRequest, + kyberCrossApi, +} from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/api' +import { executeKyberCross } from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/service' +import { + type KyberCrossRawQuote, + chainIdToKyberCrossChainName, + chainIdToViemChain, + kyberCrossSupportedChains, +} from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/types' +import { + NormalizedProvider, + getKyberCrossBridgeProviders, + mapRouteStateToSwapStatus, + normalizeProvider, +} from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/utils' +import { Quote } from 'pages/CrossChainSwap/registry' + +// ============================================ +// KyberCrossAdapter +// ============================================ + +const getKyberCrossChainName = (chainId: Chain): QuoteRequest['from_chain'] => { + const chainName = chainIdToKyberCrossChainName[chainId as ChainId] + if (!chainName) throw new Error(`Unsupported KyberCross chain: ${chainId}`) + + return chainName +} + +const getKyberCrossTokenAddress = (token: Currency): Address => + (token.isNative ? ETHER_ADDRESS : token.wrapped.address) as Address + +export class KyberCrossAdapter extends BaseSwapAdapter { + constructor(private readonly getAdapterByName?: (name?: string) => SwapProvider | undefined) { + super() + } + + getName(): string { + return 'KyberCross' + } + + getIcon(): string { + return kyberswapIcon + } + + getSupportedChains(): Chain[] { + return kyberCrossSupportedChains + } + + getSupportedTokens(_sourceChain: Chain, _destChain: Chain): Currency[] { + return [] + } + + async getQuote(params: QuoteParams): Promise { + const request: QuoteRequest = { + from_chain: getKyberCrossChainName(params.fromChain), + from_token: getKyberCrossTokenAddress(params.fromToken as Currency), + from_token_decimals: params.fromToken.decimals, + from_address: params.sender as Address, + to_chain: getKyberCrossChainName(params.toChain), + to_token: getKyberCrossTokenAddress(params.toToken as Currency), + to_token_decimals: params.toToken.decimals, + to_address: params.recipient as Address, + refund_address: params.sender as Address, + amount: params.amount, + slippage_bps: params.slippage, + client_fee_bps: params.feeBps, + include_bridges: getKyberCrossBridgeProviders(params.includedSources), + exclude_bridges: getKyberCrossBridgeProviders(params.excludedSources), + } + + if (params.feeBps > 0) { + request.client_fee_recipient = CROSS_CHAIN_FEE_RECEIVER as Address + } + + const quoteResponse = await kyberCrossApi.getQuote(request) + const routePlan = quoteResponse.data + const outputAmount = BigInt(routePlan.expected_output_amount) + const formattedOutputAmount = formatUnits(outputAmount, params.toToken.decimals) + const formattedInputAmount = formatUnits(BigInt(params.amount), params.fromToken.decimals) + const inputUsd = params.tokenInUsd * +formattedInputAmount + const outputUsd = params.tokenOutUsd * +formattedOutputAmount + const rawQuote: KyberCrossRawQuote = { + request_id: quoteResponse.request_id, + data: { + route_plan: routePlan, + }, + isNativeToken: (params.fromToken as Currency).isNative, + } + + return { + quoteParams: params, + outputAmount, + formattedOutputAmount, + inputUsd, + outputUsd, + rate: +formattedOutputAmount / +formattedInputAmount, + timeEstimate: routePlan.bridge.expected_fill_time_sec || 0, + priceImpact: !inputUsd || !outputUsd ? NaN : ((inputUsd - outputUsd) * 100) / inputUsd, + gasFeeUsd: 0, + contractAddress: ZERO_ADDRESS, + rawQuote, + protocolFee: 0, + platformFeePercent: (params.feeBps * 100) / 10_000, + } + } + + async executeSwap( + quote: Quote, + walletClient: WalletClient, + _nearWalletClient?: unknown, + _sendBtcFn?: (params: { recipient: string; amount: string | number }) => Promise, + _sendTransaction?: WalletAdapterProps['sendTransaction'], + _connection?: Connection, + ): Promise { + const normalizedQuote = quote.quote + const quoteParams = normalizedQuote.quoteParams + const rawQuote = normalizedQuote.rawQuote as KyberCrossRawQuote + const routePlan = rawQuote.data?.route_plan + + if (!routePlan) { + throw new Error('Missing KyberCross route plan') + } + + const routeProvider = routePlan.bridge.provider || routePlan.provider + const normalizedRouteProvider = normalizeProvider(routeProvider) + const buildResponse = await kyberCrossApi.build(routePlan) + const buildTx = buildResponse.data.tx + + const originChainId = quoteParams.fromChain as ChainId + const originChain = chainIdToViemChain[originChainId] + if (!originChain) throw new Error(`Unsupported chain: ${originChainId}`) + + const fromToken = quoteParams.fromToken as Currency + const isNativeToken = rawQuote.isNativeToken || fromToken.isNative + const bridgeMetadata = routePlan.bridge.metadata + const nearIntentsDepositAddress = + normalizedRouteProvider === NormalizedProvider.NearIntents + ? (bridgeMetadata as NearIntentsBridgeMetadata)?.deposit_address + : undefined + + const txHash = await executeKyberCross({ + walletClient, + originChain, + userAddress: quoteParams.sender as Address, + buildTx, + inputToken: (isNativeToken ? ZERO_ADDRESS : fromToken.wrapped.address) as Address, + inputAmount: BigInt(quoteParams.amount), + isNativeToken, + infiniteApproval: false, + }) + + return { + sender: quoteParams.sender, + sourceTxHash: txHash, + adapter: this.getName(), + id: nearIntentsDepositAddress || txHash, + sourceChain: quoteParams.fromChain, + targetChain: quoteParams.toChain, + inputAmount: quoteParams.amount, + outputAmount: normalizedQuote.outputAmount.toString(), + sourceToken: quoteParams.fromToken, + targetToken: quoteParams.toToken, + timestamp: new Date().getTime(), + amountInUsd: normalizedQuote.inputUsd, + amountOutUsd: normalizedQuote.outputUsd, + platformFeePercent: normalizedQuote.platformFeePercent, + recipient: quoteParams.recipient, + bridgeProvider: routeProvider, + routeId: routePlan.route_id, + } + } + + async getTransactionStatus(params: NormalizedTxResponse): Promise { + try { + const trackingExecution = await kyberCrossApi.scanTxStatus(params.sourceTxHash as Hash) + + return mapRouteStateToSwapStatus(trackingExecution.data) + } catch (error) { + // Fallback to delegated adapter/source receipt when KyberCross scan does not have this tx yet. + } + + const provider = normalizeProvider(params.bridgeProvider) + const adapter = this.getAdapterByName?.(provider) + + if (adapter && normalizeProvider(adapter.getName()) !== NormalizedProvider.KyberCross) { + const delegatedId = provider === NormalizedProvider.NearIntents ? params.id : params.routeId || params.id + + if (!delegatedId) { + return { + txHash: '', + status: 'Processing', + } + } + + return adapter.getTransactionStatus({ + ...params, + adapter: adapter.getName(), + id: delegatedId, + }) + } + + return { + txHash: '', + status: 'Processing', + } + } +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/openapi.yaml b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/openapi.yaml new file mode 100644 index 0000000000..9b65b2bdf1 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/openapi.yaml @@ -0,0 +1,895 @@ +openapi: 3.0.3 +info: + title: KyberCross Quote / Build API + version: 1.0.0 + description: | + OpenAPI YAML for the current public quote/build routes. + + Notes: + - Runtime JSON uses `route_id` for the route identifier. + - `to_address` defaults to `from_address` on `/api/v1/quotes` and `/api/v1/quote-and-build` when omitted. + - `refund_address` defaults to `from_address` on `/api/v1/quotes` and `/api/v1/quote-and-build` when omitted. + - `include_bridges` and `exclude_bridges` are request-only filters and are not echoed inside `route_plan.request`. + - JSON byte fields such as `message`, `execution_data`, and `protocol_data` are base64 strings. + +servers: + - url: https://pre-kybercross.kyberengineering.io + +paths: + /api/v1/quotes: + post: + tags: [quote] + operationId: postQuote + summary: Quote best route + description: | + Returns the best current route and a public route plan that can be sent to `/api/v1/builds`. + + Notes: + - `client_fee_recipient` is required when `client_fee_bps > 0`. + - If the same provider appears in both `include_bridges` and `exclude_bridges`, exclusion wins. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuoteRequest' + responses: + '200': + description: Quote created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/QuoteSuccessResponse' + '400': + description: Invalid request or unsupported input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected internal or upstream failure + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/builds: + post: + tags: [build] + operationId: postBuild + summary: Build executable transaction + description: | + Builds the execution transaction from a client-supplied route plan. + + Notes: + - The request body should be the exact `data` returned by `/api/v1/quotes`, + or the `route_plan` object returned by `/api/v1/quote-and-build`. + - Build revalidates expiry, flow shape, metadata, route continuity, and policy gates. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoutePlan' + responses: + '200': + description: Build created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BuildSuccessResponse' + '400': + description: Invalid or expired route plan + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected internal or upstream failure + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/quote-and-build: + post: + tags: [quote-and-build] + operationId: postQuoteAndBuild + summary: Quote then build + description: | + Convenience endpoint that runs the same quote flow as `/api/v1/quotes` + and then the same build flow as `/api/v1/builds`. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuoteRequest' + responses: + '200': + description: Quote and build completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/QuoteAndBuildSuccessResponse' + '400': + description: Invalid request or invalid intermediate route plan + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected internal or upstream failure + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/v1/scan/tx/{txHash}: + get: + tags: [scan] + operationId: getScanTx + summary: Get route execution by transaction hash + description: | + Returns the public tracking execution for a source or destination transaction hash. + + Source transaction hashes are checked before destination transaction hashes. + Set `include_route_plan=true` to include the stored route plan in the response data. + parameters: + - name: txHash + in: path + required: true + description: Source or destination transaction hash. The value is normalized to lowercase. + schema: + $ref: '#/components/schemas/HexHash' + - name: include_route_plan + in: query + required: false + description: Include the optional `route_plan` object in the tracking execution payload. + schema: + type: boolean + default: false + responses: + '200': + description: Tracking execution found successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TrackingExecutionSuccessResponse' + '400': + description: Invalid transaction hash or query parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Tracking execution not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected internal failure + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + schemas: + RequestId: + type: string + description: Server-generated correlation ID. + example: req_01J123ABCXYZ + + JsonObject: + type: object + additionalProperties: true + description: Arbitrary JSON object. + + UIntString: + type: string + pattern: '^[0-9]+$' + description: Unsigned integer encoded as a decimal string. + example: '1000000' + + HexAddress: + type: string + pattern: '^0x[a-fA-F0-9]{40}$' + description: EVM hex address. + example: '0x1111111111111111111111111111111111111111' + + HexHash: + type: string + pattern: '^0x[a-fA-F0-9]{64}$' + description: Hex-encoded transaction hash. + example: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + + HexAddressOrNativeToken: + type: string + description: | + EVM token address or the native-token sentinel + `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`. + example: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + + TokenReference: + type: string + description: Provider-facing token identifier. + + ChainName: + type: string + enum: [ethereum, arbitrum, base, bsc] + + BridgeProvider: + type: string + enum: [across, relay, mayan, near_intents] + + FlowType: + type: string + enum: [bridge_only, swap_then_bridge] + + RouteStatus: + type: string + enum: + - built + - source_pending + - source_confirmed + - bridge_pending + - destination_pending + - completed + - refunded + - failed + + ActionType: + type: string + enum: [wrap_native, unwrap_native, transfer] + + QuoteRequest: + type: object + additionalProperties: false + required: + - from_chain + - from_token + - from_token_decimals + - from_address + - to_chain + - to_token + - to_token_decimals + - amount + properties: + from_chain: + $ref: '#/components/schemas/ChainName' + from_token: + $ref: '#/components/schemas/HexAddressOrNativeToken' + from_token_decimals: + type: integer + minimum: 0 + maximum: 255 + from_address: + $ref: '#/components/schemas/HexAddress' + to_chain: + $ref: '#/components/schemas/ChainName' + to_token: + $ref: '#/components/schemas/HexAddressOrNativeToken' + to_token_decimals: + type: integer + minimum: 0 + maximum: 255 + to_address: + allOf: + - $ref: '#/components/schemas/HexAddress' + description: Defaults to `from_address` when omitted or empty. + amount: + $ref: '#/components/schemas/UIntString' + refund_address: + allOf: + - $ref: '#/components/schemas/HexAddress' + description: Defaults to `from_address` when omitted or empty. + slippage_bps: + type: integer + minimum: 0 + maximum: 10000 + default: 0 + client_fee_recipient: + allOf: + - $ref: '#/components/schemas/HexAddress' + description: Required when `client_fee_bps` is greater than zero. + client_fee_bps: + type: integer + minimum: 0 + maximum: 10000 + default: 0 + include_bridges: + type: array + items: + $ref: '#/components/schemas/BridgeProvider' + exclude_bridges: + type: array + items: + $ref: '#/components/schemas/BridgeProvider' + + RoutePlanRequestSnapshot: + type: object + additionalProperties: false + required: + - from_chain + - from_token + - from_token_decimals + - from_address + - to_chain + - to_token + - to_token_decimals + - to_address + - amount + - slippage_bps + description: Normalized request snapshot embedded in a route plan. + properties: + from_chain: + $ref: '#/components/schemas/ChainName' + from_token: + $ref: '#/components/schemas/HexAddressOrNativeToken' + from_token_decimals: + type: integer + minimum: 0 + maximum: 255 + from_address: + $ref: '#/components/schemas/HexAddress' + to_chain: + $ref: '#/components/schemas/ChainName' + to_token: + $ref: '#/components/schemas/HexAddressOrNativeToken' + to_token_decimals: + type: integer + minimum: 0 + maximum: 255 + to_address: + $ref: '#/components/schemas/HexAddress' + amount: + $ref: '#/components/schemas/UIntString' + refund_address: + $ref: '#/components/schemas/HexAddress' + slippage_bps: + type: integer + minimum: 0 + maximum: 10000 + client_fee_recipient: + $ref: '#/components/schemas/HexAddress' + client_fee_bps: + type: integer + minimum: 0 + maximum: 10000 + + FeePlan: + type: object + additionalProperties: false + required: + - type + - chain + - token + - recipient + - rate_bps + - charged_on + description: Current v1 fees are client fees charged on bridge input. + properties: + type: + type: string + enum: [client] + chain: + $ref: '#/components/schemas/ChainName' + token: + $ref: '#/components/schemas/TokenReference' + recipient: + $ref: '#/components/schemas/HexAddress' + rate_bps: + type: integer + minimum: 0 + maximum: 10000 + expected_amount: + $ref: '#/components/schemas/UIntString' + min_amount: + $ref: '#/components/schemas/UIntString' + charged_on: + type: string + enum: [bridge_input] + + SwapMetadata: + type: object + additionalProperties: false + required: + - route_id + - route_summary + properties: + route_id: + type: string + route_summary: + $ref: '#/components/schemas/JsonObject' + + SwapPlan: + type: object + additionalProperties: false + required: + - token_in + - token_out + - input_amount + - expected_output_amount + - min_output_amount + - metadata + properties: + token_in: + $ref: '#/components/schemas/TokenReference' + token_out: + $ref: '#/components/schemas/TokenReference' + input_amount: + $ref: '#/components/schemas/UIntString' + expected_output_amount: + $ref: '#/components/schemas/UIntString' + min_output_amount: + $ref: '#/components/schemas/UIntString' + metadata: + $ref: '#/components/schemas/SwapMetadata' + + ActionPlan: + type: object + additionalProperties: false + required: + - type + - token_in + - token_out + description: "`recipient` is used for `transfer`." + properties: + type: + $ref: '#/components/schemas/ActionType' + token_in: + $ref: '#/components/schemas/TokenReference' + token_out: + $ref: '#/components/schemas/TokenReference' + recipient: + $ref: '#/components/schemas/HexAddress' + + AcrossSpokePoolBridgeMetadata: + type: object + additionalProperties: false + description: | + Across metadata for spoke-pool settlement. + `message` is base64-encoded when present. + properties: + settlement: + type: string + enum: [spoke_pool] + spoke_pool: + $ref: '#/components/schemas/HexAddress' + input_amount: + $ref: '#/components/schemas/UIntString' + output_amount: + $ref: '#/components/schemas/UIntString' + destination_chain_id: + type: integer + depositor: + $ref: '#/components/schemas/HexAddress' + recipient: + $ref: '#/components/schemas/HexAddress' + exclusive_relayer: + $ref: '#/components/schemas/HexAddress' + quote_timestamp: + type: integer + format: int64 + fill_deadline: + type: integer + format: int64 + exclusivity_parameter: + type: integer + format: int64 + message: + type: string + format: byte + nullable: true + quote_expiry_timestamp: + type: integer + format: int64 + required: + - spoke_pool + - input_amount + - output_amount + - destination_chain_id + - depositor + - recipient + + AcrossExecutionBridgeMetadata: + type: object + additionalProperties: false + description: | + Across metadata for execution-calldata settlement (`cctp` or `oft`). + `execution_data` is base64-encoded. + required: + - settlement + - input_amount + - output_amount + - destination_chain_id + - execution_target + - execution_data + properties: + settlement: + type: string + enum: [cctp, oft] + input_amount: + $ref: '#/components/schemas/UIntString' + output_amount: + $ref: '#/components/schemas/UIntString' + destination_chain_id: + type: integer + quote_expiry_timestamp: + type: integer + format: int64 + execution_target: + $ref: '#/components/schemas/HexAddress' + execution_data: + type: string + format: byte + execution_value: + allOf: + - $ref: '#/components/schemas/UIntString' + nullable: true + + MayanBridgeMetadata: + type: object + additionalProperties: false + description: Mayan bridge metadata. `protocol_data` is base64-encoded. + required: + - mayan_forwarder + - mayan_protocol + - protocol_data + properties: + mayan_forwarder: + $ref: '#/components/schemas/HexAddress' + mayan_protocol: + $ref: '#/components/schemas/HexAddress' + protocol_data: + type: string + format: byte + + NearIntentsBridgeMetadata: + type: object + additionalProperties: false + required: + - deposit_address + properties: + deposit_address: + type: string + description: Provider deposit address. + + BridgeMetadata: + description: Provider-specific bridge metadata. Shape depends on `bridge.provider`. + oneOf: + - $ref: '#/components/schemas/AcrossSpokePoolBridgeMetadata' + - $ref: '#/components/schemas/AcrossExecutionBridgeMetadata' + - $ref: '#/components/schemas/MayanBridgeMetadata' + - $ref: '#/components/schemas/NearIntentsBridgeMetadata' + + BridgePlan: + type: object + additionalProperties: false + required: + - lane_id + - provider + - asset_group + - token_in + - token_out + - input_amount + - expected_output_amount + - min_output_amount + - metadata + properties: + lane_id: + type: string + provider: + $ref: '#/components/schemas/BridgeProvider' + asset_group: + type: string + description: Canonical asset group such as `usdc` or `eth`. + token_in: + $ref: '#/components/schemas/TokenReference' + token_out: + $ref: '#/components/schemas/TokenReference' + input_amount: + $ref: '#/components/schemas/UIntString' + expected_output_amount: + $ref: '#/components/schemas/UIntString' + min_output_amount: + $ref: '#/components/schemas/UIntString' + provider_fee: + $ref: '#/components/schemas/UIntString' + expected_fill_time_sec: + type: number + format: double + metadata: + $ref: '#/components/schemas/BridgeMetadata' + + RoutePlan: + type: object + additionalProperties: false + description: Public route-plan contract shared between quote and build. + required: + - route_id + - request + - flow_type + - expected_output_amount + - min_output_amount + - expires_at + - bridge + properties: + route_id: + type: string + description: Canonical route ID. Current build validation expects UUID v7. + request: + $ref: '#/components/schemas/RoutePlanRequestSnapshot' + flow_type: + $ref: '#/components/schemas/FlowType' + expected_output_amount: + $ref: '#/components/schemas/UIntString' + min_output_amount: + $ref: '#/components/schemas/UIntString' + expires_at: + type: string + format: date-time + status: + $ref: '#/components/schemas/RouteStatus' + updated_at: + type: string + format: date-time + fees: + type: array + items: + $ref: '#/components/schemas/FeePlan' + source_swap: + $ref: '#/components/schemas/SwapPlan' + pre_bridge: + type: array + items: + $ref: '#/components/schemas/ActionPlan' + bridge: + $ref: '#/components/schemas/BridgePlan' + post_bridge: + type: array + items: + $ref: '#/components/schemas/ActionPlan' + + RouteExecutionDetails: + type: object + additionalProperties: false + description: Execution details for the current route state. + properties: + source_swap: + $ref: '#/components/schemas/SwapDetails' + bridge: + $ref: '#/components/schemas/BridgeDetails' + dest_swap: + $ref: '#/components/schemas/SwapDetails' + + SwapDetails: + type: object + additionalProperties: false + required: + - token_in + - token_out + - amount_in + - amount_out + properties: + token_in: + $ref: '#/components/schemas/HexAddress' + token_out: + $ref: '#/components/schemas/HexAddress' + amount_in: + $ref: '#/components/schemas/UIntString' + amount_out: + $ref: '#/components/schemas/UIntString' + + BridgeDetails: + type: object + additionalProperties: false + required: + - source + properties: + source: + $ref: '#/components/schemas/OnChainBridgeDetails' + destination: + $ref: '#/components/schemas/OnChainBridgeDetails' + + OnChainBridgeDetails: + type: object + additionalProperties: false + required: + - tx_hash + - token + - amount + properties: + tx_hash: + $ref: '#/components/schemas/HexHash' + token: + $ref: '#/components/schemas/HexAddress' + amount: + $ref: '#/components/schemas/UIntString' + + TrackingExecution: + type: object + additionalProperties: false + required: + - route_id + - sender + - receiver + - source_chain + - dest_chain + - flow_type + - source_tx_hash + - route_state + - route_state_details + - created_at + - updated_at + properties: + route_id: + type: string + sender: + $ref: '#/components/schemas/HexAddress' + receiver: + $ref: '#/components/schemas/HexAddress' + source_chain: + $ref: '#/components/schemas/ChainName' + dest_chain: + $ref: '#/components/schemas/ChainName' + flow_type: + $ref: '#/components/schemas/FlowType' + source_tx_hash: + $ref: '#/components/schemas/HexHash' + dest_tx_hash: + allOf: + - $ref: '#/components/schemas/HexHash' + nullable: true + route_plan: + $ref: '#/components/schemas/RoutePlan' + route_state: + $ref: '#/components/schemas/RouteStatus' + route_state_details: + $ref: '#/components/schemas/RouteExecutionDetails' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + ExecutionTx: + type: object + additionalProperties: false + required: + - to + - data + - value + properties: + to: + $ref: '#/components/schemas/HexAddress' + data: + type: string + description: Hex-encoded calldata. + example: '0xdeadbeef' + value: + $ref: '#/components/schemas/UIntString' + gas: + $ref: '#/components/schemas/UIntString' + + BuildResult: + type: object + additionalProperties: false + required: + - tx + properties: + expires_at: + type: string + format: date-time + tx: + $ref: '#/components/schemas/ExecutionTx' + + QuoteAndBuildResult: + type: object + additionalProperties: false + required: + - route_plan + - build + properties: + route_plan: + $ref: '#/components/schemas/RoutePlan' + build: + $ref: '#/components/schemas/BuildResult' + + ErrorBody: + type: object + additionalProperties: false + required: + - code + - message + properties: + code: + type: string + example: invalid_argument + message: + type: string + details: + $ref: '#/components/schemas/JsonObject' + + ErrorResponse: + type: object + additionalProperties: false + required: + - request_id + - success + - error + properties: + request_id: + $ref: '#/components/schemas/RequestId' + success: + type: boolean + example: false + error: + $ref: '#/components/schemas/ErrorBody' + + QuoteSuccessResponse: + type: object + additionalProperties: false + required: + - request_id + - success + - data + properties: + request_id: + $ref: '#/components/schemas/RequestId' + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/RoutePlan' + + BuildSuccessResponse: + type: object + additionalProperties: false + required: + - request_id + - success + - data + properties: + request_id: + $ref: '#/components/schemas/RequestId' + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/BuildResult' + + QuoteAndBuildSuccessResponse: + type: object + additionalProperties: false + required: + - request_id + - success + - data + properties: + request_id: + $ref: '#/components/schemas/RequestId' + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/QuoteAndBuildResult' + + TrackingExecutionSuccessResponse: + type: object + additionalProperties: false + required: + - request_id + - success + - data + properties: + request_id: + $ref: '#/components/schemas/RequestId' + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/TrackingExecution' diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/service.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/service.ts new file mode 100644 index 0000000000..09276b5766 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/service.ts @@ -0,0 +1,98 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { + type Address, + type Hash, + type Chain as ViemChain, + WalletClient, + createPublicClient, + encodeFunctionData, + http, + maxUint256, + parseAbi, +} from 'viem' + +import { NETWORKS_INFO } from 'hooks/useChainsConfig' +import type { ExecutionTx } from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/api' + +export interface ExecuteParams { + walletClient: WalletClient + originChain: ViemChain + userAddress: Address + buildTx: ExecutionTx + inputToken: Address + inputAmount: bigint + isNativeToken: boolean + infiniteApproval?: boolean +} + +export const executeKyberCross = async (params: ExecuteParams): Promise => { + const { + walletClient, + originChain, + userAddress, + buildTx, + inputToken, + inputAmount, + isNativeToken, + infiniteApproval = false, + } = params + const value = BigInt(buildTx.value) + const account = walletClient.account + + if (!account) { + throw new Error('Wallet account not connected') + } + + const rpcUrl = NETWORKS_INFO[originChain.id as ChainId]?.defaultRpcUrl + if (!rpcUrl) { + throw new Error(`No RPC URL found for chain: ${originChain.id}`) + } + + const originClient = createPublicClient({ + chain: originChain, + transport: http(rpcUrl), + }) + + if (!isNativeToken) { + const allowance = await originClient.readContract({ + address: inputToken, + abi: parseAbi(['function allowance(address owner, address spender) public view returns (uint256)']), + functionName: 'allowance', + args: [userAddress, buildTx.to], + }) + + if (inputAmount > allowance) { + const approvalAmount = infiniteApproval ? maxUint256 : inputAmount + const approveCalldata = encodeFunctionData({ + abi: parseAbi(['function approve(address spender, uint256 value)']), + args: [buildTx.to, approvalAmount], + }) + + const approveTxHash = await walletClient.sendTransaction({ + account, + chain: originChain, + to: inputToken, + data: approveCalldata, + }) + + await originClient.waitForTransactionReceipt({ + hash: approveTxHash, + }) + } + } + + await originClient.call({ + to: buildTx.to, + data: buildTx.data, + value, + account, + }) + + return walletClient.sendTransaction({ + account, + to: buildTx.to, + data: buildTx.data, + value, + chain: originChain, + }) +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/types.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/types.ts new file mode 100644 index 0000000000..246909ec94 --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/types.ts @@ -0,0 +1,68 @@ +import { ChainId } from '@kyberswap/ks-sdk-core' +import { type Chain as ViemChain } from 'viem' +import { + arbitrum, + base, + blast, + bsc, + linea, + mainnet, + monad, + optimism, + plasma, + polygon, + scroll, + unichain, + zksync, +} from 'viem/chains' + +import type { ChainName, RoutePlan } from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/api' + +export const kyberCrossSupportedChains = [ + ChainId.MAINNET, + ChainId.ARBITRUM, + ChainId.OPTIMISM, + ChainId.LINEA, + ChainId.MATIC, + ChainId.ZKSYNC, + ChainId.BASE, + ChainId.SCROLL, + ChainId.BLAST, + ChainId.UNICHAIN, + ChainId.BSCMAINNET, + ChainId.PLASMA, + ChainId.MONAD, +] + +export const chainIdToViemChain: Record = { + [ChainId.MAINNET]: mainnet, + [ChainId.ARBITRUM]: arbitrum, + [ChainId.BSCMAINNET]: bsc, + [ChainId.OPTIMISM]: optimism, + [ChainId.LINEA]: linea, + [ChainId.MATIC]: polygon, + [ChainId.ZKSYNC]: zksync, + [ChainId.BASE]: base, + [ChainId.SCROLL]: scroll, + [ChainId.BLAST]: blast, + [ChainId.UNICHAIN]: unichain, + [ChainId.PLASMA]: plasma, + [ChainId.MONAD]: monad, +} + +export const chainIdToKyberCrossChainName: Partial> = { + [ChainId.MAINNET]: 'ethereum', + [ChainId.ARBITRUM]: 'arbitrum', + [ChainId.BASE]: 'base', + [ChainId.BSCMAINNET]: 'bsc', +} + +export type KyberCrossResponseData = { + route_plan?: RoutePlan +} + +export type KyberCrossRawQuote = { + request_id?: string + data?: KyberCrossResponseData + isNativeToken?: boolean +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/utils.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/utils.ts new file mode 100644 index 0000000000..d3e36a4e3a --- /dev/null +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossAdapter/utils.ts @@ -0,0 +1,63 @@ +import type { SwapStatus } from 'pages/CrossChainSwap/adapters/BaseSwapAdapter' +import type { BridgeProvider, TrackingExecution } from 'pages/CrossChainSwap/adapters/KyberCrossAdapter/api' +import { normalizeAdapterName } from 'pages/CrossChainSwap/utils' + +export enum NormalizedProvider { + Across = 'across', + Relay = 'relay', + XyFinance = 'xyfinance', + NearIntents = 'nearintents', + Mayan = 'mayan', + Symbiosis = 'symbiosis', + Debridge = 'debridge', + Lifi = 'lifi', + Optimex = 'optimex', + KyberAcross = 'kyberacross', + KyberCross = 'kybercross', +} + +const normalizedProviderMap: Record = Object.values(NormalizedProvider).reduce( + (acc, provider) => ({ ...acc, [provider]: provider }), + {}, +) + +export const normalizeProvider = (provider?: string): NormalizedProvider | undefined => { + const normalizedProvider = normalizeAdapterName(provider) + + return normalizedProvider ? normalizedProviderMap[normalizedProvider] : undefined +} + +const kyberCrossBridgeProviderMap: Partial> = { + [NormalizedProvider.Across]: 'across', + [NormalizedProvider.Relay]: 'relay', + [NormalizedProvider.Mayan]: 'mayan', + [NormalizedProvider.NearIntents]: 'near_intents', +} + +export const getKyberCrossBridgeProviders = (sources?: string[]): BridgeProvider[] | undefined => { + const providers = + sources + ?.map(source => { + const normalizedProvider = normalizeProvider(source) + return normalizedProvider ? kyberCrossBridgeProviderMap[normalizedProvider] : undefined + }) + .filter((provider): provider is BridgeProvider => !!provider) || [] + + return providers.length ? providers : undefined +} + +export const mapRouteStateToSwapStatus = (trackingExecution: TrackingExecution): SwapStatus => { + const txHash = + trackingExecution.dest_tx_hash || trackingExecution.route_state_details.bridge?.destination?.tx_hash || '' + + switch (trackingExecution.route_state) { + case 'completed': + return { txHash, status: 'Success' } + case 'refunded': + return { txHash, status: 'Refunded' } + case 'failed': + return { txHash, status: 'Failed' } + default: + return { txHash, status: 'Processing' } + } +} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossChainAdapter.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossChainAdapter.ts deleted file mode 100644 index 67e8879f92..0000000000 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/KyberCrossChainAdapter.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { parseDepositLogs, parseFillLogs, waitForDepositTx, waitForFillTx } from '@across-protocol/app-sdk' -import { ChainId, Currency } from '@kyberswap/ks-sdk-core' -import { WalletAdapterProps } from '@solana/wallet-adapter-base' -import { Connection } from '@solana/web3.js' -import { - type Address, - type Hash, - type Hex, - type TransactionReceipt, - type Chain as ViemChain, - WalletClient, - createPublicClient, - encodeFunctionData, - http, - maxUint256, - parseAbi, -} from 'viem' -import { - arbitrum, - base, - blast, - bsc, - linea, - mainnet, - monad, - optimism, - plasma, - polygon, - scroll, - unichain, - zksync, -} from 'viem/chains' - -import { NETWORKS_INFO } from 'hooks/useChainsConfig' - -import { Quote } from '../registry' -import { - BaseSwapAdapter, - Chain, - NormalizedQuote, - NormalizedTxResponse, - QuoteParams, - SwapStatus, -} from './BaseSwapAdapter' - -// Chain ID to viem Chain mapping -const chainIdToViemChain: Record = { - [ChainId.MAINNET]: mainnet, - [ChainId.ARBITRUM]: arbitrum, - [ChainId.BSCMAINNET]: bsc, - [ChainId.OPTIMISM]: optimism, - [ChainId.LINEA]: linea, - [ChainId.MATIC]: polygon, - [ChainId.ZKSYNC]: zksync, - [ChainId.BASE]: base, - [ChainId.SCROLL]: scroll, - [ChainId.BLAST]: blast, - [ChainId.UNICHAIN]: unichain, - [ChainId.PLASMA]: plasma, - [ChainId.MONAD]: monad, -} - -// ============================================ -// Progress Tracking Types -// ============================================ - -type ApproveMeta = { - approvalAmount: bigint - spender: Address -} - -export type CrossChainExecuteProgress = - | { step: 'approve'; status: 'checking' } - | { step: 'approve'; status: 'txPending'; txHash: Hash; meta: ApproveMeta } - | { step: 'approve'; status: 'txSuccess'; txReceipt: TransactionReceipt; meta: ApproveMeta } - | { step: 'ksExecute'; status: 'simulationPending' } - | { step: 'ksExecute'; status: 'simulationSuccess'; txRequest: any } - | { step: 'ksExecute'; status: 'txPending'; txHash: Hash } - | { step: 'ksExecute'; status: 'txSuccess'; txReceipt: TransactionReceipt; depositId: bigint; depositLog: any } - | { step: 'fill'; status: 'pending'; depositId: bigint } - | { - step: 'fill' - status: 'txSuccess' - txReceipt: TransactionReceipt - fillTxTimestamp: bigint - actionSuccess: boolean | undefined - fillLog: ReturnType - } - | { step: 'approve' | 'ksExecute' | 'fill'; status: 'error'; error: Error } - -// ============================================ -// Execute Parameters -// ============================================ - -export interface CrossChainExecuteResponse { - depositId?: bigint - swapAndBridgeTxReceipt?: TransactionReceipt - fillTxReceipt?: TransactionReceipt - error?: Error -} - -export interface ExecuteParams { - walletClient: WalletClient - originChain: ViemChain - destinationChain: ViemChain - userAddress: Address - to: Address - txData: Hex - value: bigint - destinationSpokePoolAddress: Address - message: Hex // Needed for fill monitoring - // Approval params - inputToken: Address - inputAmount: bigint - isNativeToken: boolean - infiniteApproval?: boolean - // Options - throwOnError?: boolean - onProgress?: (progress: CrossChainExecuteProgress) => void -} - -// ============================================ -// KyberCrossChainAdapter -// ============================================ - -export class KyberCrossChainAdapter extends BaseSwapAdapter { - getName(): string { - return 'KyberAcross' - } - - getIcon(): string { - return 'https://i.ibb.co/fVLsZryT/kyberacross.jpg' - } - - getSupportedChains(): Chain[] { - return [ - ChainId.MAINNET, - ChainId.ARBITRUM, - ChainId.OPTIMISM, - ChainId.LINEA, - ChainId.MATIC, - ChainId.ZKSYNC, - ChainId.BASE, - ChainId.SCROLL, - ChainId.BLAST, - ChainId.UNICHAIN, - ChainId.BSCMAINNET, - ChainId.PLASMA, - ChainId.MONAD, - ] - } - - getSupportedTokens(_sourceChain: Chain, _destChain: Chain): Currency[] { - return [] - } - - // getQuote is empty - we use the stream API response for this provider - async getQuote(_params: QuoteParams): Promise { - throw new Error('KyberCrossChain does not support direct quote fetching. Use stream API response instead.') - } - - async executeSwap( - quote: Quote, - walletClient: WalletClient, - _nearWalletClient?: any, - _sendBtcFn?: (params: { recipient: string; amount: string | number }) => Promise, - _sendTransaction?: WalletAdapterProps['sendTransaction'], - _connection?: Connection, - ): Promise { - const rawQuote = quote.quote.rawQuote - - console.log('KyberCrossChainAdapter rawQuote ======== ', rawQuote) - - // Validate new required fields from API - const to = rawQuote.to as Address - const txData = rawQuote.txData as Hex - const value = BigInt(rawQuote.value || '0') - const destinationSpokePoolAddress = rawQuote.destinationSpokePoolAddress as Address - - if (!to || !txData) { - throw new Error('Missing required transaction data (to, txData)') - } - - if (!destinationSpokePoolAddress) { - throw new Error('Missing destinationSpokePoolAddress') - } - - const originChainId = quote.quote.quoteParams.fromChain as ChainId - const originChain = chainIdToViemChain[originChainId] - if (!originChain) throw new Error(`Unsupported chain: ${originChainId}`) - - const destinationChainId = quote.quote.quoteParams.toChain as ChainId - const destinationChain = chainIdToViemChain[destinationChainId] - if (!destinationChain) throw new Error(`Unsupported destination chain: ${destinationChainId}`) - - // Extract message for fill monitoring (from bridge data if available) - const message = (rawQuote.bridge?.deposit?.message || '0x') as Hex - - // Get user address - const userAddress = quote.quote.quoteParams.sender as Address - - // Get input token info for approval - const fromToken = quote.quote.quoteParams.fromToken as any - const isNativeToken = rawQuote.isNativeToken || fromToken?.isNative || false - const inputToken = isNativeToken - ? ('0x0000000000000000000000000000000000000000' as Address) - : ((fromToken?.wrapped?.address || fromToken?.address) as Address) - const inputAmount = BigInt(quote.quote.quoteParams.amount) - - return new Promise((resolve, reject) => { - this.execute({ - walletClient, - originChain, - destinationChain, - userAddress, - to, - txData, - value, - destinationSpokePoolAddress, - message, - inputToken, - inputAmount, - isNativeToken, - infiniteApproval: false, - throwOnError: true, - onProgress: progress => { - if (progress.step === 'ksExecute' && 'txHash' in progress) { - resolve({ - sender: quote.quote.quoteParams.sender, - sourceTxHash: progress.txHash, - adapter: this.getName(), - id: progress.txHash, - sourceChain: quote.quote.quoteParams.fromChain, - targetChain: quote.quote.quoteParams.toChain, - inputAmount: quote.quote.quoteParams.amount, - outputAmount: quote.quote.outputAmount.toString(), - sourceToken: quote.quote.quoteParams.fromToken, - targetToken: quote.quote.quoteParams.toToken, - timestamp: new Date().getTime(), - amountInUsd: quote.quote.inputUsd, - amountOutUsd: quote.quote.outputUsd, - platformFeePercent: quote.quote.platformFeePercent, - recipient: quote.quote.quoteParams.recipient, - }) - } - }, - }).catch(reject) - }) - } - - /** - * Executes a cross-chain swap transaction - * Flow: - * 1. Checks and handles token approval to AllowanceHub (if ERC20) - * 2. Sends pre-encoded transaction to the target contract (AllowanceHub) - * 3. Monitors for fill on destination chain - * - * Note: Transaction data (to, txData, value) is pre-encoded by the backend API - */ - async execute(params: ExecuteParams): Promise { - const { - walletClient, - originChain, - destinationChain, - userAddress, - to, - txData, - value, - destinationSpokePoolAddress, - message, - inputToken, - inputAmount, - isNativeToken, - infiniteApproval = false, - throwOnError = false, - onProgress, - } = params - - const rpcUrl = NETWORKS_INFO[originChain.id as ChainId]?.defaultRpcUrl - if (!rpcUrl) { - throw new Error(`No RPC URL found for chain: ${originChain.id}`) - } - - const originClient = createPublicClient({ - chain: originChain, - transport: http(rpcUrl), - }) - - try { - // --- Step 1: Check and handle approval if necessary (skip for native tokens) --- - if (!isNativeToken) { - onProgress?.({ - step: 'approve', - status: 'checking', - }) - - const allowance = await originClient.readContract({ - address: inputToken, - abi: parseAbi(['function allowance(address owner, address spender) public view returns (uint256)']), - functionName: 'allowance', - args: [userAddress, to], // `to` is the AllowanceHub address - }) - - if (inputAmount > allowance) { - const approvalAmount = infiniteApproval ? maxUint256 : inputAmount - - if (!walletClient.account) { - throw new Error('Wallet account not connected') - } - - // Execute approval to AllowanceHub - const approveCalldata = encodeFunctionData({ - abi: parseAbi(['function approve(address spender, uint256 value)']), - args: [to, approvalAmount], // Approve AllowanceHub (the `to` address) - }) - - const approveTxHash = await walletClient.sendTransaction({ - account: walletClient.account, - chain: originChain, - to: inputToken, - data: approveCalldata, - }) - - onProgress?.({ - step: 'approve', - status: 'txPending', - txHash: approveTxHash, - meta: { approvalAmount, spender: to }, - }) - - // Wait for approval confirmation - const approveTxReceipt = await originClient.waitForTransactionReceipt({ - hash: approveTxHash, - }) - - onProgress?.({ - step: 'approve', - status: 'txSuccess', - txReceipt: approveTxReceipt, - meta: { approvalAmount, spender: to }, - }) - } - } - - // --- Step 2: Report simulation pending --- - onProgress?.({ - step: 'ksExecute', - status: 'simulationPending', - }) - - // --- Simulate transaction --- - await originClient.call({ - to, - data: txData, - value, - account: walletClient.account, - }) - - onProgress?.({ - step: 'ksExecute', - status: 'simulationSuccess', - txRequest: { to, data: txData, value }, - }) - - // --- Execute transaction --- - if (!walletClient.account) { - throw new Error('Wallet account not connected') - } - - const txHash = await walletClient.sendTransaction({ - account: walletClient.account, - to, - data: txData, - value, - chain: originChain, - }) - - onProgress?.({ - step: 'ksExecute', - status: 'txPending', - txHash, - }) - - // --- Wait for deposit tx and parse deposit from logs --- - const { depositId, depositTxReceipt } = await waitForDepositTx({ - transactionHash: txHash, - originChainId: originChain.id, - publicClient: originClient, - }) - - const depositLog = parseDepositLogs(depositTxReceipt.logs) - - onProgress?.({ - step: 'ksExecute', - status: 'txSuccess', - txReceipt: depositTxReceipt, - depositId, - depositLog, - }) - - // --- Step 3: Wait for fill on destination chain --- - onProgress?.({ - step: 'fill', - status: 'pending', - depositId, - }) - - const destRpcUrl = NETWORKS_INFO[destinationChain.id as ChainId]?.defaultRpcUrl - if (!destRpcUrl) { - throw new Error(`No RPC URL found for destination chain: ${destinationChain.id}`) - } - - const destClient = createPublicClient({ - chain: destinationChain, - transport: http(destRpcUrl), - }) - - const destinationBlock = await destClient.getBlockNumber() - - const { fillTxReceipt, fillTxTimestamp } = await waitForFillTx({ - deposit: { - originChainId: originChain.id, - destinationChainId: destinationChain.id, - destinationSpokePoolAddress, - message, - }, - depositId, - depositTxHash: depositTxReceipt.transactionHash, - destinationChainClient: destClient, - fromBlock: destinationBlock - 100n, - }) - - const fillLog = parseFillLogs(fillTxReceipt.logs) - - // Note: actionSuccess from SDK checks MulticallHandler events. - // Since we use our own BridgeAdapter (not MulticallHandler), - // we consider the action successful if the fill tx succeeded. - // The BridgeAdapter reverts on failure, so tx success = action success. - const actionSuccessOverride = true - - onProgress?.({ - step: 'fill', - status: 'txSuccess', - txReceipt: fillTxReceipt, - fillTxTimestamp, - actionSuccess: actionSuccessOverride, - fillLog, - }) - - return { - depositId, - swapAndBridgeTxReceipt: depositTxReceipt, - fillTxReceipt, - } - } catch (error: any) { - onProgress?.({ - step: 'ksExecute', - status: 'error', - error, - }) - - if (throwOnError) { - throw error - } - - return { - depositId: undefined, - swapAndBridgeTxReceipt: undefined, - fillTxReceipt: undefined, - error, - } - } - } - - async getTransactionStatus(params: NormalizedTxResponse): Promise { - try { - const res = await fetch(`https://app.across.to/api/deposit/status?depositTxHash=${params.sourceTxHash}`).then( - res => res.json(), - ) - return { - txHash: res.fillTx || '', - status: res.status === 'refunded' ? 'Refunded' : res.status === 'filled' ? 'Success' : 'Processing', - } - } catch (error) { - console.error('Error fetching transaction status:', error) - return { - txHash: '', - status: 'Processing', - } - } - } -} diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/index.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/index.ts index 3a013f6284..c8078fdbb7 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/index.ts +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/index.ts @@ -2,7 +2,7 @@ export * from './AcrossAdapter' export * from './BaseSwapAdapter' export * from './DebridgeAdapter' export * from './KyberAcrossAdapter' -export * from './KyberCrossChainAdapter' +export * from './KyberCrossAdapter' export * from './KyberSwapAdapter' export * from './LifiAdapter' export * from './MayanAdapter' diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/types.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/types.ts index b1d5f378bc..0fe84ce861 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/types.ts +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/adapters/types.ts @@ -66,6 +66,8 @@ export interface QuoteParams { sender: string recipient: string publicKey?: string + includedSources?: string[] + excludedSources?: string[] } export interface EvmQuoteParams extends QuoteParams { @@ -115,6 +117,8 @@ export interface NormalizedTxResponse { targetTxHash?: string timestamp: number status?: 'Processing' | 'Success' | 'Failed' | 'Refunded' + bridgeProvider?: string + routeId?: string // Enriched fields for data analysis amountInUsd: number amountOutUsd: number diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/components/QuoteProviderName.tsx b/apps/kyberswap-interface/src/pages/CrossChainSwap/components/QuoteProviderName.tsx index 0da8e193ff..ed6827f66c 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/components/QuoteProviderName.tsx +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/components/QuoteProviderName.tsx @@ -5,7 +5,8 @@ import { registry } from 'pages/CrossChainSwap/hooks/useCrossChainSwap' import { Quote } from 'pages/CrossChainSwap/registry' const getStepProviders = (quote: Quote): SwapProvider[] => { - if (quote.adapter.getName().toLowerCase() !== 'kyberacross') return [] + const adapterName = quote.adapter.getName().toLowerCase() + if (adapterName !== 'kyberacross' && adapterName !== 'kybercross') return [] const steps = quote.quote.rawQuote?.steps if (!Array.isArray(steps)) return [] diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/factory.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/factory.ts index 803bd16340..b710106642 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/factory.ts +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/factory.ts @@ -2,7 +2,7 @@ import { AcrossAdapter, DeBridgeAdapter, KyberAcrossAdapter, - KyberCrossChainAdapter, + KyberCrossAdapter, KyberSwapAdapter, LifiAdapter, MayanAdapter, @@ -15,6 +15,7 @@ import { BungeeAdapter } from './adapters/BungeeAdapter' import { NearIntentsAdapter } from './adapters/NearIntentsAdapter' import { OptimexAdapter } from './adapters/OptimexAdapter' import { OrbiterAdapter } from './adapters/OrbiterAdapter' +import { normalizeAdapterName } from './utils' // Factory for creating swap provider instances export class CrossChainSwapFactory { @@ -32,7 +33,7 @@ export class CrossChainSwapFactory { private static orbiterInstance: OrbiterAdapter private static bungeeInstance: BungeeAdapter private static kyberAcrossInstance: KyberAcrossAdapter - private static kyberCrossChainInstance: KyberCrossChainAdapter + private static kyberCrossInstance: KyberCrossAdapter // Get or create Across adapter static getAcrossAdapter(): AcrossAdapter { @@ -127,11 +128,13 @@ export class CrossChainSwapFactory { return CrossChainSwapFactory.kyberAcrossInstance } - static getKyberCrossChainAdapter(): KyberCrossChainAdapter { - if (!CrossChainSwapFactory.kyberCrossChainInstance) { - CrossChainSwapFactory.kyberCrossChainInstance = new KyberCrossChainAdapter() + static getKyberCrossAdapter(): KyberCrossAdapter { + if (!CrossChainSwapFactory.kyberCrossInstance) { + CrossChainSwapFactory.kyberCrossInstance = new KyberCrossAdapter(name => + name ? CrossChainSwapFactory.getAdapterByName(name) : undefined, + ) } - return CrossChainSwapFactory.kyberCrossChainInstance + return CrossChainSwapFactory.kyberCrossInstance } // Get all registered adapters @@ -149,21 +152,21 @@ export class CrossChainSwapFactory { CrossChainSwapFactory.getKsApdater(), // CrossChainSwapFactory.getOrbiterAdapter(), CrossChainSwapFactory.getBungeeAdapter(), - // CrossChainSwapFactory.getKyberAcrossAdapter(), - // CrossChainSwapFactory.getKyberCrossChainAdapter(), + CrossChainSwapFactory.getKyberAcrossAdapter(), + CrossChainSwapFactory.getKyberCrossAdapter(), ] } // Get adapter by name static getAdapterByName(name: string): SwapProvider | undefined { - switch (name.toLowerCase()) { + switch (normalizeAdapterName(name)) { case 'across': return CrossChainSwapFactory.getAcrossAdapter() case 'relay': return CrossChainSwapFactory.getRelayAdapter() case 'xyfinance': return CrossChainSwapFactory.getXyFinanceAdapter() - case 'near intents': + case 'nearintents': return CrossChainSwapFactory.getNearIntentsAdapter() case 'mayan': return CrossChainSwapFactory.getMayanAdapter() @@ -182,7 +185,9 @@ export class CrossChainSwapFactory { case 'bungee': return CrossChainSwapFactory.getBungeeAdapter() case 'kyberacross': - return CrossChainSwapFactory.getKyberCrossChainAdapter() + return CrossChainSwapFactory.getKyberAcrossAdapter() + case 'kybercross': + return CrossChainSwapFactory.getKyberCrossAdapter() default: return undefined } diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/hooks/useCrossChainSwap.tsx b/apps/kyberswap-interface/src/pages/CrossChainSwap/hooks/useCrossChainSwap.tsx index 1d6a520dee..dfb1dd7f4d 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/hooks/useCrossChainSwap.tsx +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/hooks/useCrossChainSwap.tsx @@ -946,8 +946,13 @@ export const CrossChainSwapRegistryProvider = ({ children }: { children: React.R return } + const isExcludedAllAdapters = excludedSources.length === registry.getAllAdapters().length + const quoteParams = isExcludedAllAdapters + ? params + : { ...params, includedSources: includedSourceNames, excludedSources: excludedSourceNames } + // Race between the adapter quote and timeout - const quote = await Promise.race([adapter.getQuote(params), createTimeoutPromise(9_000)]) + const quote = await Promise.race([adapter.getQuote(quoteParams), createTimeoutPromise(9_000)]) // Check for cancellation after getting quote if (signal.aborted) throw new Error('Cancelled') diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/registry.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/registry.ts index 804b0621ea..9504ed9cbe 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/registry.ts +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/registry.ts @@ -1,9 +1,5 @@ -// import { isEvmChain } from 'utils' -import { - // NearQuoteParams, - NormalizedQuote, // QuoteParams, - SwapProvider, -} from './adapters' +import { NormalizedQuote, SwapProvider } from './adapters' +import { normalizeAdapterName } from './utils' export interface Quote { adapter: SwapProvider @@ -14,53 +10,15 @@ export class CrossChainSwapAdapterRegistry { private adapters: Map = new Map() registerAdapter(adapter: SwapProvider): void { - this.adapters.set(adapter.getName().toLowerCase().replace(/\s+/g, ''), adapter) + this.adapters.set(normalizeAdapterName(adapter.getName()), adapter) } getAdapter(name?: string): SwapProvider | undefined { if (!name) return undefined - return this.adapters.get(name.toLowerCase().replace(/\s+/g, '')) + return this.adapters.get(normalizeAdapterName(name)) } getAllAdapters(): SwapProvider[] { return Array.from(this.adapters.values()) } - - // get quotes from all adapters and sort them by output amount - // async getQuotes(params: QuoteParams | NearQuoteParams): Promise { - // const quotes: { adapter: SwapProvider; quote: NormalizedQuote }[] = [] - // - // const adapters = - // params.fromChain === params.toChain && isEvmChain(params.fromChain) - // ? ([this.getAdapter('KyberSwap')] as SwapProvider[]) - // : this.getAllAdapters().filter( - // adapter => - // adapter.getSupportedChains().includes(params.fromChain) && - // adapter.getSupportedChains().includes(params.toChain), - // ) - // - // console.log( - // 'Available adapters', - // params, - // adapters.map(ad => ad.getName()), - // ) - // // Get quotes from all compatible adapters - // const quotePromises = adapters.map(async adapter => { - // try { - // const quote = await adapter.getQuote(params) - // quotes.push({ adapter, quote }) - // } catch (err) { - // console.error(`Failed to get quote from ${adapter.getName()}:`, err) - // } - // }) - // - // await Promise.all(quotePromises) - // - // if (quotes.length === 0) { - // throw new Error('No valid quotes found for the requested swap') - // } - // - // quotes.sort((a, b) => (a.quote.outputAmount < b.quote.outputAmount ? 1 : -1)) - // return quotes - // } } diff --git a/apps/kyberswap-interface/src/pages/CrossChainSwap/utils/index.ts b/apps/kyberswap-interface/src/pages/CrossChainSwap/utils/index.ts index 8a01e23002..a367c3391a 100644 --- a/apps/kyberswap-interface/src/pages/CrossChainSwap/utils/index.ts +++ b/apps/kyberswap-interface/src/pages/CrossChainSwap/utils/index.ts @@ -5,6 +5,10 @@ import { isEvmChain } from 'utils' import { Chain, Currency, NonEvmChain, NonEvmChainInfo } from '../adapters' +export const normalizeAdapterName = (name?: string): string => { + return name?.toLowerCase().replace(/[\s_-]+/g, '') ?? '' +} + export const getNetworkInfo = (chain: Chain) => { if (isEvmChain(chain)) return {