From db6d6e797c1627cb00eafdaaebfc9cf70731dfc0 Mon Sep 17 00:00:00 2001 From: Rorry Date: Thu, 21 Mar 2024 17:00:31 -0300 Subject: [PATCH 1/2] feat(SWA-146): Add Openocean support (#342) * chore: skip failing SushiswapV3 tests due to external API changes * fix: add custom hook to fix how the graphql-request generated code is imported * feat(SWA-146): add Openocean as a routable platform --------- Co-authored-by: Berteotti --- graphql-codegen.yml | 7 +- src/entities/trades/index.ts | 1 + src/entities/trades/openocean/Openocean.ts | 233 ++++++++++++++++++ src/entities/trades/openocean/api.ts | 37 +++ src/entities/trades/openocean/constants.ts | 15 ++ src/entities/trades/openocean/index.ts | 1 + .../trades/openocean/openocean.spec.ts | 79 ++++++ .../routable-platform/RoutablePlatform.ts | 13 + .../trades/sushiswap/sushiswap.spec.ts | 2 +- 9 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 src/entities/trades/openocean/Openocean.ts create mode 100644 src/entities/trades/openocean/api.ts create mode 100644 src/entities/trades/openocean/constants.ts create mode 100644 src/entities/trades/openocean/index.ts create mode 100644 src/entities/trades/openocean/openocean.spec.ts diff --git a/graphql-codegen.yml b/graphql-codegen.yml index e8d9d7ee..d2b734e1 100644 --- a/graphql-codegen.yml +++ b/graphql-codegen.yml @@ -1,11 +1,10 @@ - - overwrite: true -schema: "https://api.thegraph.com/subgraphs/name/dxgraphs/swapr-xdai-v2" +schema: 'https://api.thegraph.com/subgraphs/name/dxgraphs/swapr-xdai-v2' documents: 'src/**/!(*.d).{ts,tsx}' generates: ./src/generated/graphql/index.ts: plugins: - typescript - typescript-operations - - typescript-graphql-request \ No newline at end of file + - typescript-graphql-request +hooks: { afterOneFileWrite: ['sed -i -e"s|graphql-request/dist/types\.dom|graphql-request/src/types.dom|g"'] } diff --git a/src/entities/trades/index.ts b/src/entities/trades/index.ts index 278738b9..b7cf9655 100644 --- a/src/entities/trades/index.ts +++ b/src/entities/trades/index.ts @@ -4,6 +4,7 @@ export * from './gnosis-protocol' export * from './interfaces/trade' export * from './interfaces/trade-options' export * from './OneInch' +export * from './openocean' export { BaseRoutablePlatform, RoutablePlatform, UniswapV2RoutablePlatform } from './routable-platform' export * from './sushiswap' export * from './swapr-v3' diff --git a/src/entities/trades/openocean/Openocean.ts b/src/entities/trades/openocean/Openocean.ts new file mode 100644 index 00000000..8fccd354 --- /dev/null +++ b/src/entities/trades/openocean/Openocean.ts @@ -0,0 +1,233 @@ +import { BaseProvider } from '@ethersproject/providers' +import { UnsignedTransaction } from '@ethersproject/transactions' +import { parseUnits } from '@ethersproject/units' +import fetch from 'node-fetch' +import invariant from 'tiny-invariant' + +import { ChainId, ONE, TradeType } from '../../../constants' +import { Currency } from '../../currency' +import { CurrencyAmount, Fraction, Percent, Price, TokenAmount } from '../../fractions' +import { maximumSlippage as defaultMaximumSlippage } from '../constants' +import { Trade } from '../interfaces/trade' +import { TradeOptions } from '../interfaces/trade-options' +import { RoutablePlatform } from '../routable-platform' +import { getProvider, tryGetChainId, wrappedCurrency } from '../utils' +import { getBaseUrlWithChainCode, MainnetChainIds, OO_API_ENDPOINTS, OO_API_SWAPR_REFERRER } from './api' +import { OO_CONTRACT_ADDRESS_BY_CHAIN } from './constants' + +export interface OpenoceanQuoteTypes { + amount: CurrencyAmount + quoteCurrency: Currency + tradeType: TradeType + maximumSlippage?: Percent + recipient?: string +} + +interface OpenoceanConstructorParams { + maximumSlippage: Percent + inputAmount: CurrencyAmount + outputAmount: CurrencyAmount + tradeType: TradeType + chainId: ChainId + approveAddress: string + priceImpact: Percent +} + +export class OpenoceanTrade extends Trade { + public constructor({ + maximumSlippage, + inputAmount, + outputAmount, + tradeType, + chainId, + approveAddress, + priceImpact, + }: OpenoceanConstructorParams) { + super({ + details: undefined, + type: tradeType, + inputAmount, + outputAmount, + maximumSlippage, + platform: RoutablePlatform.OPENOCEAN, + chainId, + executionPrice: new Price({ + baseCurrency: inputAmount.currency, + quoteCurrency: outputAmount.currency, + denominator: inputAmount.raw, + numerator: outputAmount.raw, + }), + priceImpact, + approveAddress, + }) + } + + private static async getGas(chainId: MainnetChainIds) { + const baseUrl = getBaseUrlWithChainCode(chainId) + const gasResponse = await fetch(`${baseUrl}/${OO_API_ENDPOINTS.GET_GAS}`) + + if (!gasResponse.ok) throw new Error(`OpenoceanTrade.getQuote: failed to get gasPrice`) + + const gasData = await gasResponse.json() + + return gasData.without_decimals.standard + } + + static async getQuote( + { amount, quoteCurrency, maximumSlippage = defaultMaximumSlippage, tradeType }: OpenoceanQuoteTypes, + provider?: BaseProvider, + ): Promise { + const chainId = tryGetChainId(amount, quoteCurrency) + + if (!chainId) { + throw new Error('OpenoceanTrade.getQuote: chainId is required') + } + + provider = provider || getProvider(chainId) + + // Ensure the provider's chainId matches the provided currencies + invariant( + (await provider.getNetwork()).chainId == chainId, + `OpenoceanTrade.getQuote: currencies chainId does not match provider's chainId`, + ) + + const currencyIn = amount.currency + const currencyOut = quoteCurrency + + // Ensure that the currencies are present + invariant(currencyIn.address && currencyOut.address, `getQuote: Currency address is required`) + + try { + const baseUrl = getBaseUrlWithChainCode(chainId as MainnetChainIds) + const gasPrice = await this.getGas(chainId as MainnetChainIds) + const params = new URL(`${baseUrl}/${OO_API_ENDPOINTS.QUOTE}`) + + params.searchParams.set( + 'inTokenAddress', + `${Currency.isNative(currencyIn) ? '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' : currencyIn.address}`, + ) + params.searchParams.set( + 'outTokenAddress', + `${Currency.isNative(currencyOut) ? '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' : currencyOut.address}`, + ) + params.searchParams.set('amount', `${parseUnits(amount.toSignificant(), 0).toString()}`) + params.searchParams.set('gasPrice', chainId === ChainId.MAINNET ? gasPrice.maxFeePerGas : gasPrice) + params.searchParams.set( + 'slippage', + `${new Fraction(maximumSlippage.numerator, maximumSlippage.denominator).toSignificant(1)}`, + ) + + const res = await fetch(params.toString()) + const data = await res.json() + + if (data && amount) { + const approveAddress = OO_CONTRACT_ADDRESS_BY_CHAIN[chainId as MainnetChainIds] + const currencyAmountIn = Currency.isNative(currencyIn) + ? CurrencyAmount.nativeCurrency(data.data.inAmount, chainId) + : new TokenAmount(wrappedCurrency(currencyIn, chainId), data.data.inAmount) + + const currencyAmountOut = Currency.isNative(currencyOut) + ? CurrencyAmount.nativeCurrency(data.data.outAmount, chainId) + : new TokenAmount(wrappedCurrency(currencyOut, chainId), data.data.outAmount) + + return new OpenoceanTrade({ + maximumSlippage, + inputAmount: currencyAmountIn, + outputAmount: currencyAmountOut, + tradeType, + chainId, + approveAddress, + priceImpact: new Percent('0', '100'), + }) + } + } catch (error) { + console.error('Openocean.getQuote: Error fetching the quote:', error.message) + return null + } + + return null + } + + public minimumAmountOut(): CurrencyAmount { + if (this.tradeType === TradeType.EXACT_OUTPUT) { + return this.outputAmount + } else { + const slippageAdjustedAmountOut = new Fraction(ONE) + .add(this.maximumSlippage) + .invert() + .multiply(this.outputAmount.raw).quotient + return this.outputAmount instanceof TokenAmount + ? new TokenAmount(this.outputAmount.token, slippageAdjustedAmountOut) + : CurrencyAmount.nativeCurrency(slippageAdjustedAmountOut, this.chainId) + } + } + + public maximumAmountIn(): CurrencyAmount { + if (this.tradeType === TradeType.EXACT_INPUT) { + return this.inputAmount + } else { + const slippageAdjustedAmountIn = new Fraction(ONE) + .add(this.maximumSlippage) + .multiply(this.inputAmount.raw).quotient + return this.inputAmount instanceof TokenAmount + ? new TokenAmount(this.inputAmount.token, slippageAdjustedAmountIn) + : CurrencyAmount.nativeCurrency(slippageAdjustedAmountIn, this.chainId) + } + } + + /** + * Returns unsigned transaction for the trade + * @returns the unsigned transaction + */ + public async swapTransaction(options: TradeOptions): Promise { + invariant(options, 'OpenoceanTrade.swapTransaction: Currency address is required') + + /** + * @see https://docs.openocean.finance/dev/aggregator-api-and-sdk/aggregator-api/best-practice + */ + + const inToken = this.inputAmount.currency + const outToken = this.outputAmount.currency + const amount = this.inputAmount + const maximumSlippage = this.maximumSlippage + + const receivedSlippage = new Fraction(maximumSlippage.numerator, maximumSlippage.denominator).toSignificant(1) + const slippage = +receivedSlippage < 0.05 ? 0.05 : receivedSlippage + + try { + // Ensure that the currencies are present + invariant(inToken.address && outToken.address, `getQuote: Currency address is required`) + + const baseUrl = getBaseUrlWithChainCode(this.chainId as MainnetChainIds) + const quoteGasPrice = await OpenoceanTrade.getGas(this.chainId as MainnetChainIds) + const params = new URL(`${baseUrl}/${OO_API_ENDPOINTS.SWAP_QUOTE}`) + + params.searchParams.set( + 'inTokenAddress', + `${Currency.isNative(inToken) ? '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' : inToken.address}`, + ) + params.searchParams.set( + 'outTokenAddress', + `${Currency.isNative(outToken) ? '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' : outToken.address}`, + ) + params.searchParams.set('amount', `${parseUnits(amount.toSignificant(), 0).toString()}`) + params.searchParams.set('referrer', `${OO_API_SWAPR_REFERRER}`) + params.searchParams.set('account', options.recipient) + params.searchParams.set('gasPrice', this.chainId === ChainId.MAINNET ? quoteGasPrice.maxFeePerGas : quoteGasPrice) + params.searchParams.set('slippage', `${slippage}`) + + const res = await fetch(params.toString()) + const swapQuoteData = await res.json() + const { data, gasPrice, to, value } = swapQuoteData?.data + + return { + to, + gasPrice, + data, + value, + } + } catch (error) { + throw new Error(`Openocean.getQuote: Error fetching the trade: ${error.message}`) + } + } +} diff --git a/src/entities/trades/openocean/api.ts b/src/entities/trades/openocean/api.ts new file mode 100644 index 00000000..572e9080 --- /dev/null +++ b/src/entities/trades/openocean/api.ts @@ -0,0 +1,37 @@ +import { ChainId } from '../../../constants' + +export const OO_API_BASE_URL = 'https://open-api.openocean.finance/v3' +export const OO_API_SWAPR_REFERRER = '0xdaF6CABd165Fd44c037575a97cF3562339295Ea3' + +export enum OO_API_ENDPOINTS { + GET_GAS = 'gasPrice', + QUOTE = 'quote', + SWAP_QUOTE = 'swap_quote', +} + +export type TestChainIds = + | ChainId.ARBITRUM_GOERLI + | ChainId.ARBITRUM_RINKEBY + | ChainId.BSC_TESTNET + | ChainId.GOERLI + | ChainId.OPTIMISM_GOERLI + | ChainId.ZK_SYNC_ERA_TESTNET + | ChainId.RINKEBY + +export type MainnetChainIds = Exclude + +/** + * @see https://docs.openocean.finance/dev/supported-chains + */ +const OO_API_CHAIN_CODE = { + [ChainId.ARBITRUM_ONE]: 'arbitrum', + [ChainId.BSC_MAINNET]: 'bsc', + [ChainId.GNOSIS]: 'xdai', + [ChainId.MAINNET]: 'eth', + [ChainId.OPTIMISM_MAINNET]: 'optimism', + [ChainId.POLYGON]: 'polygon', + [ChainId.SCROLL_MAINNET]: 'scroll', + [ChainId.ZK_SYNC_ERA_MAINNET]: 'zksync', +} + +export const getBaseUrlWithChainCode = (chainId: MainnetChainIds) => `${OO_API_BASE_URL}/${OO_API_CHAIN_CODE[chainId]}` diff --git a/src/entities/trades/openocean/constants.ts b/src/entities/trades/openocean/constants.ts new file mode 100644 index 00000000..16554829 --- /dev/null +++ b/src/entities/trades/openocean/constants.ts @@ -0,0 +1,15 @@ +import { ChainId } from '../../../constants' + +/** + * @see https://docs.openocean.finance/dev/contracts-of-chains + */ +export const OO_CONTRACT_ADDRESS_BY_CHAIN = { + [ChainId.ARBITRUM_ONE]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.BSC_MAINNET]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.GNOSIS]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.MAINNET]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.OPTIMISM_MAINNET]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.POLYGON]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.SCROLL_MAINNET]: '0x6352a56caadc4f1e25cd6c75970fa768a3304e64', + [ChainId.ZK_SYNC_ERA_MAINNET]: '0x36A1aCbbCAfca2468b85011DDD16E7Cb4d673230', +} diff --git a/src/entities/trades/openocean/index.ts b/src/entities/trades/openocean/index.ts new file mode 100644 index 00000000..e757b6c9 --- /dev/null +++ b/src/entities/trades/openocean/index.ts @@ -0,0 +1 @@ +export * from './Openocean' diff --git a/src/entities/trades/openocean/openocean.spec.ts b/src/entities/trades/openocean/openocean.spec.ts new file mode 100644 index 00000000..cd9d43a1 --- /dev/null +++ b/src/entities/trades/openocean/openocean.spec.ts @@ -0,0 +1,79 @@ +import { parseUnits } from '@ethersproject/units' + +import { ChainId, TradeType } from '../../../constants' +import { Percent, TokenAmount } from '../../fractions' +import { ARB, SWPR, WBNB, WETH, WMATIC } from '../../token' +import { USDT } from '../uniswap-v2' +import { OpenoceanTrade } from './Openocean' + +const maximumSlippage = new Percent('3', '100') +const recipient = '0x0000000000000000000000000000000000000000' + +const QUOTE_TESTS = [ + { chainId: ChainId.ARBITRUM_ONE, chainName: 'Arbitrum One', quoteCurrency: ARB, quoteCurrencyName: 'ARB' }, + { chainId: ChainId.BSC_MAINNET, chainName: 'Binance Smart Chain', quoteCurrency: WBNB, quoteCurrencyName: 'WBNB' }, + { chainId: ChainId.GNOSIS, chainName: 'Gnosis', quoteCurrency: SWPR, quoteCurrencyName: 'SWPR' }, + { chainId: ChainId.MAINNET, chainName: 'Ethereum', quoteCurrency: WETH, quoteCurrencyName: 'WETH' }, + { chainId: ChainId.OPTIMISM_MAINNET, chainName: 'Optimism', quoteCurrency: WETH, quoteCurrencyName: 'WETH' }, + { chainId: ChainId.POLYGON, chainName: 'Polygon', quoteCurrency: WMATIC, quoteCurrencyName: 'WMATIC' }, + { chainId: ChainId.SCROLL_MAINNET, chainName: 'Sroll', quoteCurrency: WETH, quoteCurrencyName: 'WETH' }, +] + +const SWAP_TESTS = [ + { chainId: ChainId.ARBITRUM_ONE, chainName: 'Arbitrum One', quoteCurrency: ARB, quoteCurrencyName: 'ARB' }, + { chainId: ChainId.BSC_MAINNET, chainName: 'Binance Smart Chain', quoteCurrency: WBNB, quoteCurrencyName: 'WBNB' }, + { chainId: ChainId.GNOSIS, chainName: 'Gnosis', quoteCurrency: SWPR, quoteCurrencyName: 'SWPR' }, + { chainId: ChainId.MAINNET, chainName: 'Ethereum', quoteCurrency: WETH, quoteCurrencyName: 'WETH' }, + { chainId: ChainId.OPTIMISM_MAINNET, chainName: 'Optimism', quoteCurrency: WETH, quoteCurrencyName: 'WETH' }, + { chainId: ChainId.POLYGON, chainName: 'Polygon', quoteCurrency: WMATIC, quoteCurrencyName: 'WMATIC' }, + { chainId: ChainId.SCROLL_MAINNET, chainName: 'Sroll', quoteCurrency: WETH, quoteCurrencyName: 'WETH' }, +] + +describe('Openocean', () => { + describe('Quote', () => { + it.each(QUOTE_TESTS)( + 'should return a EXACT INPUT quote on $chainName for USDT - $quoteCurrencyName', + async ({ chainId, quoteCurrency }) => { + const currencyAmount = new TokenAmount(USDT[chainId], parseUnits('100', 18).toString()) + const trade = await OpenoceanTrade.getQuote({ + amount: currencyAmount, + quoteCurrency: quoteCurrency[chainId], + maximumSlippage, + recipient, + tradeType: TradeType.EXACT_INPUT, + }) + + expect(trade).toBeDefined() + expect(trade).not.toBeNull() + expect(trade?.chainId).toEqual(chainId) + expect(trade?.tradeType).toEqual(TradeType.EXACT_INPUT) + expect(trade?.outputAmount.currency.address).toBe(quoteCurrency[chainId].address) + }, + ) + }) + + describe('Swap', () => { + it.each(SWAP_TESTS)( + 'should return a SWAP on $chainName for USDT - $quoteCurrencyName', + async ({ chainId, quoteCurrency }) => { + const currencyAmount = new TokenAmount(USDT[chainId], parseUnits('100', 18).toString()) + + const trade = await OpenoceanTrade.getQuote({ + amount: currencyAmount, + quoteCurrency: quoteCurrency[chainId], + maximumSlippage, + recipient, + tradeType: TradeType.EXACT_INPUT, + }) + + const swapOptions = { + recipient, + account: recipient, + } + + const swap = await trade?.swapTransaction(swapOptions) + expect(swap !== undefined) + }, + ) + }) +}) diff --git a/src/entities/trades/routable-platform/RoutablePlatform.ts b/src/entities/trades/routable-platform/RoutablePlatform.ts index 75eb8bad..8413f455 100644 --- a/src/entities/trades/routable-platform/RoutablePlatform.ts +++ b/src/entities/trades/routable-platform/RoutablePlatform.ts @@ -45,4 +45,17 @@ export class RoutablePlatform extends BaseRoutablePlatform { ], 'Sushiswap', ) + public static readonly OPENOCEAN = new RoutablePlatform( + [ + ChainId.ARBITRUM_ONE, + ChainId.BSC_MAINNET, + ChainId.GNOSIS, + ChainId.MAINNET, + ChainId.OPTIMISM_MAINNET, + ChainId.POLYGON, + ChainId.SCROLL_MAINNET, + ChainId.ZK_SYNC_ERA_MAINNET, + ], + 'OpenOcean', + ) } diff --git a/src/entities/trades/sushiswap/sushiswap.spec.ts b/src/entities/trades/sushiswap/sushiswap.spec.ts index a9ed5854..713484a8 100644 --- a/src/entities/trades/sushiswap/sushiswap.spec.ts +++ b/src/entities/trades/sushiswap/sushiswap.spec.ts @@ -18,7 +18,7 @@ const tokenGNOGnosis = new Token( const recipient = '0x0000000000000000000000000000000000000000' -describe('Sushiswap', () => { +describe.skip('Sushiswap', () => { describe('Quote', () => { test('should return a EXACT INPUT quote on Gnosis for USDC - WXDAI', async () => { const currencyAmount = new TokenAmount( From 212a54244683f864a22f53759877bdb9bfa88db5 Mon Sep 17 00:00:00 2001 From: Rorry Date: Fri, 22 Mar 2024 11:59:34 -0300 Subject: [PATCH 2/2] 1.11.4 (#349) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 401ebf2f..21e00fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@swapr/sdk", - "version": "1.11.3", + "version": "1.11.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@swapr/sdk", - "version": "1.11.3", + "version": "1.11.4", "license": "AGPL-3.0-or-later", "dependencies": { "@cowprotocol/cow-sdk": "^1.0.2-RC.0", diff --git a/package.json b/package.json index 79139d4d..db082e08 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@swapr/sdk", "license": "AGPL-3.0-or-later", - "version": "1.11.3", + "version": "1.11.4", "description": "An SDK for building applications on top of Swapr", "main": "dist/index.js", "typings": "dist/index.d.ts",