diff --git a/package.json b/package.json index 05ca8a0c..afaafdf0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@chakra-ui/icons": "^2.0.12", "@chakra-ui/react": "2.8.2", "@defillama/sdk": "^3.0.25", + "@ekubo/evm-hyper-router-sdk": "0.1.0-alpha.1", "@emotion/react": "^11", "@emotion/styled": "^11", "@rainbow-me/rainbowkit": "^2.2.8", diff --git a/src/components/Aggregator/adapters/ekubo.ts b/src/components/Aggregator/adapters/ekubo.ts new file mode 100644 index 00000000..9b5f77f3 --- /dev/null +++ b/src/components/Aggregator/adapters/ekubo.ts @@ -0,0 +1,153 @@ +import { sendTx } from '../utils/sendTx'; +import { getTxs } from '../utils/getTxs'; +import { zeroAddress, pad, Hex } from 'viem'; +import { MAINNET_ADDRESS, MultiHop, Parameters, generateCalldata } from '@ekubo/evm-hyper-router-sdk'; + +export const chainToId = { + ethereum: 1, +}; + +export const name = 'Ekubo'; +export const token = 'EKUBO'; +export const referral = false; +export const isOutputAvailable = true; + +const logo = 'https://app.ekubo.org/logo.svg'; + +export function approvalAddress(chain: string) { + if (chain === 'ethereum') return MAINNET_ADDRESS; + throw new Error('Ekubo: unsupported network'); +} + +export function ekuboApiEndpoint(chain: string) { + if (chain === 'ethereum') return 'https://eth-mainnet-quoter-api.ekubo.org'; + throw new Error('Ekubo: unsupported network'); +} + +function normalizeAddress(address: string): Hex { + if (address === zeroAddress) return zeroAddress; + return pad(address as Hex, { size: 20 }); +} + +function normalizeConfig(config: string): Hex { + return pad(config as Hex, { size: 32 }); +} + +export async function getQuote(chain: string, from: string, to: string, amount: string, extra) { + const ekuboRouter = approvalAddress(chain); + const quoterEndpoint = ekuboApiEndpoint(chain); + const isExactOut = extra.amountOut && extra.amountOut !== '0'; + + // Call Ekubo API - for exact out, we swap the tokens and negate the amount + const apiUrl = isExactOut + ? `${quoterEndpoint}/-${extra.amountOut}/${to}/${from}` + : `${quoterEndpoint}/${amount}/${from}/${to}`; + + const data = await fetch(apiUrl).then((r) => r.json()); + + if (!data.splits || data.splits.length === 0) { + throw new Error('[Ekubo] No valid routes found'); + } + + const multiHops: MultiHop[] = data.splits.map(split => { + return { + specifiedAmount: BigInt(split.amount_specified), + hops: split.route.map(hop => { + if (hop.swap) { + return { + type: "swap", + poolKey: { + config: normalizeConfig(hop.swap.pool_key.config), + token0: normalizeAddress(hop.swap.pool_key.token0), + token1: normalizeAddress(hop.swap.pool_key.token1) + }, + skipAhead: hop.swap.skip_ahead, + sqrtRatioLimit: BigInt(hop.swap.sqrt_ratio_limit) + } + } else { + return { + type: "wrappedToken", + underlying: normalizeAddress(hop.wrapped_token.underlying), + wrapped: normalizeAddress(hop.wrapped_token.wrapped) + } + } + }) + } + }) + + const slippageFactor = extra.slippage ? parseFloat(extra.slippage) / 100 : 0.001; + + let amountReturned, amountIn, calculatedAmountThreshold; + + // Apply slippage + if (isExactOut) { + // For exact out, amountIn is adjusted upwards + // total_calculated and calculatedAmountThreshold should be negative + amountIn = BigInt(Math.floor(Math.abs(data.total_calculated))).toString(); + amountReturned = extra.amountOut; + calculatedAmountThreshold = -BigInt(Math.ceil(amountIn * (1 + slippageFactor))); + } else { + // For exact in, amountReturned is adjusted downwards + amountIn = amount; + amountReturned = data.total_calculated; + calculatedAmountThreshold = BigInt(Math.floor(amountReturned * (1 - slippageFactor))); + } + + const calldata = generateCalldata({ + specifiedToken: isExactOut ? to : from, + multiHops: multiHops, + calculatedAmountThreshold + } as Parameters); + + const rawQuote = extra.userAddress !== zeroAddress ? { + from: extra.userAddress, + to: ekuboRouter, + data: calldata, + value: from === zeroAddress ? (isExactOut ? -calculatedAmountThreshold : amountIn) : '0' + } : null; + + // Base transaction costs + swap execution + token transfers + const estimatedGas = 21000 + data.estimated_gas_cost + (from === zeroAddress ? 0 : 30000) + (to === zeroAddress ? 0 : 30000); + + const result = { + amountIn, + amountReturned, + estimatedGas, + tokenApprovalAddress: ekuboRouter, + rawQuote, + logo + }; + + return result; +} + +export async function swap({ tokens, fromAmount, rawQuote, eip5792 }) { + const txs = getTxs({ + fromAddress: rawQuote.from, + routerAddress: rawQuote.to, + data: rawQuote.data, + value: rawQuote.value, + fromTokenAddress: tokens.fromToken.address, + fromAmount, + eip5792, + tokenApprovalAddress: rawQuote.to + }); + + const tx = await sendTx(txs); + + return tx; +} + +export const getTxData = ({ rawQuote }) => rawQuote?.data; + +export const getTx = ({ rawQuote }) => { + if (rawQuote === null) { + return {}; + } + return { + from: rawQuote.from, + to: rawQuote.to, + data: rawQuote.data, + value: rawQuote.value + }; +}; diff --git a/src/components/Aggregator/list.ts b/src/components/Aggregator/list.ts index 8201d681..4d590af5 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 ekubo from './adapters/ekubo'; -export const adapters = [matcha, cowswap, paraswap, kyberswap, inch, matchaGasless, odos, matchaV2]; +export const adapters = [matcha, cowswap, paraswap, kyberswap, inch, matchaGasless, odos, matchaV2, ekubo]; export const inifiniteApprovalAllowed = [matcha.name, cowswap.name, matchaGasless.name]; diff --git a/yarn.lock b/yarn.lock index 8e7337e7..a6715e84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,6 +1010,13 @@ resolved "https://registry.yarnpkg.com/@ecies/ciphers/-/ciphers-0.2.3.tgz#963805e46d07e646550098ac29cbcc5b132218ea" integrity sha512-tapn6XhOueMwht3E2UzY0ZZjYokdaw9XtL9kEyjhQ/Fb9vL9xTFbOaI+fV0AWvTpYu4BNloC6getKW6NtSg4mA== +"@ekubo/evm-hyper-router-sdk@0.1.0-alpha.1": + version "0.1.0-alpha.1" + resolved "https://registry.yarnpkg.com/@ekubo/evm-hyper-router-sdk/-/evm-hyper-router-sdk-0.1.0-alpha.1.tgz#15de1ca493c198ab622269b36b9748be70c2b61d" + integrity sha512-otWeh/AHJCeKJkFTGG/nPbcDSgdjKggFsPENhn8liHRs/EzbVj7cObAdMqNt9rI3T1ARLwR8t4to9PjDT0jP4w== + dependencies: + viem "^2.33.0" + "@emotion/babel-plugin@^11.10.5": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz#65fa6e1790ddc9e23cc22658a4c5dea423c55c3c" @@ -1978,6 +1985,13 @@ dependencies: "@noble/hashes" "1.7.1" +"@noble/curves@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.1.tgz#9654a0bc6c13420ae252ddcf975eaf0f58f0a35c" + integrity sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA== + dependencies: + "@noble/hashes" "1.8.0" + "@noble/curves@1.9.2", "@noble/curves@^1.9.1", "@noble/curves@~1.9.0": version "1.9.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.2.tgz#73388356ce733922396214a933ff7c95afcef911" @@ -2995,6 +3009,11 @@ abitype@1.0.8, abitype@^1.0.8: resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.0.8.tgz#3554f28b2e9d6e9f35eb59878193eabd1b9f46ba" integrity sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg== +abitype@1.1.0, abitype@^1.0.9: + version "1.1.0" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.1.0.tgz#510c5b3f92901877977af5e864841f443bf55406" + integrity sha512-6Vh4HcRxNMLA0puzPjM5GBgT4aAcFGKZzSgAXvuZ27shJP6NEpielTuqbBmZILR5/xd0PizkBGy5hReKz9jl5A== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -5463,6 +5482,20 @@ ox@0.8.1: abitype "^1.0.8" eventemitter3 "5.0.1" +ox@0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/ox/-/ox-0.9.3.tgz#92cc1008dcd913e919364fd4175c860b3eeb18db" + integrity sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg== + dependencies: + "@adraffy/ens-normalize" "^1.11.0" + "@noble/ciphers" "^1.3.0" + "@noble/curves" "1.9.1" + "@noble/hashes" "^1.8.0" + "@scure/bip32" "^1.7.0" + "@scure/bip39" "^1.6.0" + abitype "^1.0.9" + eventemitter3 "5.0.1" + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -6624,6 +6657,20 @@ viem@^2.1.1: webauthn-p256 "0.0.10" ws "8.18.0" +viem@^2.33.0: + version "2.37.4" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.37.4.tgz#94c9e837b4a7ef6f7b6c033487a12625534bd8bc" + integrity sha512-1ig5O6l1wJmaw3yrSrUimjRLQEZon2ymTqSDjdntu6Bry1/tLC2GClXeS3SiCzrifpLxzfCLQWDITYVTBA10KA== + dependencies: + "@noble/curves" "1.9.1" + "@noble/hashes" "1.8.0" + "@scure/bip32" "1.7.0" + "@scure/bip39" "1.6.0" + abitype "1.1.0" + isows "1.0.7" + ox "0.9.3" + ws "8.18.3" + wagmi@2.15.6: version "2.15.6" resolved "https://registry.yarnpkg.com/wagmi/-/wagmi-2.15.6.tgz#eaad3576f29f383bb082cac53694fae3a9075393" @@ -6748,6 +6795,11 @@ ws@8.18.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a" integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ== +ws@8.18.3: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + ws@^7.3.1, ws@^7.5.1: version "7.5.9" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"