UNPKG

@openocean.finance/widget

Version:

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

787 lines (725 loc) 27.5 kB
import { OneClickService, OpenAPI, QuoteRequest } from '@defuse-protocol/one-click-sdk-typescript' import { ChainId } from '@openocean.finance/widget-sdk' import { Currency, SolanaToken } from '../constants/index.js' import { useWalletSelector } from '@near-wallet-selector/react-hook' import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, createAssociatedTokenAccountInstruction, createTransferInstruction, getAccount, getAssociatedTokenAddress, } from '@solana/spl-token' import { WalletAdapterProps } from '@solana/wallet-adapter-base' import { Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js' import { WalletClient, formatUnits } from 'viem' import { BTC_DEFAULT_RECEIVER, CROSS_CHAIN_FEE_RECEIVER, SOLANA_NATIVE, ZERO_ADDRESS } from '../constants/index.js' import { Quote } from '../registry.js' import { BaseSwapAdapter, Chain, NearQuoteParams, NonEvmChain, NormalizedQuote, NormalizedTxResponse, SwapStatus, } from './BaseSwapAdapter.js' export const MappingChainIdToBlockChain: Record<string, string> = { [NonEvmChain.Bitcoin]: 'btc', [NonEvmChain.Solana]: 'sol', [ChainId.ETH]: 'eth', [ChainId.ARB]: 'arb', [ChainId.BSC]: 'bsc', [ChainId.ERA]: 'bera', [ChainId.POL]: 'pol', [ChainId.BAS]: 'base', [ChainId.NEAR]: 'near', [ChainId.MONAD]: 'monad', [ChainId.FLR]: 'flr', } const erc20Abi = [ { inputs: [ { type: 'address', name: 'recipient' }, { type: 'uint256', name: 'amount' }, ], name: 'transfer', outputs: [{ type: 'bool', name: '' }], stateMutability: 'nonpayable', type: 'function', }, ] export interface NearToken { assetId: string decimals: number blockchain: string symbol: string price: number priceUpdatedAt: number contractAddress: string logo: string } const getTokenLogoUrl = (token: NearToken) => { const { symbol, contractAddress } = token // For major tokens without contract addresses or as fallbacks switch (symbol) { case 'ETH': return 'https://assets.coingecko.com/coins/images/279/small/ethereum.png' case 'BTC': case 'wBTC': return 'https://assets.coingecko.com/coins/images/1/small/bitcoin.png' case 'USDC': return 'https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png' case 'USDT': return 'https://assets.coingecko.com/coins/images/325/small/Tether.png' case 'DAI': return 'https://assets.coingecko.com/coins/images/9956/small/4943.png' case 'SOL': return 'https://assets.coingecko.com/coins/images/4128/small/solana.png' case 'NEAR': case 'wNEAR': return 'https://assets.coingecko.com/coins/images/10365/small/near.jpg' case 'BNB': return 'https://assets.coingecko.com/coins/images/825/small/bnb-icon2_2x.png' case 'DOGE': return 'https://assets.coingecko.com/coins/images/5/small/dogecoin.png' case 'XRP': return 'https://assets.coingecko.com/coins/images/44/small/xrp-symbol-white-128.png' case 'TRX': return 'https://assets.coingecko.com/coins/images/1094/small/tron-logo.png' case 'FRAX': return 'https://assets.coingecko.com/coins/images/13422/small/FRAX_icon.png' case 'LINK': return 'https://assets.coingecko.com/coins/images/877/small/chainlink-new-logo.png' case 'UNI': return 'https://assets.coingecko.com/coins/images/12504/small/uni.jpg' case 'AAVE': return 'https://assets.coingecko.com/coins/images/12645/small/AAVE.png' case 'SHIB': return 'https://assets.coingecko.com/coins/images/11939/small/shiba.png' case 'PEPE': return 'https://assets.coingecko.com/coins/images/29850/small/pepe-token.jpeg' case 'REF': return 'https://s2.coinmarketcap.com/static/img/coins/64x64/11809.png' case 'AURORA': return 'https://s2.coinmarketcap.com/static/img/coins/64x64/14803.png' case 'BLACKDRAGON': return 'https://s2.coinmarketcap.com/static/img/coins/64x64/29627.png' // Add more cases as needed default: // Fallback to a generic token icon return `https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x${contractAddress}/logo.png` } } export class NearIntentsAdapter extends BaseSwapAdapter { private nearTokens: NearToken[] = [] constructor() { super() // Initialize the API client // OpenAPI.BASE = 'https://1click.chaindefuser.com' OpenAPI.BASE = 'https://1click.openocean.finance' if (this.nearTokens.length === 0) { this.getAllSupportedTokens() } } getName(): string { return 'Near Intents' } getIcon(): string { return 'https://storage.googleapis.com/ks-setting-1d682dca/000c677f-2ebc-44cc-8d76-e4c6d07627631744962669170.png' } getSupportedChains(): Chain[] { return [ NonEvmChain.Solana, NonEvmChain.Bitcoin, NonEvmChain.Near, ...Object.keys(MappingChainIdToBlockChain).map(Number), ] } async getAllSupportedTokens(): Promise<void> { fetch(`https://1click.chaindefuser.com/v0/tokens`, { headers: { 'Authorization': `Bearer ${OpenAPI.TOKEN}`, }, }) .then(res => res.json()) .then(res => { const wNear = res.find((token: NearToken) => token.contractAddress === 'wrap.near') const native: NearToken = wNear ? { ...wNear, symbol: 'NEAR', contractAddress: '', assetId: 'near', logo: getTokenLogoUrl(wNear), } : { assetId: 'near', decimals: 24, blockchain: 'near', symbol: 'NEAR', price: 0, priceUpdatedAt: 0, contractAddress: '', logo: getTokenLogoUrl(wNear), } this.nearTokens = [ native, ...(res?.map((item: NearToken) => { if (item.blockchain == 'btc') { console.log(item) } return { ...item, logo: getTokenLogoUrl(item), } }) || []), ] localStorage.setItem('nearTokens', JSON.stringify(this.nearTokens)) }) .catch(error => { console.error('Failed to fetch near tokens:', error) let nearTokens = localStorage.getItem('nearTokens') if (nearTokens) { this.nearTokens = JSON.parse(nearTokens) } else { this.nearTokens = [] } // Reset loading state on error }) } getSupportedTokens(_sourceChain: Chain, _destChain: Chain): Currency[] { return [] } async getQuote(params: NearQuoteParams): Promise<NormalizedQuote> { const deadline = new Date() // 1 hour for Bitcoin, 20 minutes for other chains deadline.setSeconds(deadline.getSeconds() + (params.fromChain === NonEvmChain.Bitcoin ? 60 * 60 : 60 * 20)) if (this.nearTokens.length === 0) { await this.getAllSupportedTokens() await new Promise(resolve => setTimeout(resolve, 3000)) } let fromAssetId: any = '' if ((params.fromToken as any).address === 'near.near') { fromAssetId = 'nep141:wrap.near' } else { fromAssetId = this.nearTokens.find(token => { const blockchain = MappingChainIdToBlockChain[params.fromChain as ChainId] if (params.fromChain === 1151111081099710) { return (params.fromToken as SolanaToken).address === SOLANA_NATIVE ? token.symbol === 'SOL' && token.blockchain === 'sol' : token.blockchain === blockchain && token.contractAddress === (params.fromToken as any).address } if (token.blockchain === blockchain) { // console.log(token.symbol) // console.log(token.assetId) // console.log(token) if (!token.contractAddress && (params.fromToken as any).isNative && token.symbol.toLowerCase() === params.fromToken.symbol?.toLowerCase()) { return true } return token.contractAddress?.toLowerCase() === (params.fromToken as any).address.toLowerCase() } else { return false } })?.assetId } let toAssetId: any = '' if ((params.toToken as any).address === 'near.near') { toAssetId = 'nep141:wrap.near' } else { toAssetId = this.nearTokens.find((token: NearToken) => { const blockchain = MappingChainIdToBlockChain[params.toChain as ChainId] if (params.toChain === 1151111081099710) { return (params.toToken as SolanaToken).address === SOLANA_NATIVE ? token.symbol === 'SOL' && token.blockchain === 'sol' : token.blockchain === blockchain && token.contractAddress === (params.toToken as any).address } if (token.blockchain === blockchain) { // console.log(token.symbol) // console.log(token.assetId) // console.log(token) if (!token.contractAddress && (params.toToken as any).isNative && token.symbol.toLowerCase() === params.toToken.symbol?.toLowerCase()) { return true } return token.contractAddress?.toLowerCase() === (params.toToken as any).address.toLowerCase() } else { return false } })?.assetId } if (!fromAssetId) { throw new Error('not supported from token') } if (!toAssetId) { throw new Error('not supported to token') } // Create a quote request const quoteRequest: QuoteRequest = { dry: true, deadline: deadline.toISOString(), slippageTolerance: params.slippage, swapType: QuoteRequest.swapType.EXACT_INPUT, originAsset: fromAssetId, depositType: QuoteRequest.depositType.ORIGIN_CHAIN, destinationAsset: toAssetId, amount: params.amount, refundTo: params.sender, refundType: QuoteRequest.refundType.ORIGIN_CHAIN, referral: 'kyberswap', recipient: params.recipient, recipientType: QuoteRequest.recipientType.DESTINATION_CHAIN, appFees: [ { recipient: CROSS_CHAIN_FEE_RECEIVER.toLowerCase(), fee: params.feeBps, }, ], } try { const quote = await OneClickService.getQuote(quoteRequest) const formattedInputAmount = formatUnits(BigInt(params.amount), params.fromToken.decimals) const rawAmountOut = Number(quote?.quote?.amountOut ?? 0) const amountOut = rawAmountOut / 10 ** params.toToken.decimals const formattedOutputAmount = amountOut.toString() const inputUsd = Number(quote?.quote?.amountInUsd ?? 0) const outputUsd = +quote.quote.amountOutUsd const platformFeePercent = (params.feeBps * 100) / 10_000 const protocolFee = +quote.quote.amountInUsd * params.feeBps / 10000 return { quoteParams: params, outputAmount: BigInt(rawAmountOut), formattedOutputAmount, inputUsd: +quote.quote.amountInUsd, outputUsd: +quote.quote.amountOutUsd, priceImpact: !inputUsd || !outputUsd ? NaN : ((inputUsd - outputUsd) * 100) / inputUsd, rate: +formattedOutputAmount / +formattedInputAmount, gasFeeUsd: 0, timeEstimate: quote.quote.timeEstimate || 0, contractAddress: ZERO_ADDRESS, rawQuote: quote, protocolFee: protocolFee, platformFeePercent: platformFeePercent, } } catch (error) { // console.log('NearIntentsAdapter getQuote error', error) if (error && typeof error === 'object' && 'body' in error) { const errorWithBody = error as { body?: { message?: string } } if (errorWithBody.body?.message) { throw new Error(errorWithBody.body.message) } } throw error } } async executeSwap( { quote }: Quote, walletClient: WalletClient, nearWallet?: ReturnType<typeof useWalletSelector> ): Promise<NormalizedTxResponse> { const quoteParams = { ...quote.rawQuote.quoteRequest, dry: false, // adjust slippage to 0,01% to accept the rate change slippageTolerance: Math.floor(quote.quoteParams.slippage * 0.9) > 1 ? Math.floor(quote.quoteParams.slippage * 0.9) : quote.quoteParams.slippage, } delete quoteParams.correlationId const refreshedQuote = await OneClickService.getQuote(quoteParams) const depositAddress = refreshedQuote?.quote?.depositAddress if (!depositAddress) { throw new Error('Deposit address not found') } if ( refreshedQuote.quoteRequest.recipient === ZERO_ADDRESS || refreshedQuote.quoteRequest.refundTo === ZERO_ADDRESS || refreshedQuote.quoteRequest.recipient.toLowerCase() === BTC_DEFAULT_RECEIVER || refreshedQuote.quoteRequest.refundTo.toLowerCase() === BTC_DEFAULT_RECEIVER ) { throw new Error('Near Intent recipient or refundTo is ZERO ADDRESS') } if (BigInt(refreshedQuote.quote.minAmountOut) < BigInt(quote.rawQuote.quote.minAmountOut)) { throw new Error('Quote amount out is less than expected') } const params = { sender: quote.quoteParams.sender, id: depositAddress, // specific id for each provider adapter: this.getName(), sourceChain: quote.quoteParams.fromChain, targetChain: quote.quoteParams.toChain, inputAmount: quote.quoteParams.amount, outputAmount: quote.outputAmount.toString(), sourceToken: quote.quoteParams.fromToken, targetToken: quote.quoteParams.toToken, timestamp: new Date().getTime(), } if (quote.quoteParams.fromChain === NonEvmChain.Solana) { return new Promise<NormalizedTxResponse>(async (resolve, reject) => { // Use walletClient (adaptedWallet) from ExecuteRoute.ts const adaptedWallet = walletClient as any if (!adaptedWallet || !adaptedWallet.sendTransaction) { reject('Not connected or walletClient does not support sendTransaction') return } // Get connection from adaptedWallet (exposed by ExecuteRoute.ts) const connection = adaptedWallet.connection if (!connection) { reject('Connection not available from walletClient') return } const waitForConfirmation = async (txId: string) => { try { const latestBlockhash = await connection.getLatestBlockhash() // Wait for confirmation with timeout const confirmation = await Promise.race([ connection.confirmTransaction( { signature: txId, blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, }, 'confirmed', ), new Promise((_, reject) => setTimeout(() => reject(new Error('Transaction confirmation timeout')), 60000), ), ]) const confirmationResult = confirmation as { value: { err: any } } if (confirmationResult.value.err) { throw new Error(`Transaction failed: ${JSON.stringify(confirmationResult.value.err)}`) } console.log('Transaction confirmed successfully!') } catch (confirmError) { console.error('Transaction confirmation failed:', confirmError) // Check if transaction actually succeeded despite timeout const txStatus = await connection.getSignatureStatus(txId) if (txStatus?.value?.confirmationStatus !== 'confirmed') { throw new Error(`Transaction was not confirmed: ${confirmError instanceof Error ? confirmError.message : 'Unknown error'}`) } } } const fromPubkey = new PublicKey(quote.quoteParams.sender) const recipientPubkey = new PublicKey(depositAddress) const fromToken = quote.quoteParams.fromToken as SolanaToken if (fromToken.address === SOLANA_NATIVE) { // Get latest blockhash before creating transaction const { blockhash } = await connection.getLatestBlockhash('confirmed') const transaction = new Transaction({ recentBlockhash: blockhash, feePayer: fromPubkey, }).add( SystemProgram.transfer({ fromPubkey: fromPubkey, toPubkey: recipientPubkey, lamports: BigInt(quote.quoteParams.amount), }), ) try { // Use adaptedWallet.sendTransaction (exposed by ExecuteRoute.ts) const result = await adaptedWallet.sendTransaction(transaction) const signature = result?.signature || result await waitForConfirmation(signature) resolve({ ...params, sourceTxHash: signature, }) } catch (error) { reject(error) } } else { const mintPubkey = new PublicKey(fromToken.address) // Get associated token addresses const senderTokenAddress = await getAssociatedTokenAddress( mintPubkey, fromPubkey, false, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, ) const recipientTokenAddress = await getAssociatedTokenAddress( mintPubkey, recipientPubkey, false, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, ) // Get latest blockhash before creating transaction const { blockhash } = await connection.getLatestBlockhash('confirmed') const transaction = new Transaction({ recentBlockhash: blockhash, feePayer: fromPubkey, }) try { // Check if recipient's token account exists await getAccount(connection, recipientTokenAddress) } catch (err) { // Account doesn't exist, create it console.log('Creating recipient token account...') transaction.add( createAssociatedTokenAccountInstruction( fromPubkey, // payer recipientTokenAddress, // associated token account recipientPubkey, // owner mintPubkey, // mint TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, ), ) } // Add transfer instruction transaction.add( createTransferInstruction( senderTokenAddress, // source recipientTokenAddress, // destination fromPubkey, // owner BigInt(quote.quoteParams.amount), [], TOKEN_PROGRAM_ID, ), ) try { // Use adaptedWallet.sendTransaction (exposed by ExecuteRoute.ts) const result = await adaptedWallet.sendTransaction(transaction) const signature = result?.signature || result await waitForConfirmation(signature) resolve({ ...params, sourceTxHash: signature, }) } catch (error) { reject(error) } } return }) } if (quote.quoteParams.fromChain === NonEvmChain.Bitcoin) { return new Promise<NormalizedTxResponse>(async (resolve, reject) => { if (!walletClient || !walletClient.sendTransaction) { reject('Not connected') return } try { const tx = await walletClient.sendTransaction({ recipient: depositAddress, amount: quote.quoteParams.amount, chain: undefined, account: walletClient.account?.address as `0x${string}`, kzg: undefined, }) await OneClickService.submitDepositTx({ txHash: tx, depositAddress, }).catch(e => { console.log('NearIntents submitDepositTx failed', e) }) resolve({ ...params, sourceTxHash: tx, }) } catch (e) { console.log(e) reject(e) return } }) } if (quote.quoteParams.fromChain === NonEvmChain.Near) { return new Promise<NormalizedTxResponse>(async (resolve, reject) => { if (!nearWallet || !nearWallet.signedAccountId) { reject('Not connected') return } const fromToken = quote.quoteParams.fromToken as any const isNative = fromToken.address === 'near.near' const rawAmount = quote.quoteParams.amount || '0' const amount = String(rawAmount) // yoctoNEAR 字符串 const transactions: { signerId: string receiverId: string actions: any[] }[] = [] // wNEAR 合约地址(标准 wrap.near) const WRAP_CONTRACT_ID = 'wrap.near' const tokenContract = fromToken.address as string | undefined if (isNative) { // 原生 NEAR:先在 wNEAR 合约上给桥地址做 storage_deposit,再 near_deposit 包成 wNEAR,然后 ft_transfer 给桥地址 transactions.push({ signerId: nearWallet.signedAccountId, receiverId: WRAP_CONTRACT_ID, actions: [ { // 1) storage_deposit,确保桥地址在 wNEAR 上已注册 type: 'FunctionCall', params: { methodName: 'storage_deposit', args: { account_id: depositAddress, registration_only: true, }, gas: '30000000000000', deposit: '1250000000000000000000', // 0.00125 NEAR }, }, { // 2) near_deposit:把原生 NEAR 包成 wNEAR type: 'FunctionCall', params: { methodName: 'near_deposit', args: {}, gas: '30000000000000', deposit: amount, // 使用原始 NEAR 数量(yoctoNEAR) }, }, { // 3) ft_transfer:将 wNEAR 发送到桥地址 type: 'FunctionCall', params: { methodName: 'ft_transfer', args: { receiver_id: depositAddress, amount, }, gas: '30000000000000', deposit: '1', // NEP-141 规范要求 1 yoctoNEAR }, }, ], }) } else if (tokenContract) { // 非原生 NEP-141 token:先在 token 合约上给桥地址做 storage_deposit,再 ft_transfer transactions.push({ signerId: nearWallet.signedAccountId, receiverId: tokenContract, actions: [ { type: 'FunctionCall', params: { methodName: 'storage_deposit', args: { account_id: depositAddress, registration_only: true, }, gas: '30000000000000', deposit: '1250000000000000000000', // 0.00125 NEAR }, }, { type: 'FunctionCall', params: { methodName: 'ft_transfer', args: { receiver_id: depositAddress, amount, }, gas: '30000000000000', deposit: '1', }, }, ], }) } else { reject('Invalid NEAR token contract') return } // MyNearWallet 会跳转到网页,需要在本地记录一次 if (nearWallet?.wallet?.id === 'my-near-wallet') { localStorage.setItem( 'cross-chain-swap-my-near-wallet-tx', JSON.stringify({ ...params, sourceTxHash: depositAddress, }) ) } const txResult = await nearWallet .signAndSendTransactions({ transactions }) .catch((e: any) => { console.log('NearIntents signAndSendTransactions failed', e) if (nearWallet?.wallet?.id === 'my-near-wallet') reject() else reject(e) }) let transaction: any = { hash: "" }; if (txResult && txResult.length === 1) { transaction = txResult[txResult.length - 1].transaction || {}; } else if (txResult && txResult.length > 1) { transaction = txResult.filter((item: any) => { const { actions = [] } = item && item.transaction || {}; const _actions = actions.filter((fc: any) => { const { FunctionCall = {} } = fc; const { method_name } = FunctionCall; return method_name === 'ft_transfer_call'; }); return _actions && _actions.length > 0; }); if (transaction && transaction.length) { transaction = transaction[0].transaction; } else { transaction = txResult[txResult.length - 1].transaction || {}; } } const { hash } = transaction; resolve({ ...params, sourceTxHash: hash, }) }) } return new Promise<NormalizedTxResponse>(async (resolve, reject) => { try { if (!walletClient || !walletClient.account) reject('Not connected') if (quote.quoteParams.sender === ZERO_ADDRESS || quote.quoteParams.recipient === ZERO_ADDRESS) { reject('Near Intent refundTo or recipient is ZERO ADDRESS') return } const account = walletClient.account?.address as `0x${string}` const fromToken = quote.quoteParams.fromToken const hash = await ((fromToken as any).isNative ? walletClient.sendTransaction({ to: depositAddress as `0x${string}`, value: BigInt(quote.quoteParams.amount), chain: undefined, account, kzg: undefined }) : walletClient.writeContract({ address: ('contractAddress' in fromToken ? fromToken.contractAddress : (fromToken as any).address) as `0x${string}`, abi: erc20Abi, functionName: 'transfer', args: [depositAddress, quote.quoteParams.amount], chain: undefined, account, })) await OneClickService.submitDepositTx({ txHash: hash, depositAddress, }).catch(e => { console.log('NearIntents submitDepositTx failed', e) }) resolve({ ...params, sourceTxHash: hash, }) } catch (e) { reject(e) } }) } async getTransactionStatus(p: NormalizedTxResponse): Promise<SwapStatus> { const res = await OneClickService.getExecutionStatus(p.id) return { txHash: res.swapDetails?.destinationChainTxHashes[0]?.hash || '', status: res.status === 'SUCCESS' ? 'Success' : res.status === 'FAILED' ? 'Failed' : res.status === 'REFUNDED' ? 'Refunded' : 'Processing', } } }