|
| 1 | +import { |
| 2 | + type Address, |
| 3 | + Addresses, |
| 4 | + Chains, |
| 5 | + isSameAddress, |
| 6 | + timeoutPromise, |
| 7 | +} from "@balmy/sdk" |
| 8 | +import { AlwaysValidConfigAndContextSource } from "@balmy/sdk/dist/services/quotes/quote-sources/base/always-valid-source" |
| 9 | +import type { |
| 10 | + BuildTxParams, |
| 11 | + QuoteParams, |
| 12 | + QuoteSourceMetadata, |
| 13 | + SourceQuoteResponse, |
| 14 | + SourceQuoteTransaction, |
| 15 | +} from "@balmy/sdk/dist/services/quotes/quote-sources/types" |
| 16 | +import { |
| 17 | + addQuoteSlippage, |
| 18 | + calculateAllowanceTarget, |
| 19 | + checksum, |
| 20 | + failed, |
| 21 | +} from "@balmy/sdk/dist/services/quotes/quote-sources/utils" |
| 22 | + |
| 23 | +// Supported Networks: https://docs.odos.xyz/#future-oriented-and-scalable |
| 24 | +const ODOS_METADATA: QuoteSourceMetadata<OdosSupport> = { |
| 25 | + name: "Odos", |
| 26 | + supports: { |
| 27 | + chains: [ |
| 28 | + Chains.ETHEREUM.chainId, |
| 29 | + Chains.POLYGON.chainId, |
| 30 | + Chains.ARBITRUM.chainId, |
| 31 | + Chains.OPTIMISM.chainId, |
| 32 | + Chains.AVALANCHE.chainId, |
| 33 | + Chains.BNB_CHAIN.chainId, |
| 34 | + Chains.FANTOM.chainId, |
| 35 | + Chains.BASE_GOERLI.chainId, |
| 36 | + Chains.BASE.chainId, |
| 37 | + Chains.MODE.chainId, |
| 38 | + Chains.LINEA.chainId, |
| 39 | + Chains.MANTLE.chainId, |
| 40 | + Chains.SCROLL.chainId, |
| 41 | + ], |
| 42 | + swapAndTransfer: true, |
| 43 | + buyOrders: false, |
| 44 | + }, |
| 45 | + logoURI: "ipfs://Qma71evDJfVUSBU53qkf8eDDysUgojsZNSnFRWa4qWragz", |
| 46 | +} |
| 47 | + |
| 48 | +type SourcesConfig = |
| 49 | + | { sourceAllowlist?: string[]; sourceDenylist?: undefined } |
| 50 | + | { sourceAllowlist?: undefined; sourceDenylist?: string[] } |
| 51 | +type OdosSupport = { buyOrders: false; swapAndTransfer: true } |
| 52 | +type OdosConfig = { |
| 53 | + supportRFQs?: boolean |
| 54 | + referralCode?: number |
| 55 | +} & SourcesConfig |
| 56 | +type OdosData = { tx: SourceQuoteTransaction } |
| 57 | +export class CustomOdosQuoteSource extends AlwaysValidConfigAndContextSource< |
| 58 | + OdosSupport, |
| 59 | + OdosConfig, |
| 60 | + OdosData |
| 61 | +> { |
| 62 | + getMetadata() { |
| 63 | + return ODOS_METADATA |
| 64 | + } |
| 65 | + |
| 66 | + async quote( |
| 67 | + params: QuoteParams<OdosSupport, OdosConfig>, |
| 68 | + ): Promise<SourceQuoteResponse<OdosData>> { |
| 69 | + // Note: Odos supports simple and advanced quotes. Simple quotes may offer worse prices, but it resolves faster. Since the advanced quote |
| 70 | + // might timeout, we will make two quotes (one simple and one advanced) and we'll return the simple one if the other one timeouts |
| 71 | + const simpleQuote = getQuote({ ...params, simple: true }) |
| 72 | + const advancedQuote = timeoutPromise( |
| 73 | + getQuote({ ...params, simple: false }), |
| 74 | + params.request.config.timeout, |
| 75 | + { reduceBy: "100ms" }, |
| 76 | + ) |
| 77 | + const [simple, advanced] = await Promise.allSettled([ |
| 78 | + simpleQuote, |
| 79 | + advancedQuote, |
| 80 | + ]) |
| 81 | + |
| 82 | + if (advanced.status === "fulfilled") { |
| 83 | + return advanced.value |
| 84 | + } |
| 85 | + if (simple.status === "fulfilled") { |
| 86 | + return simple.value |
| 87 | + } |
| 88 | + |
| 89 | + return Promise.reject(simple.reason) |
| 90 | + } |
| 91 | + |
| 92 | + async buildTx({ |
| 93 | + request, |
| 94 | + }: BuildTxParams<OdosConfig, OdosData>): Promise<SourceQuoteTransaction> { |
| 95 | + return request.customData.tx |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +async function getQuote({ |
| 100 | + simple, |
| 101 | + components: { fetchService }, |
| 102 | + request: { |
| 103 | + chainId, |
| 104 | + sellToken, |
| 105 | + buyToken, |
| 106 | + order, |
| 107 | + accounts: { takeFrom, recipient }, |
| 108 | + config: { slippagePercentage, timeout }, |
| 109 | + }, |
| 110 | + config, |
| 111 | +}: QuoteParams<OdosSupport, OdosConfig> & { simple: boolean }): Promise< |
| 112 | + SourceQuoteResponse<OdosData> |
| 113 | +> { |
| 114 | + const checksummedSell = checksumAndMapIfNecessary(sellToken) |
| 115 | + const checksummedBuy = checksumAndMapIfNecessary(buyToken) |
| 116 | + const userAddr = checksum(takeFrom) |
| 117 | + const quoteBody = { |
| 118 | + chainId, |
| 119 | + inputTokens: [ |
| 120 | + { tokenAddress: checksummedSell, amount: order.sellAmount.toString() }, |
| 121 | + ], |
| 122 | + outputTokens: [{ tokenAddress: checksummedBuy, proportion: 1 }], |
| 123 | + userAddr, |
| 124 | + slippageLimitPercent: slippagePercentage, |
| 125 | + sourceWhitelist: config?.sourceAllowlist, |
| 126 | + sourceBlacklist: config?.sourceDenylist, |
| 127 | + simulate: !config.disableValidation, |
| 128 | + pathViz: false, |
| 129 | + disableRFQs: !config?.supportRFQs, // Disable by default |
| 130 | + simple, |
| 131 | + ...(config?.referralCode ? { referralCode: config?.referralCode } : {}), |
| 132 | + } |
| 133 | + |
| 134 | + const [quoteResponse, routerResponse] = await Promise.all([ |
| 135 | + fetchService.fetch("https://api.odos.xyz/sor/quote/v2", { |
| 136 | + body: JSON.stringify(quoteBody), |
| 137 | + method: "POST", |
| 138 | + headers: { "Content-Type": "application/json" }, |
| 139 | + timeout, |
| 140 | + }), |
| 141 | + fetchService.fetch(`https://api.odos.xyz/info/router/v2/${chainId}`, { |
| 142 | + headers: { "Content-Type": "application/json" }, |
| 143 | + timeout, |
| 144 | + }), |
| 145 | + ]) |
| 146 | + |
| 147 | + if (!quoteResponse.ok) { |
| 148 | + failed( |
| 149 | + ODOS_METADATA, |
| 150 | + chainId, |
| 151 | + sellToken, |
| 152 | + buyToken, |
| 153 | + await quoteResponse.text(), |
| 154 | + ) |
| 155 | + } |
| 156 | + if (!routerResponse.ok) { |
| 157 | + failed( |
| 158 | + ODOS_METADATA, |
| 159 | + chainId, |
| 160 | + sellToken, |
| 161 | + buyToken, |
| 162 | + await routerResponse.text(), |
| 163 | + ) |
| 164 | + } |
| 165 | + const { |
| 166 | + pathId, |
| 167 | + gasEstimate, |
| 168 | + outAmounts: [outputTokenAmount], |
| 169 | + }: QuoteResponse = await quoteResponse.json() |
| 170 | + |
| 171 | + const { address } = await routerResponse.json() |
| 172 | + |
| 173 | + const assembleResponse = await fetchService.fetch( |
| 174 | + "https://api.odos.xyz/sor/assemble", |
| 175 | + { |
| 176 | + body: JSON.stringify({ userAddr, pathId, receiver: recipient }), |
| 177 | + method: "POST", |
| 178 | + headers: { "Content-Type": "application/json" }, |
| 179 | + timeout, |
| 180 | + }, |
| 181 | + ) |
| 182 | + if (!assembleResponse.ok) { |
| 183 | + failed( |
| 184 | + ODOS_METADATA, |
| 185 | + chainId, |
| 186 | + sellToken, |
| 187 | + buyToken, |
| 188 | + await assembleResponse.text(), |
| 189 | + ) |
| 190 | + } |
| 191 | + const { |
| 192 | + transaction: { data, to, value }, |
| 193 | + }: AssemblyResponse = await assembleResponse.json() |
| 194 | + |
| 195 | + const quote = { |
| 196 | + sellAmount: order.sellAmount, |
| 197 | + buyAmount: BigInt(outputTokenAmount), |
| 198 | + estimatedGas: BigInt(gasEstimate), |
| 199 | + allowanceTarget: calculateAllowanceTarget(sellToken, address), |
| 200 | + customData: { |
| 201 | + tx: { |
| 202 | + to, |
| 203 | + calldata: data, |
| 204 | + value: BigInt(value), |
| 205 | + }, |
| 206 | + pathId, |
| 207 | + userAddr, |
| 208 | + recipient: recipient ? checksum(recipient) : userAddr, |
| 209 | + }, |
| 210 | + } |
| 211 | + |
| 212 | + return addQuoteSlippage(quote, "sell", slippagePercentage) |
| 213 | +} |
| 214 | + |
| 215 | +function checksumAndMapIfNecessary(address: Address) { |
| 216 | + return isSameAddress(address, Addresses.NATIVE_TOKEN) |
| 217 | + ? Addresses.ZERO_ADDRESS |
| 218 | + : checksum(address) |
| 219 | +} |
| 220 | + |
| 221 | +type QuoteResponse = { |
| 222 | + gasEstimate: number |
| 223 | + pathId: string |
| 224 | + outAmounts: string[] |
| 225 | +} |
| 226 | + |
| 227 | +type AssemblyResponse = { |
| 228 | + transaction: { |
| 229 | + to: Address |
| 230 | + data: string |
| 231 | + value: number |
| 232 | + } |
| 233 | +} |
0 commit comments