diff --git a/src/components/Aggregator/adapters/nearintents/index.ts b/src/components/Aggregator/adapters/nearintents/index.ts new file mode 100644 index 00000000..04b74a99 --- /dev/null +++ b/src/components/Aggregator/adapters/nearintents/index.ts @@ -0,0 +1,282 @@ +// Source: https://docs.near-intents.org/near-intents/integration/distribution-channels/1click-api + +import { zeroAddress, erc20Abi } from 'viem'; +import { sendTransaction, writeContract } from 'wagmi/actions'; +import { config } from '../../../WalletProvider'; +import { chainsMap } from '../../constants'; + +export const chainToId = { + ethereum: 'eth', + arbitrum: 'arb', + base: 'base', + optimism: 'op', + polygon: 'pol', + bsc: 'bsc', + avax: 'avax', + gnosis: 'gnosis' +}; + +export const name = 'NEAR Intents'; +export const token = 'NEAR'; +export const referral = true; + +export function approvalAddress() { + return null; +} + +const API_BASE = 'https://1click.chaindefuser.com'; + +interface TokenInfo { + assetId: string; + decimals: number; + blockchain: string; + symbol: string; + price: number; + contractAddress?: string; +} + +let tokensCache: TokenInfo[] | null = null; +let tokensCacheTime = 0; + +const CACHE_TTL = 60_000; +const POLL_INTERVAL = 5_000; +const POLL_INITIAL_DELAY = 10_000; +const MAX_POLL_ATTEMPTS = 120; +const DEADLINE_MINUTES = 30; + +async function getTokens(): Promise { + const now = Date.now(); + if (tokensCache && now - tokensCacheTime < CACHE_TTL) { + return tokensCache; + } + + try { + const response = await fetch(`${API_BASE}/v0/tokens`); + if (!response.ok) { + return tokensCache; // Return stale cache if available, otherwise null + } + + tokensCache = await response.json(); + tokensCacheTime = now; + return tokensCache; + } catch { + return tokensCache; // Return stale cache on network error + } +} + +function findToken(chain: string, tokenAddress: string, tokens: TokenInfo[]): TokenInfo | null { + const blockchain = chainToId[chain]; + if (!blockchain) return null; + + const isNative = tokenAddress === zeroAddress; + + return ( + tokens.find((t) => { + if (t.blockchain !== blockchain) return false; + if (isNative) return !t.contractAddress; + return t.contractAddress?.toLowerCase() === tokenAddress.toLowerCase(); + }) ?? null + ); +} + +const waitForOrder = + ({ depositAddress }) => + (onSuccess) => { + let attempts = 0; + + const poll = async () => { + if (attempts >= MAX_POLL_ATTEMPTS) return; + attempts++; + + try { + const response = await fetch(`${API_BASE}/v0/status?depositAddress=${depositAddress}`); + if (!response.ok) { + setTimeout(poll, POLL_INTERVAL); + return; + } + + const status = await response.json(); + + if (status.status === 'SUCCESS') { + onSuccess(); + return; + } + + if (status.status === 'FAILED' || status.status === 'REFUNDED') { + return; + } + + setTimeout(poll, POLL_INTERVAL); + } catch { + setTimeout(poll, POLL_INTERVAL); + } + }; + + setTimeout(poll, POLL_INITIAL_DELAY); + }; + +async function fetchQuote(chain: string, from: string, to: string, amount: string, extra, isDry: boolean) { + try { + const tokens = await getTokens(); + if (!tokens) { + return null; + } + + const fromToken = findToken(chain, from, tokens); + const toToken = findToken(chain, to, tokens); + + if (!fromToken || !toToken) { + return null; + } + + const userAddr = extra.userAddress?.toLowerCase() ?? zeroAddress; + const slippageBps = Math.round(Number(extra.slippage || 1) * 100); + + const quoteRequest = { + dry: isDry, + swapType: 'EXACT_INPUT', + slippageTolerance: slippageBps, + originAsset: fromToken.assetId, + destinationAsset: toToken.assetId, + amount, + depositType: 'ORIGIN_CHAIN', + refundTo: isDry ? zeroAddress : userAddr, + refundType: 'ORIGIN_CHAIN', + recipient: isDry ? zeroAddress : userAddr, + recipientType: 'DESTINATION_CHAIN', + deadline: new Date(Date.now() + DEADLINE_MINUTES * 60 * 1000).toISOString(), + referral: 'llamaswap' + }; + + const response = await fetch(`${API_BASE}/v0/quote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(quoteRequest) + }); + + if (!response.ok) { + return null; + } + + const quote = await response.json(); + + if (!quote?.quote?.amountOut) { + return null; + } + + return { + amountReturned: quote.quote.amountOut, + amountIn: quote.quote.amountIn || amount, + estimatedGas: 21000, + tokenApprovalAddress: null, + rawQuote: { + ...quote, + fromToken, + toToken, + chain, + fromAddress: from, + userAddress: extra.userAddress, + slippage: extra.slippage + }, + logo: 'https://assets.coingecko.com/coins/images/10365/small/near.jpg', + isMEVSafe: true + }; + } catch { + return null; + } +} + +export async function getQuote(chain: string, from: string, to: string, amount: string, extra) { + return fetchQuote(chain, from, to, amount, extra, true); +} + +export async function swap({ chain, rawQuote, from }) { + const extra = { + userAddress: rawQuote.userAddress, + slippage: rawQuote.slippage + }; + const toAddress = rawQuote.toToken.contractAddress || zeroAddress; + const liveQuote = await fetchQuote( + chain, + from, + toAddress, + rawQuote.quote.amountIn, + extra, + false + ); + + if (!liveQuote?.rawQuote?.quote?.depositAddress) { + throw { reason: 'Failed to get deposit address. Please try again.' }; + } + + // Validate price hasn't moved beyond slippage tolerance + const originalAmountOut = BigInt(rawQuote.quote.amountOut); + const liveAmountOut = BigInt(liveQuote.rawQuote.quote.amountOut); + const slippageTolerance = rawQuote.slippage ?? 1; + const slippageBps = BigInt(Math.round(slippageTolerance * 100)); + const minAcceptableAmount = originalAmountOut - (originalAmountOut * slippageBps) / BigInt(10000); + + if (originalAmountOut > 0n && liveAmountOut < minAcceptableAmount) { + const priceChangePercent = Number(((originalAmountOut - liveAmountOut) * BigInt(10000)) / originalAmountOut) / 100; + throw { + reason: `Price has moved unfavorably by ${priceChangePercent.toFixed(2)}%, which exceeds your slippage tolerance of ${slippageTolerance.toFixed(2)}%. Please refresh the quote and try again.` + }; + } + + const depositAddress = liveQuote.rawQuote.quote.depositAddress; + const amount = liveQuote.rawQuote.quote.amountIn; + + const isNative = !liveQuote.rawQuote.fromToken.contractAddress; + let txHash: string; + + if (isNative) { + txHash = await sendTransaction(config, { + to: depositAddress as `0x${string}`, + value: BigInt(amount), + chainId: chainsMap[chain] + }); + } else { + txHash = await writeContract(config, { + address: from as `0x${string}`, + abi: erc20Abi, + functionName: 'transfer', + args: [depositAddress as `0x${string}`, BigInt(amount)], + chainId: chainsMap[chain] + }); + } + + // Notify API of deposit (non-critical) + fetch(`${API_BASE}/v0/deposit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ txHash, depositAddress }) + }).catch(() => {}); + + return { + hash: txHash, + waitForOrder: waitForOrder({ depositAddress }) + }; +} + +export const getTxData = () => ''; + +export const getTx = ({ rawQuote }) => { + if (!rawQuote?.quote?.depositAddress) { + return {}; + } + + const isNative = !rawQuote.fromToken?.contractAddress; + + if (isNative) { + return { + to: rawQuote.quote.depositAddress, + value: rawQuote.quote.amountIn + }; + } + + return { + to: rawQuote.fromAddress, + data: '', + value: '0' + }; +}; diff --git a/src/components/Aggregator/list.ts b/src/components/Aggregator/list.ts index 8201d681..b7a46ec1 100644 --- a/src/components/Aggregator/list.ts +++ b/src/components/Aggregator/list.ts @@ -17,8 +17,9 @@ import * as odos from './adapters/odos'; // import * as krystal from './adapters/krystal' import * as matchaGasless from './adapters/0xGasless'; import * as matchaV2 from './adapters/0xV2'; +import * as nearintents from './adapters/nearintents'; -export const adapters = [matcha, cowswap, paraswap, kyberswap, inch, matchaGasless, odos, matchaV2]; +export const adapters = [matcha, cowswap, paraswap, kyberswap, inch, matchaGasless, odos, matchaV2, nearintents]; export const inifiniteApprovalAllowed = [matcha.name, cowswap.name, matchaGasless.name];