From 9710d30dfc51f53d2ac54cfe8a822565421d19ea Mon Sep 17 00:00:00 2001 From: devsparrow5 Date: Wed, 24 Dec 2025 13:11:40 +0100 Subject: [PATCH 1/5] feat: add NEAR Intents adapter --- .../Aggregator/adapters/nearintents/index.ts | 244 ++++++++++++++++++ src/components/Aggregator/list.ts | 3 +- 2 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/components/Aggregator/adapters/nearintents/index.ts diff --git a/src/components/Aggregator/adapters/nearintents/index.ts b/src/components/Aggregator/adapters/nearintents/index.ts new file mode 100644 index 00000000..b5cc9f26 --- /dev/null +++ b/src/components/Aggregator/adapters/nearintents/index.ts @@ -0,0 +1,244 @@ +// 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'; +const nativeToken = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + +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; + } + + const response = await fetch(`${API_BASE}/v0/tokens`); + if (!response.ok) { + throw new Error('Failed to fetch 1Click tokens'); + } + + tokensCache = await response.json(); + tokensCacheTime = now; + return tokensCache!; +} + +function isNativeToken(address: string): boolean { + return address === zeroAddress || address.toLowerCase() === nativeToken.toLowerCase(); +} + +function findToken(chain: string, tokenAddress: string, tokens: TokenInfo[]): TokenInfo | null { + const blockchain = chainToId[chain]; + if (!blockchain) return null; + + const isNative = isNativeToken(tokenAddress); + + 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); + }; + +export async function getQuote(chain: string, from: string, to: string, amount: string, extra) { + const tokens = await getTokens(); + + const fromToken = findToken(chain, from, tokens); + const toToken = findToken(chain, to, tokens); + + if (!fromToken || !toToken) { + return null; + } + + const userAddr = extra.userAddress?.toLowerCase() ?? zeroAddress; + const isDryRun = !extra.userAddress || extra.userAddress === zeroAddress; + const slippageBps = Math.round(Number(extra.slippage || 1) * 100); + + const quoteRequest = { + dry: isDryRun, + swapType: 'EXACT_INPUT', + slippageTolerance: slippageBps, + originAsset: fromToken.assetId, + destinationAsset: toToken.assetId, + amount, + depositType: 'ORIGIN_CHAIN', + refundTo: isDryRun ? zeroAddress : userAddr, + refundType: 'ORIGIN_CHAIN', + recipient: isDryRun ? 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 || '0', + estimatedGas: 21000, + tokenApprovalAddress: null, + rawQuote: { + ...quote, + fromToken, + toToken, + chain, + fromAddress: from, + userAddress: extra.userAddress + }, + logo: 'https://assets.coingecko.com/coins/images/10365/small/near.jpg', + isMEVSafe: true + }; +} + +export async function swap({ chain, rawQuote, from }) { + const depositAddress = rawQuote.quote.depositAddress; + const amount = rawQuote.quote.amountIn; + + if (!depositAddress) { + throw { reason: 'No deposit address. Please refresh quote.' }; + } + + const isNative = isNativeToken(from); + 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 = isNativeToken(rawQuote.fromAddress); + + 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]; From b9a33ac4cc25b9d33139408d69ea35fc0c38468a Mon Sep 17 00:00:00 2001 From: devsparrow5 Date: Mon, 5 Jan 2026 22:32:56 +0100 Subject: [PATCH 2/5] Use dryQuote on browse prices and real quote on swap --- .../Aggregator/adapters/nearintents/index.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/components/Aggregator/adapters/nearintents/index.ts b/src/components/Aggregator/adapters/nearintents/index.ts index b5cc9f26..b54f0e2e 100644 --- a/src/components/Aggregator/adapters/nearintents/index.ts +++ b/src/components/Aggregator/adapters/nearintents/index.ts @@ -116,7 +116,7 @@ const waitForOrder = setTimeout(poll, POLL_INITIAL_DELAY); }; -export async function getQuote(chain: string, from: string, to: string, amount: string, extra) { +async function fetchQuote(chain: string, from: string, to: string, amount: string, extra, isDry: boolean) { const tokens = await getTokens(); const fromToken = findToken(chain, from, tokens); @@ -127,20 +127,19 @@ export async function getQuote(chain: string, from: string, to: string, amount: } const userAddr = extra.userAddress?.toLowerCase() ?? zeroAddress; - const isDryRun = !extra.userAddress || extra.userAddress === zeroAddress; const slippageBps = Math.round(Number(extra.slippage || 1) * 100); const quoteRequest = { - dry: isDryRun, + dry: isDry, swapType: 'EXACT_INPUT', slippageTolerance: slippageBps, originAsset: fromToken.assetId, destinationAsset: toToken.assetId, amount, depositType: 'ORIGIN_CHAIN', - refundTo: isDryRun ? zeroAddress : userAddr, + refundTo: isDry ? zeroAddress : userAddr, refundType: 'ORIGIN_CHAIN', - recipient: isDryRun ? zeroAddress : userAddr, + recipient: isDry ? zeroAddress : userAddr, recipientType: 'DESTINATION_CHAIN', deadline: new Date(Date.now() + DEADLINE_MINUTES * 60 * 1000).toISOString(), referral: 'llamaswap' @@ -164,7 +163,7 @@ export async function getQuote(chain: string, from: string, to: string, amount: return { amountReturned: quote.quote.amountOut, - amountIn: quote.quote.amountIn || '0', + amountIn: quote.quote.amountIn || amount, estimatedGas: 21000, tokenApprovalAddress: null, rawQuote: { @@ -173,21 +172,40 @@ export async function getQuote(chain: string, from: string, to: string, amount: toToken, chain, fromAddress: from, - userAddress: extra.userAddress + userAddress: extra.userAddress, + slippage: extra.slippage }, logo: 'https://assets.coingecko.com/coins/images/10365/small/near.jpg', isMEVSafe: true }; } +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 depositAddress = rawQuote.quote.depositAddress; - const amount = rawQuote.quote.amountIn; + 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 (!depositAddress) { - throw { reason: 'No deposit address. Please refresh quote.' }; + if (!liveQuote?.rawQuote?.quote?.depositAddress) { + throw { reason: 'Failed to get deposit address. Please try again.' }; } + const depositAddress = liveQuote.rawQuote.quote.depositAddress; + const amount = liveQuote.rawQuote.quote.amountIn; + const isNative = isNativeToken(from); let txHash: string; From 214d1359a3cb7319dc57510f0aea1472384dbeff Mon Sep 17 00:00:00 2001 From: devsparrow5 Date: Thu, 15 Jan 2026 11:58:41 +0100 Subject: [PATCH 3/5] identify native tokens by checking existence on contractAddress --- .../Aggregator/adapters/nearintents/index.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/Aggregator/adapters/nearintents/index.ts b/src/components/Aggregator/adapters/nearintents/index.ts index b54f0e2e..55016e6e 100644 --- a/src/components/Aggregator/adapters/nearintents/index.ts +++ b/src/components/Aggregator/adapters/nearintents/index.ts @@ -25,7 +25,6 @@ export function approvalAddress() { } const API_BASE = 'https://1click.chaindefuser.com'; -const nativeToken = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; interface TokenInfo { assetId: string; @@ -61,15 +60,11 @@ async function getTokens(): Promise { return tokensCache!; } -function isNativeToken(address: string): boolean { - return address === zeroAddress || address.toLowerCase() === nativeToken.toLowerCase(); -} - function findToken(chain: string, tokenAddress: string, tokens: TokenInfo[]): TokenInfo | null { const blockchain = chainToId[chain]; if (!blockchain) return null; - const isNative = isNativeToken(tokenAddress); + const isNative = tokenAddress === zeroAddress; return ( tokens.find((t) => { @@ -206,7 +201,7 @@ export async function swap({ chain, rawQuote, from }) { const depositAddress = liveQuote.rawQuote.quote.depositAddress; const amount = liveQuote.rawQuote.quote.amountIn; - const isNative = isNativeToken(from); + const isNative = !liveQuote.rawQuote.fromToken.contractAddress; let txHash: string; if (isNative) { @@ -245,7 +240,7 @@ export const getTx = ({ rawQuote }) => { return {}; } - const isNative = isNativeToken(rawQuote.fromAddress); + const isNative = !rawQuote.fromToken?.contractAddress; if (isNative) { return { From 25ea48d477d6c6b3679b2b57ad8204390a876b80 Mon Sep 17 00:00:00 2001 From: devsparrow5 Date: Thu, 15 Jan 2026 20:47:52 +0100 Subject: [PATCH 4/5] fix: improve error handling and native token detection --- .../Aggregator/adapters/nearintents/index.ts | 135 ++++++++++-------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/src/components/Aggregator/adapters/nearintents/index.ts b/src/components/Aggregator/adapters/nearintents/index.ts index 55016e6e..204f715b 100644 --- a/src/components/Aggregator/adapters/nearintents/index.ts +++ b/src/components/Aggregator/adapters/nearintents/index.ts @@ -44,20 +44,24 @@ const POLL_INITIAL_DELAY = 10_000; const MAX_POLL_ATTEMPTS = 120; const DEADLINE_MINUTES = 30; -async function getTokens(): Promise { +async function getTokens(): Promise { const now = Date.now(); if (tokensCache && now - tokensCacheTime < CACHE_TTL) { return tokensCache; } - const response = await fetch(`${API_BASE}/v0/tokens`); - if (!response.ok) { - throw new Error('Failed to fetch 1Click tokens'); - } + 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!; + 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 { @@ -112,67 +116,74 @@ const waitForOrder = }; async function fetchQuote(chain: string, from: string, to: string, amount: string, extra, isDry: boolean) { - const tokens = await getTokens(); + 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 fromToken = findToken(chain, from, tokens); - const toToken = findToken(chain, to, tokens); + const response = await fetch(`${API_BASE}/v0/quote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(quoteRequest) + }); - if (!fromToken || !toToken) { - return null; - } + if (!response.ok) { + 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 quote = await response.json(); - const response = await fetch(`${API_BASE}/v0/quote`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(quoteRequest) - }); + if (!quote?.quote?.amountOut) { + return null; + } - if (!response.ok) { - return null; - } - - const quote = await response.json(); - - if (!quote?.quote?.amountOut) { + 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; } - - 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 - }; } export async function getQuote(chain: string, from: string, to: string, amount: string, extra) { From e5bbd9939e5ed233996f09dc3dd891d8bfb312dc Mon Sep 17 00:00:00 2001 From: devsparrow5 Date: Wed, 21 Jan 2026 17:34:04 +0100 Subject: [PATCH 5/5] Show error if price changes more than slippage tolerance between quote and swap --- .../Aggregator/adapters/nearintents/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/Aggregator/adapters/nearintents/index.ts b/src/components/Aggregator/adapters/nearintents/index.ts index 204f715b..04b74a99 100644 --- a/src/components/Aggregator/adapters/nearintents/index.ts +++ b/src/components/Aggregator/adapters/nearintents/index.ts @@ -209,6 +209,20 @@ export async function swap({ chain, rawQuote, from }) { 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;