From d016d8c1b7e1e398b2028822f4ff4b5b4cc6d836 Mon Sep 17 00:00:00 2001 From: Jeff Huang Date: Mon, 13 Nov 2023 10:11:35 +0800 Subject: [PATCH] feat: support OpenOceanV2 on Metis --- .changeset/ninety-seas-fry.md | 5 + .env.metis | 2 + package.json | 3 +- src/logics/index.ts | 1 + src/logics/openocean-v2/configs.ts | 37 ++++++++ src/logics/openocean-v2/index.ts | 2 + .../openocean-v2/logic.swap-token.test.ts | 67 +++++++++++++ src/logics/openocean-v2/logic.swap-token.ts | 93 +++++++++++++++++++ src/logics/openocean-v2/slippage.test.ts | 54 +++++++++++ src/logics/openocean-v2/slippage.ts | 7 ++ .../openocean-v2/tokens/data/metis.json | 30 ++++++ src/logics/openocean-v2/tokens/index.ts | 6 ++ test/logics/openocean-v2/swap-token.test.ts | 80 ++++++++++++++++ 13 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 .changeset/ninety-seas-fry.md create mode 100644 .env.metis create mode 100644 src/logics/openocean-v2/configs.ts create mode 100644 src/logics/openocean-v2/index.ts create mode 100644 src/logics/openocean-v2/logic.swap-token.test.ts create mode 100644 src/logics/openocean-v2/logic.swap-token.ts create mode 100644 src/logics/openocean-v2/slippage.test.ts create mode 100644 src/logics/openocean-v2/slippage.ts create mode 100644 src/logics/openocean-v2/tokens/data/metis.json create mode 100644 src/logics/openocean-v2/tokens/index.ts create mode 100644 test/logics/openocean-v2/swap-token.test.ts diff --git a/.changeset/ninety-seas-fry.md b/.changeset/ninety-seas-fry.md new file mode 100644 index 00000000..d3b671b1 --- /dev/null +++ b/.changeset/ninety-seas-fry.md @@ -0,0 +1,5 @@ +--- +'@protocolink/logics': patch +--- + +support OpenOceanV2 on Metis diff --git a/.env.metis b/.env.metis new file mode 100644 index 00000000..f8c71709 --- /dev/null +++ b/.env.metis @@ -0,0 +1,2 @@ +CHAIN_ID=1088 +HTTP_RPC_URL=https://andromeda.metis.io/?owner=1088 diff --git a/package.json b/package.json index 167e9b99..577e4153 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,10 @@ "lint": "eslint --fix src", "prepublishOnly": "yarn build", "test": "mocha", - "test:e2e": "yarn run test:e2e:mainnet && yarn run test:e2e:polygon && yarn run test:e2e:arbitrum", + "test:e2e": "yarn run test:e2e:mainnet && yarn run test:e2e:polygon && yarn run test:e2e:metis && yarn run test:e2e:arbitrum", "test:e2e:mainnet": "env-cmd -f .env.mainnet hardhat test --grep 'mainnet'", "test:e2e:polygon": "env-cmd -f .env.polygon hardhat test --grep 'polygon'", + "test:e2e:metis": "env-cmd -f .env.metis hardhat test --grep 'metis'", "test:e2e:arbitrum": "env-cmd -f .env.arbitrum hardhat test --grep 'arbitrum'", "test:unit": "mocha --recursive src" }, diff --git a/src/logics/index.ts b/src/logics/index.ts index 2b591abc..13b3c3de 100644 --- a/src/logics/index.ts +++ b/src/logics/index.ts @@ -8,3 +8,4 @@ export * as syncswap from './syncswap'; export * as uniswapv3 from './uniswap-v3'; export * as utility from './utility'; export * as radiantv2 from './radiant-v2'; +export * as openoceanv2 from './openocean-v2'; diff --git a/src/logics/openocean-v2/configs.ts b/src/logics/openocean-v2/configs.ts new file mode 100644 index 00000000..2a6a6e37 --- /dev/null +++ b/src/logics/openocean-v2/configs.ts @@ -0,0 +1,37 @@ +import * as common from '@protocolink/common'; + +export interface Config { + chainId: number; + exchangeAddress: string; + gasPrice: string; +} + +export const configs: Config[] = [ + { + chainId: common.ChainId.metis, + exchangeAddress: '0x6352a56caadC4F1E25CD6c75970Fa768A3304e64', + gasPrice: '20', + }, +]; + +export const [supportedChainIds, configMap] = configs.reduce( + (accumulator, config) => { + accumulator[0].push(config.chainId); + accumulator[1][config.chainId] = config; + return accumulator; + }, + [[], {}] as [number[], Record] +); + +export function getExchangeAddress(chainId: number) { + return configMap[chainId].exchangeAddress; +} + +export function getGasPrice(chainId: number) { + return configMap[chainId].gasPrice; +} + +export function getApiUrl(chainId: number) { + const url = 'https://open-api.openocean.finance/v3'; + return url + `/${chainId}`; +} diff --git a/src/logics/openocean-v2/index.ts b/src/logics/openocean-v2/index.ts new file mode 100644 index 00000000..6906b869 --- /dev/null +++ b/src/logics/openocean-v2/index.ts @@ -0,0 +1,2 @@ +export * from './configs'; +export * from './logic.swap-token'; diff --git a/src/logics/openocean-v2/logic.swap-token.test.ts b/src/logics/openocean-v2/logic.swap-token.test.ts new file mode 100644 index 00000000..6ebc50ec --- /dev/null +++ b/src/logics/openocean-v2/logic.swap-token.test.ts @@ -0,0 +1,67 @@ +import { LogicTestCaseWithChainId } from 'test/types'; +import { SwapTokenLogic, SwapTokenLogicFields, SwapTokenLogicOptions } from './logic.swap-token'; +import * as common from '@protocolink/common'; +import { constants, utils } from 'ethers'; +import * as core from '@protocolink/core'; +import { expect } from 'chai'; +import { getExchangeAddress } from './configs'; +import { metisTokens } from 'src/logics/openocean-v2/tokens'; + +describe('OpenOceanV2 SwapTokenLogic', function () { + context('Test getTokenList', async function () { + SwapTokenLogic.supportedChainIds.forEach((chainId) => { + it(`network: ${common.toNetworkId(chainId)}`, async function () { + const logic = new SwapTokenLogic(chainId); + const tokenList = await logic.getTokenList(); + expect(tokenList).to.have.lengthOf.above(0); + }); + }); + }); + + context('Test build', function () { + const testCases: LogicTestCaseWithChainId[] = [ + { + chainId: common.ChainId.metis, + fields: { + input: new common.TokenAmount(metisTokens.WETH, '1'), + output: new common.TokenAmount(metisTokens.USDC, '0'), + slippage: 100, + }, + options: { account: '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa' }, + }, + { + chainId: common.ChainId.metis, + fields: { + input: new common.TokenAmount(metisTokens.METIS, '1'), + output: new common.TokenAmount(metisTokens.USDC, '0'), + slippage: 100, + }, + options: { account: '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa' }, + }, + { + chainId: common.ChainId.metis, + fields: { + input: new common.TokenAmount(metisTokens.USDC, '1'), + output: new common.TokenAmount(metisTokens.METIS, '0'), + slippage: 100, + }, + options: { account: '0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa' }, + }, + ]; + + testCases.forEach(({ chainId, fields, options }) => { + it(`${fields.input.token.symbol} to ${fields.output.token.symbol}`, async function () { + const logic = new SwapTokenLogic(chainId); + const routerLogic = await logic.build(fields, options); + const { input } = fields; + + expect(routerLogic.to).to.eq(getExchangeAddress(chainId)); + expect(utils.isBytesLike(routerLogic.data)).to.be.true; + expect(routerLogic.inputs[0].balanceBps).to.eq(core.BPS_NOT_USED); + expect(routerLogic.inputs[0].amountOrOffset).to.eq(input.amountWei); + expect(routerLogic.approveTo).to.eq(constants.AddressZero); + expect(routerLogic.callback).to.eq(constants.AddressZero); + }); + }); + }); +}); diff --git a/src/logics/openocean-v2/logic.swap-token.ts b/src/logics/openocean-v2/logic.swap-token.ts new file mode 100644 index 00000000..adc006a5 --- /dev/null +++ b/src/logics/openocean-v2/logic.swap-token.ts @@ -0,0 +1,93 @@ +import { axios } from 'src/utils'; +import * as common from '@protocolink/common'; +import * as core from '@protocolink/core'; +import { getApiUrl, getGasPrice, supportedChainIds } from './configs'; +import { slippageToOpenOcean, slippageToProtocolink } from './slippage'; + +export type SwapTokenLogicTokenList = common.Token[]; + +export type SwapTokenLogicParams = core.TokenToTokenExactInParams<{ + slippage?: number; + disabledDexIds?: string; +}>; + +export type SwapTokenLogicFields = core.TokenToTokenExactInFields<{ + slippage?: number; + disabledDexIds?: string; +}>; + +export type SwapTokenLogicOptions = Pick; + +@core.LogicDefinitionDecorator() +export class SwapTokenLogic + extends core.Logic + implements core.LogicTokenListInterface, core.LogicOracleInterface, core.LogicBuilderInterface +{ + static readonly supportedChainIds = supportedChainIds; + + async getTokenList() { + const url = getApiUrl(this.chainId); + const resp = await axios.get(url + '/tokenList'); + const tokens = resp.data.data; + const tokenList: SwapTokenLogicTokenList = []; + for (const { address, decimals, symbol, name } of tokens) { + tokenList.push(new common.Token(this.chainId, address, decimals, symbol, name)); + } + return tokenList; + } + + // If you wish to exclude quotes from a specific DEX, you can include the corresponding DEX ID + // in the 'disabledDexIds' parameter. You can retrieve the DEX IDs from the following API: + // https://open-api.openocean.finance/v3/{chainId}/dexList + async quote(params: SwapTokenLogicParams) { + const { input, tokenOut, disabledDexIds } = params; + const url = getApiUrl(this.chainId); + const gasPrice = getGasPrice(this.chainId); + let slippage = slippageToOpenOcean(params.slippage ?? 0); + + const resp = await axios.get(url + '/quote', { + params: { + inTokenAddress: input.token.address, + outTokenAddress: tokenOut.address, + amount: input.amount, + gasPrice, + slippage, + disabledDexIds, + }, + }); + + slippage = slippageToProtocolink(slippage); + + const { outAmount } = resp.data.data; + const output = new common.TokenAmount(tokenOut).setWei(outAmount); + return { input, output, slippage, disabledDexIds }; + } + + // Different gas_price will lead to different routes. + // This is due to that OpenOcean calculates the best overall return. + // The best overall return = out_value - tx cost and the tx_cost = gas_used & gas_price + async build(fields: SwapTokenLogicFields, options: SwapTokenLogicOptions) { + const { input, output, disabledDexIds } = fields; + const { account } = options; + const url = getApiUrl(this.chainId); + const gasPrice = getGasPrice(this.chainId); + const agent = await this.calcAgent(account); + const slippage = slippageToOpenOcean(fields.slippage ?? 0); + + const resp = await axios.get(url + '/swap_quote', { + params: { + inTokenAddress: input.token.address, + outTokenAddress: output.token.address, + amount: input.amount, + gasPrice, + slippage, + account: agent, + disabledDexIds, + }, + }); + + const { to, data } = resp.data.data; + const inputs = [core.newLogicInput({ input })]; + return core.newLogic({ to, data, inputs }); + } +} diff --git a/src/logics/openocean-v2/slippage.test.ts b/src/logics/openocean-v2/slippage.test.ts new file mode 100644 index 00000000..18bb62a9 --- /dev/null +++ b/src/logics/openocean-v2/slippage.test.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import { slippageToOpenOcean, slippageToProtocolink } from './slippage'; + +describe('Test slippageToOpenOcean', function () { + const testCases = [ + { + title: 'in range integer', + slippage: 100, + expected: 1, + }, + { + title: 'in range floating number', + slippage: 150, + expected: 1.5, + }, + { + title: 'out of range minimal', + slippage: 0, + expected: 0.05, + }, + { + title: 'out of range maximal', + slippage: 10000, + expected: 50, + }, + ]; + + testCases.forEach(({ title, slippage, expected }) => { + it(title, function () { + expect(slippageToOpenOcean(slippage) === expected).to.be.true; + }); + }); +}); + +describe('Test slippageToProtocolink', function () { + const testCases = [ + { + title: 'in range integer', + slippage: 1, + expected: 100, + }, + { + title: 'in range floating number', + slippage: 1.5, + expected: 150, + }, + ]; + + testCases.forEach(({ title, slippage, expected }) => { + it(title, function () { + expect(slippageToProtocolink(slippage) === expected).to.be.true; + }); + }); +}); diff --git a/src/logics/openocean-v2/slippage.ts b/src/logics/openocean-v2/slippage.ts new file mode 100644 index 00000000..2753e167 --- /dev/null +++ b/src/logics/openocean-v2/slippage.ts @@ -0,0 +1,7 @@ +export function slippageToOpenOcean(slippage: number) { + return Math.min(Math.max(slippage / 100, 0.05), 50); +} + +export function slippageToProtocolink(slippage: number) { + return slippage * 100; +} diff --git a/src/logics/openocean-v2/tokens/data/metis.json b/src/logics/openocean-v2/tokens/data/metis.json new file mode 100644 index 00000000..6ad2f62c --- /dev/null +++ b/src/logics/openocean-v2/tokens/data/metis.json @@ -0,0 +1,30 @@ +{ + "METIS": { + "chainId": 1088, + "address": "0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000", + "decimals": 18, + "symbol": "METIS", + "name": "Metis" + }, + "USDC": { + "chainId": 1088, + "address": "0xEA32A96608495e54156Ae48931A7c20f0dcc1a21", + "decimals": 6, + "symbol": "m.USDC", + "name": "USDC Token" + }, + "WETH": { + "chainId": 1, + "address": "0x420000000000000000000000000000000000000a", + "decimals": 18, + "symbol": "WETH", + "name": "Wrapped Ether" + }, + "DAI": { + "chainId": 1088, + "address": "0x4c078361FC9BbB78DF910800A991C7c3DD2F6ce0", + "decimals": 18, + "symbol": "m.DAI", + "name": "DAI Token" + } +} diff --git a/src/logics/openocean-v2/tokens/index.ts b/src/logics/openocean-v2/tokens/index.ts new file mode 100644 index 00000000..cb537e67 --- /dev/null +++ b/src/logics/openocean-v2/tokens/index.ts @@ -0,0 +1,6 @@ +import * as common from '@protocolink/common'; +import metisTokensJSON from './data/metis.json'; + +type MetisTokenSymbols = keyof typeof metisTokensJSON; + +export const metisTokens = common.toTokenMap(metisTokensJSON); diff --git a/test/logics/openocean-v2/swap-token.test.ts b/test/logics/openocean-v2/swap-token.test.ts new file mode 100644 index 00000000..639e695c --- /dev/null +++ b/test/logics/openocean-v2/swap-token.test.ts @@ -0,0 +1,80 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { claimToken, getChainId, snapshotAndRevertEach } from '@protocolink/test-helpers'; +import * as common from '@protocolink/common'; +import * as core from '@protocolink/core'; +import { expect } from 'chai'; +import hre from 'hardhat'; +import { metisTokens } from 'src/logics/openocean-v2/tokens'; +import * as openoceanV2 from 'src/logics/openocean-v2'; +import * as utils from 'test/utils'; + +describe('metis: Test OpenOceanV2 SwapToken Logic', function () { + let chainId: number; + let user: SignerWithAddress; + + before(async function () { + chainId = await getChainId(); + [, user] = await hre.ethers.getSigners(); + await claimToken(chainId, user.address, metisTokens.METIS, '100', '0x7314Ef2CA509490f65F52CC8FC9E0675C66390b8'); + await claimToken(chainId, user.address, metisTokens.WETH, '100', '0xc5779AB95fc7C8B04c96f3431736F2455b0E6A1A'); + await claimToken(chainId, user.address, metisTokens.USDC, '3000', '0x885C8AEC5867571582545F894A5906971dB9bf27'); + }); + + snapshotAndRevertEach(); + + const testCases = [ + { + params: { + input: new common.TokenAmount(metisTokens.METIS, '1'), + tokenOut: metisTokens.USDC, + slippage: 100, + }, + }, + { + params: { + input: new common.TokenAmount(metisTokens.USDC, '1'), + tokenOut: metisTokens.METIS, + slippage: 100, + }, + }, + { + params: { + input: new common.TokenAmount(metisTokens.USDC, '1'), + tokenOut: metisTokens.DAI, + slippage: 100, + }, + }, + ]; + + testCases.forEach(({ params }, i) => { + it(`case ${i + 1}`, async function () { + // 1. get output + const openoceanV2SwapTokenLogic = new openoceanV2.SwapTokenLogic(chainId); + const quotation = await openoceanV2SwapTokenLogic.quote(params); + const { input, output } = quotation; + + // 2. build funds, tokensReturn + const funds = new common.TokenAmounts(input); + const tokensReturn = [output.token.elasticAddress]; + + // 3. build router logics + const routerLogics: core.DataType.LogicStruct[] = []; + routerLogics.push(await openoceanV2SwapTokenLogic.build(quotation, { account: user.address })); + + // 4. get router permit2 datas + const permit2Datas = await utils.getRouterPermit2Datas(chainId, user, funds.erc20); + + // 5. send router tx + const routerKit = new core.RouterKit(chainId); + const transactionRequest = routerKit.buildExecuteTransactionRequest({ + permit2Datas, + routerLogics, + tokensReturn, + value: 0, + }); + await expect(user.sendTransaction(transactionRequest)).to.not.be.reverted; + await expect(user.address).to.changeBalance(input.token, -input.amount); + await expect(user.address).to.changeBalance(output.token, output.amount, quotation.slippage); + }); + }); +});