diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 724f7e16fb..2ac7ed200a 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -79,6 +79,7 @@ jobs: cp -rf src/templates/* conf sed -i 's|/home/gateway/conf/lists/|conf/lists/|g' ./conf/*.yml sed -i 's/https:\/\/rpc.ankr.com\/eth_goerli/http:\/\/127.0.0.1:8545\//g' ./conf/ethereum.yml + sed -i 's/https:\/\/etc.rivet.link/http:\/\/127.0.0.1:8545\//g' ./conf/ethereum-classic.yml - name: Run unit test coverage if: github.event_name == 'pull_request' diff --git a/package.json b/package.json index bf87aebf03..c8f451164a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:scripts": "jest -i --verbose ./test-scripts/*.test.ts" }, "dependencies": { + "@_etcswap/smart-order-router": "^3.15.2", "@balancer-labs/sdk": "^1.1.5", "@bancor/carbon-sdk": "^0.0.93-DEV", "@cosmjs/amino": "^0.32.2", @@ -63,7 +64,7 @@ "@types/uuid": "^8.3.4", "@uniswap/sdk": "3.0.3", "@uniswap/sdk-core": "^5.3.1", - "@uniswap/smart-order-router": "^3.39.0", + "@uniswap/smart-order-router": "^3.46.1", "@uniswap/v3-core": "^1.0.1", "@uniswap/v3-periphery": "^1.1.1", "@uniswap/v3-sdk": "^3.13.1", diff --git a/src/chains/ethereum-classic/ethereum-classic.ts b/src/chains/ethereum-classic/ethereum-classic.ts new file mode 100644 index 0000000000..7acf3e925d --- /dev/null +++ b/src/chains/ethereum-classic/ethereum-classic.ts @@ -0,0 +1,126 @@ +import abi from '../ethereum/ethereum.abi.json'; +import { logger } from '../../services/logger'; +import { Contract, Transaction, Wallet } from 'ethers'; +import { EthereumBase } from '../ethereum/ethereum-base'; +import { getEthereumConfig as getEthereumClassicChainConfig } from '../ethereum/ethereum.config'; +import { Provider } from '@ethersproject/abstract-provider'; +import { Chain as Ethereumish } from '../../services/common-interfaces'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; +import { EVMController } from '../ethereum/evm.controllers'; +import { ETCSwapConfig } from '../../connectors/etcswap/etcswap.config'; + +export class EthereumClassicChain extends EthereumBase implements Ethereumish { + private static _instances: { [name: string]: EthereumClassicChain }; + private _chain: string; + private _gasPrice: number; + private _gasPriceRefreshInterval: number | null; + private _nativeTokenSymbol: string; + public controller; + + private constructor(network: string) { + const config = getEthereumClassicChainConfig('ethereum-classic', network); + super( + 'ethereum-classic', + config.network.chainID, + config.network.nodeURL, + config.network.tokenListSource, + config.network.tokenListType, + config.manualGasPrice, + config.gasLimitTransaction, + ConfigManagerV2.getInstance().get('server.nonceDbPath'), + ConfigManagerV2.getInstance().get('server.transactionDbPath'), + ); + this._chain = config.network.name; + this._nativeTokenSymbol = config.nativeCurrencySymbol; + this._gasPrice = config.manualGasPrice; + this._gasPriceRefreshInterval = + config.network.gasPriceRefreshInterval !== undefined + ? config.network.gasPriceRefreshInterval + : null; + + this.updateGasPrice(); + this.controller = EVMController; + } + + public static getInstance(network: string): EthereumClassicChain { + if (EthereumClassicChain._instances === undefined) { + EthereumClassicChain._instances = {}; + } + if (!(network in EthereumClassicChain._instances)) { + EthereumClassicChain._instances[network] = new EthereumClassicChain( + network, + ); + } + + return EthereumClassicChain._instances[network]; + } + + public static getConnectedInstances(): { + [name: string]: EthereumClassicChain; + } { + return EthereumClassicChain._instances; + } + + /** + * Automatically update the prevailing gas price on the network from the connected RPC node. + */ + async updateGasPrice(): Promise { + if (this._gasPriceRefreshInterval === null) { + return; + } + + const gasPrice: number = (await this.provider.getGasPrice()).toNumber(); + + this._gasPrice = gasPrice * 1e-9; + + setTimeout( + this.updateGasPrice.bind(this), + this._gasPriceRefreshInterval * 1000, + ); + } + + // getters + + public get gasPrice(): number { + return this._gasPrice; + } + + public get nativeTokenSymbol(): string { + return this._nativeTokenSymbol; + } + + public get chain(): string { + return this._chain; + } + + // in place for mocking + public get provider() { + return super.provider; + } + + getContract(tokenAddress: string, signerOrProvider?: Wallet | Provider) { + return new Contract(tokenAddress, abi.ERC20Abi, signerOrProvider); + } + + getSpender(reqSpender: string): string { + let spender: string; + if (reqSpender === 'etcswapLP') { + spender = ETCSwapConfig.config.etcswapV3NftManagerAddress(this._chain); + } else if (reqSpender === 'etcswap') { + spender = ETCSwapConfig.config.etcswapV3SmartOrderRouterAddress( + this._chain, + ); + } else { + spender = reqSpender; + } + return spender; + } + + // cancel transaction + async cancelTx(wallet: Wallet, nonce: number): Promise { + logger.info( + 'Canceling any existing transaction(s) with nonce number ' + nonce + '.', + ); + return super.cancelTxWithGasPrice(wallet, nonce, this._gasPrice * 2); + } +} diff --git a/src/chains/ethereum/ethereum.validators.ts b/src/chains/ethereum/ethereum.validators.ts index 84475096bd..142a340d1a 100644 --- a/src/chains/ethereum/ethereum.validators.ts +++ b/src/chains/ethereum/ethereum.validators.ts @@ -64,6 +64,8 @@ export const validateSpender: Validator = mkValidator( val === 'curve' || val === 'carbonamm' || val === 'balancer' || + val === 'etcswapLP' || + val === 'etcswap' || isAddress(val)) ); diff --git a/src/connectors/connectors.routes.ts b/src/connectors/connectors.routes.ts index f38f29fba3..b3246b31c9 100644 --- a/src/connectors/connectors.routes.ts +++ b/src/connectors/connectors.routes.ts @@ -19,6 +19,7 @@ import { PlentyConfig } from './plenty/plenty.config'; import { OsmosisConfig } from '../chains/osmosis/osmosis.config'; import { CarbonConfig } from './carbon/carbon.config'; import { BalancerConfig } from './balancer/balancer.config'; +import { ETCSwapConfig } from './etcswap/etcswap.config'; export namespace ConnectorsRoutes { export const router = Router(); @@ -140,6 +141,19 @@ export namespace ConnectorsRoutes { chain_type: BalancerConfig.config.chainType, available_networks: BalancerConfig.config.availableNetworks, }, + { + name: 'etcswap', + trading_type: ETCSwapConfig.config.tradingTypes('swap'), + chain_type: ETCSwapConfig.config.chainType, + available_networks: ETCSwapConfig.config.availableNetworks, + }, + { + name: 'etcswapLP', + trading_type: ETCSwapConfig.config.tradingTypes('LP'), + chain_type: ETCSwapConfig.config.chainType, + available_networks: ETCSwapConfig.config.availableNetworks, + additional_spenders: ['etcswap'], + }, ], }); }) diff --git a/src/connectors/etcswap/etcswap.config.ts b/src/connectors/etcswap/etcswap.config.ts new file mode 100644 index 0000000000..2ce3785161 --- /dev/null +++ b/src/connectors/etcswap/etcswap.config.ts @@ -0,0 +1,58 @@ +import { AvailableNetworks } from '../../services/config-manager-types'; +import { ConfigManagerV2 } from '../../services/config-manager-v2'; +export namespace ETCSwapConfig { + export interface NetworkConfig { + allowedSlippage: string; + gasLimitEstimate: number; + ttl: number; + maximumHops: number; + etcswapV3SmartOrderRouterAddress: (network: string) => string; + etcswapV3NftManagerAddress: (network: string) => string; + etcswapV3FactoryAddress: (network: string) => string; + quoterContractAddress: (network: string) => string; + tradingTypes: (type: string) => Array; + chainType: string; + availableNetworks: Array; + useRouter?: boolean; + feeTier?: string; + } + + export const config: NetworkConfig = { + allowedSlippage: ConfigManagerV2.getInstance().get( + `etcswap.allowedSlippage` + ), + gasLimitEstimate: ConfigManagerV2.getInstance().get( + `etcswap.gasLimitEstimate` + ), + ttl: ConfigManagerV2.getInstance().get(`etcswap.ttl`), + maximumHops: ConfigManagerV2.getInstance().get(`etcswap.maximumHops`), + etcswapV3SmartOrderRouterAddress: (network: string) => + ConfigManagerV2.getInstance().get( + `etcswap.contractAddresses.${network}.etcswapV3SmartOrderRouterAddress`, + ), + etcswapV3NftManagerAddress: (network: string) => + ConfigManagerV2.getInstance().get( + `etcswap.contractAddresses.${network}.etcswapV3NftManagerAddress`, + ), + etcswapV3FactoryAddress: (network: string) => + ConfigManagerV2.getInstance().get( + `etcswap.contractAddresses.${network}.etcswapV3FactoryAddress` + ), + quoterContractAddress: (network: string) => + ConfigManagerV2.getInstance().get( + `etcswap.contractAddresses.${network}.etcswapV3QuoterV2ContractAddress` + ), + tradingTypes: (type: string) => { + return type === 'swap' ? ['AMM'] : ['AMM_LP']; + }, + chainType: 'EVM', + availableNetworks: [ + { + chain: 'ethereum-classic', + networks: ['mainnet'] + }, + ], + useRouter: ConfigManagerV2.getInstance().get(`etcswap.useRouter`), + feeTier: ConfigManagerV2.getInstance().get(`etcswap.feeTier`), + }; +} diff --git a/src/connectors/etcswap/etcswap.lp.helper.ts b/src/connectors/etcswap/etcswap.lp.helper.ts new file mode 100644 index 0000000000..ef6f36bc75 --- /dev/null +++ b/src/connectors/etcswap/etcswap.lp.helper.ts @@ -0,0 +1,421 @@ +import { + InitializationError, + SERVICE_UNITIALIZED_ERROR_CODE, + SERVICE_UNITIALIZED_ERROR_MESSAGE, +} from '../../services/error-handler'; +import { Contract, ContractInterface } from '@ethersproject/contracts'; +import { Token, CurrencyAmount, Percent, Price } from '@uniswap/sdk-core'; +import * as v3 from '@uniswap/v3-sdk'; +import { providers, Wallet, Signer, utils } from 'ethers'; +import { percentRegexp } from '../../services/config-manager-v2'; +import { + PoolState, + RawPosition, + AddPosReturn, +} from '../uniswap/uniswap.lp.interfaces'; +import * as math from 'mathjs'; +import { getAddress } from 'ethers/lib/utils'; +import { ETCSwapConfig } from './etcswap.config'; +import { EthereumClassicChain } from '../../chains/ethereum-classic/ethereum-classic'; + +export const FACTORY = "0x2624E907BcC04f93C8f29d7C7149a8700Ceb8cDC"; +export const POOL_INIT = "0x7ea2da342810af3c5a9b47258f990aaac829fe1385a1398feb77d0126a85dbef"; + +export class ETCSwapLPHelper { + protected chain: EthereumClassicChain; + protected chainId; + private _router: string; + private _nftManager: string; + private _ttl: number; + private _routerAbi: ContractInterface; + private _nftAbi: ContractInterface; + private _poolAbi: ContractInterface; + private tokenList: Record = {}; + private _ready: boolean = false; + public abiDecoder: any; + + constructor(chain: string, network: string) { + this.chain = EthereumClassicChain.getInstance(network); + this.chainId = this.getChainId(chain, network); + this._router = + ETCSwapConfig.config.etcswapV3SmartOrderRouterAddress(network); + this._nftManager = + ETCSwapConfig.config.etcswapV3NftManagerAddress(network); + this._ttl = ETCSwapConfig.config.ttl; + this._routerAbi = + require('@uniswap/v3-periphery/artifacts/contracts/SwapRouter.sol/SwapRouter.json').abi; + this._nftAbi = + require('@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json').abi; + this._poolAbi = + require('@uniswap/v3-core/artifacts/contracts/UniswapV3Pool.sol/UniswapV3Pool.json').abi; + this.abiDecoder = require('abi-decoder'); + this.abiDecoder.addABI(this._nftAbi); + this.abiDecoder.addABI(this._routerAbi); + } + + public ready(): boolean { + return this._ready; + } + + public get router(): string { + return this._router; + } + + public get nftManager(): string { + return this._nftManager; + } + + public get ttl(): number { + return parseInt(String(Date.now() / 1000)) + this._ttl; + } + + public get routerAbi(): ContractInterface { + return this._routerAbi; + } + + public get nftAbi(): ContractInterface { + return this._nftAbi; + } + + public get poolAbi(): ContractInterface { + return this._poolAbi; + } + + /** + * Given a token's address, return the connector's native representation of + * the token. + * + * @param address Token address + */ + public getTokenByAddress(address: string): Token { + return this.tokenList[getAddress(address)]; + } + + public async init() { + const chainName = this.chain.toString(); + if (!this.chain.ready()) + throw new InitializationError( + SERVICE_UNITIALIZED_ERROR_MESSAGE(chainName), + SERVICE_UNITIALIZED_ERROR_CODE, + ); + for (const token of this.chain.storedTokenList) { + this.tokenList[token.address] = new Token( + this.chainId, + token.address, + token.decimals, + token.symbol, + token.name, + ); + } + this._ready = true; + } + + public getChainId(_chain: string, network: string): number { + return EthereumClassicChain.getInstance(network).chainId; + } + + getPercentage(rawPercent: number | string): Percent { + const slippage = math.fraction(rawPercent) as math.Fraction; + return new Percent(slippage.n, slippage.d * 100); + } + + getSlippagePercentage(): Percent { + const allowedSlippage = ETCSwapConfig.config.allowedSlippage; + const nd = allowedSlippage.match(percentRegexp); + if (nd) return new Percent(nd[1], nd[2]); + throw new Error( + 'Encountered a malformed percent string in the config for ALLOWED_SLIPPAGE.', + ); + } + + getContract( + contract: string, + signer: providers.StaticJsonRpcProvider | Signer, + ): Contract { + if (contract === 'router') { + return new Contract(this.router, this.routerAbi, signer); + } else { + return new Contract(this.nftManager, this.nftAbi, signer); + } + } + + getPoolContract( + pool: string, + wallet: providers.StaticJsonRpcProvider | Signer, + ): Contract { + return new Contract(pool, this.poolAbi, wallet); + } + + async getPoolState( + poolAddress: string, + fee: v3.FeeAmount, + ): Promise { + const poolContract = this.getPoolContract(poolAddress, this.chain.provider); + const minTick = v3.nearestUsableTick( + v3.TickMath.MIN_TICK, + v3.TICK_SPACINGS[fee], + ); + const maxTick = v3.nearestUsableTick( + v3.TickMath.MAX_TICK, + v3.TICK_SPACINGS[fee], + ); + const poolDataReq = await Promise.allSettled([ + poolContract.liquidity(), + poolContract.slot0(), + poolContract.ticks(minTick), + poolContract.ticks(maxTick), + ]); + + const rejected = poolDataReq.filter( + (r) => r.status === 'rejected', + ) as PromiseRejectedResult[]; + + if (rejected.length > 0) throw new Error('Unable to fetch pool state'); + + const poolData = ( + poolDataReq.filter( + (r) => r.status === 'fulfilled', + ) as PromiseFulfilledResult[] + ).map((r) => r.value); + + return { + liquidity: poolData[0], + sqrtPriceX96: poolData[1][0], + tick: poolData[1][1], + observationIndex: poolData[1][2], + observationCardinality: poolData[1][3], + observationCardinalityNext: poolData[1][4], + feeProtocol: poolData[1][5], + unlocked: poolData[1][6], + fee: fee, + tickProvider: [ + { + index: minTick, + liquidityNet: poolData[2][1], + liquidityGross: poolData[2][0], + }, + { + index: maxTick, + liquidityNet: poolData[3][1], + liquidityGross: poolData[3][0], + }, + ], + }; + } + + async poolPrice( + token0: Token, + token1: Token, + tier: string, + period: number = 1, + interval: number = 1, + ): Promise { + const fetchPriceTime = []; + const prices = []; + const fee = v3.FeeAmount[tier as keyof typeof v3.FeeAmount]; + const poolContract = new Contract( + v3.Pool.getAddress(token0, token1, fee, POOL_INIT, FACTORY), + this.poolAbi, + this.chain.provider, + ); + for ( + let x = Math.ceil(period / interval) * interval; + x >= 0; + x -= interval + ) { + fetchPriceTime.push(x); + } + try { + const response = await poolContract.observe(fetchPriceTime); + for (let twap = 0; twap < response.tickCumulatives.length - 1; twap++) { + prices.push( + v3 + .tickToPrice( + token0, + token1, + Math.ceil( + response.tickCumulatives[twap + 1].sub( + response.tickCumulatives[twap].toNumber(), + ) / interval, + ), + ) + .toFixed(8), + ); + } + } catch (e) { + return ['0']; + } + return prices; + } + + async getRawPosition(wallet: Wallet, tokenId: number): Promise { + const contract = this.getContract('nft', wallet); + const requests = [contract.positions(tokenId)]; + const positionInfoReq = await Promise.allSettled(requests); + const rejected = positionInfoReq.filter( + (r) => r.status === 'rejected', + ) as PromiseRejectedResult[]; + if (rejected.length > 0) throw new Error('Unable to fetch position'); + const positionInfo = ( + positionInfoReq.filter( + (r) => r.status === 'fulfilled', + ) as PromiseFulfilledResult[] + ).map((r) => r.value); + return positionInfo[0]; + } + + getReduceLiquidityData( + percent: number, + tokenId: number, + token0: Token, + token1: Token, + wallet: Wallet, + ): v3.RemoveLiquidityOptions { + // }; // recipient: string; // expectedCurrencyOwed1: CurrencyAmount; // expectedCurrencyOwed0: CurrencyAmount; // collectOptions: { // burnToken: boolean; // deadline: number; // slippageTolerance: Percent; // liquidityPercentage: Percent; // tokenId: number; // { + return { + tokenId: tokenId, + liquidityPercentage: this.getPercentage(percent), + slippageTolerance: this.getSlippagePercentage(), + deadline: this.ttl, + burnToken: false, + collectOptions: { + expectedCurrencyOwed0: CurrencyAmount.fromRawAmount(token0, '0'), + expectedCurrencyOwed1: CurrencyAmount.fromRawAmount(token1, '0'), + recipient: <`0x${string}`>wallet.address, + }, + }; + } + + async addPositionHelper( + wallet: Wallet, + token0: Token, + token1: Token, + amount0: string, + amount1: string, + fee: v3.FeeAmount, + lowerPrice: number, + upperPrice: number, + tokenId: number = 0, + ): Promise { + if (token1.sortsBefore(token0)) { + [token0, token1] = [token1, token0]; + [amount0, amount1] = [amount1, amount0]; + [lowerPrice, upperPrice] = [1 / upperPrice, 1 / lowerPrice]; + } + const lowerPriceInFraction = math.fraction(lowerPrice) as math.Fraction; + const upperPriceInFraction = math.fraction(upperPrice) as math.Fraction; + const poolData = await this.getPoolState( + v3.Pool.getAddress(token0, token1, fee, POOL_INIT, FACTORY), + fee, + ); + const pool = new v3.Pool( + token0, + token1, + poolData.fee, + poolData.sqrtPriceX96.toString(), + poolData.liquidity.toString(), + poolData.tick, + ); + + const addLiquidityOptions = { + recipient: wallet.address, + tokenId: tokenId ? tokenId : 0, + }; + + const swapOptions = { + recipient: wallet.address, + slippageTolerance: this.getSlippagePercentage(), + deadline: this.ttl, + }; + + const tickLower = v3.nearestUsableTick( + v3.priceToClosestTick( + new Price( + token0, + token1, + utils + .parseUnits(lowerPriceInFraction.d.toString(), token0.decimals) + .toString(), + utils + .parseUnits(lowerPriceInFraction.n.toString(), token1.decimals) + .toString(), + ), + ), + v3.TICK_SPACINGS[fee], + ); + + const tickUpper = v3.nearestUsableTick( + v3.priceToClosestTick( + new Price( + token0, + token1, + utils + .parseUnits(upperPriceInFraction.d.toString(), token0.decimals) + .toString(), + utils + .parseUnits(upperPriceInFraction.n.toString(), token1.decimals) + .toString(), + ), + ), + v3.TICK_SPACINGS[fee], + ); + + const position = v3.Position.fromAmounts({ + pool: pool, + tickLower: + tickLower === tickUpper ? tickLower - v3.TICK_SPACINGS[fee] : tickLower, + tickUpper: tickUpper, + amount0: utils.parseUnits(amount0, token0.decimals).toString(), + amount1: utils.parseUnits(amount1, token1.decimals).toString(), + useFullPrecision: true, + }); + + const methodParameters = v3.NonfungiblePositionManager.addCallParameters( + position, + { ...swapOptions, ...addLiquidityOptions }, + ); + return { ...methodParameters, swapRequired: false }; + } + + async reducePositionHelper( + wallet: Wallet, + tokenId: number, + decreasePercent: number, + ): Promise { + // Reduce position and burn + const positionData = await this.getRawPosition(wallet, tokenId); + const token0 = this.getTokenByAddress(positionData.token0); + const token1 = this.getTokenByAddress(positionData.token1); + const fee = positionData.fee; + if (!token0 || !token1) { + throw new Error( + `One of the tokens in this position isn't recognized. $token0: ${token0}, $token1: ${token1}`, + ); + } + const poolAddress = v3.Pool.getAddress(token0, token1, fee, POOL_INIT, FACTORY); + const poolData = await this.getPoolState(poolAddress, fee); + const position = new v3.Position({ + pool: new v3.Pool( + token0, + token1, + poolData.fee, + poolData.sqrtPriceX96.toString(), + poolData.liquidity.toString(), + poolData.tick, + ), + tickLower: positionData.tickLower, + tickUpper: positionData.tickUpper, + liquidity: positionData.liquidity, + }); + return v3.NonfungiblePositionManager.removeCallParameters( + position, + this.getReduceLiquidityData( + decreasePercent, + tokenId, + token0, + token1, + wallet, + ), + ); + } +} diff --git a/src/connectors/etcswap/etcswap.lp.ts b/src/connectors/etcswap/etcswap.lp.ts new file mode 100644 index 0000000000..6572ceb53d --- /dev/null +++ b/src/connectors/etcswap/etcswap.lp.ts @@ -0,0 +1,261 @@ +import { logger } from '../../services/logger'; +import { PositionInfo, UniswapLPish } from '../../services/common-interfaces'; +import * as v3 from '@uniswap/v3-sdk'; +import { Token } from '@uniswap/sdk-core'; +import { + BigNumber, + Transaction, + Wallet, + utils, + constants, + providers, +} from 'ethers'; +import { ETCSwapLPHelper, FACTORY, POOL_INIT } from './etcswap.lp.helper'; +import { AddPosReturn } from '../uniswap/uniswap.lp.interfaces'; +import { ETCSwapConfig } from './etcswap.config'; + +const MaxUint128 = BigNumber.from(2).pow(128).sub(1); + +export type Overrides = { + gasLimit: BigNumber; + gasPrice?: BigNumber; + value?: BigNumber; + nonce?: BigNumber; + maxFeePerGas?: BigNumber; + maxPriorityFeePerGas?: BigNumber; +}; + +export class ETCSwapLP extends ETCSwapLPHelper implements UniswapLPish { + private static _instances: { [name: string]: ETCSwapLP }; + private _gasLimitEstimate: number; + + private constructor(chain: string, network: string) { + super(chain, network); + this._gasLimitEstimate = ETCSwapConfig.config.gasLimitEstimate; + } + + public static getInstance(chain: string, network: string): ETCSwapLP { + if (ETCSwapLP._instances === undefined) { + ETCSwapLP._instances = {}; + } + if (!(chain + network in ETCSwapLP._instances)) { + ETCSwapLP._instances[chain + network] = new ETCSwapLP( + chain, + network, + ); + } + + return ETCSwapLP._instances[chain + network]; + } + + /** + * Default gas limit for swap transactions. + */ + public get gasLimitEstimate(): number { + return this._gasLimitEstimate; + } + + async getPosition(tokenId: number): Promise { + const contract = this.getContract('nft', this.chain.provider); + const requests = [ + contract.positions(tokenId), + this.collectFees(this.chain.provider, tokenId), // static call to calculate earned fees + ]; + const positionInfoReq = await Promise.allSettled(requests); + const rejected = positionInfoReq.filter( + (r) => r.status === 'rejected', + ) as PromiseRejectedResult[]; + if (rejected.length > 0) + throw new Error(`Unable to fetch position with id ${tokenId}`); + const positionInfo = ( + positionInfoReq.filter( + (r) => r.status === 'fulfilled', + ) as PromiseFulfilledResult[] + ).map((r) => r.value); + const position = positionInfo[0]; + const feeInfo = positionInfo[1]; + const token0 = this.getTokenByAddress(position.token0); + const token1 = this.getTokenByAddress(position.token1); + if (!token0 || !token1) { + throw new Error(`One of the tokens in this position isn't recognized.`); + } + const fee = position.fee; + const poolAddress = v3.Pool.getAddress(token0, token1, fee, POOL_INIT, FACTORY); + const poolData = await this.getPoolState(poolAddress, fee); + const positionInst = new v3.Position({ + pool: new v3.Pool( + token0, + token1, + poolData.fee, + poolData.sqrtPriceX96.toString(), + poolData.liquidity.toString(), + poolData.tick, + ), + tickLower: position.tickLower, + tickUpper: position.tickUpper, + liquidity: position.liquidity, + }); + return { + token0: token0.symbol, + token1: token1.symbol, + fee: v3.FeeAmount[position.fee], + lowerPrice: positionInst.token0PriceLower.toFixed(8), + upperPrice: positionInst.token0PriceUpper.toFixed(8), + amount0: positionInst.amount0.toFixed(), + amount1: positionInst.amount1.toFixed(), + unclaimedToken0: utils.formatUnits( + feeInfo.amount0.toString(), + token0.decimals, + ), + unclaimedToken1: utils.formatUnits( + feeInfo.amount1.toString(), + token1.decimals, + ), + }; + } + + async addPosition( + wallet: Wallet, + token0: Token, + token1: Token, + amount0: string, + amount1: string, + fee: string, + lowerPrice: number, + upperPrice: number, + tokenId: number = 0, + gasLimit: number, + gasPrice: number, + nonce?: number, + maxFeePerGas?: BigNumber, + maxPriorityFeePerGas?: BigNumber, + ): Promise { + const convertedFee = v3.FeeAmount[fee as keyof typeof v3.FeeAmount]; + const addLiquidityResponse: AddPosReturn = await this.addPositionHelper( + wallet, + token0, + token1, + amount0, + amount1, + convertedFee, + lowerPrice, + upperPrice, + tokenId, + ); + + if (nonce === undefined) { + nonce = await this.chain.nonceManager.getNextNonce(wallet.address); + } + + const tx = await wallet.sendTransaction({ + data: addLiquidityResponse.calldata, + to: addLiquidityResponse.swapRequired ? this.router : this.nftManager, + ...this.generateOverrides( + gasLimit, + gasPrice, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + addLiquidityResponse.value, + ), + }); + logger.info(`Pancakeswap V3 Add position Tx Hash: ${tx.hash}`); + return tx; + } + + async reducePosition( + wallet: Wallet, + tokenId: number, + decreasePercent: number = 100, + gasLimit: number, + gasPrice: number, + nonce?: number, + maxFeePerGas?: BigNumber, + maxPriorityFeePerGas?: BigNumber, + ): Promise { + // Reduce position and burn + const contract = this.getContract('nft', wallet); + const { calldata, value } = await this.reducePositionHelper( + wallet, + tokenId, + decreasePercent, + ); + + if (nonce === undefined) { + nonce = await this.chain.nonceManager.getNextNonce(wallet.address); + } + + const tx = await contract.multicall( + [calldata], + this.generateOverrides( + gasLimit, + gasPrice, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + value, + ), + ); + logger.info(`Pancakeswap V3 Remove position Tx Hash: ${tx.hash}`); + return tx; + } + + async collectFees( + wallet: Wallet | providers.StaticJsonRpcProvider, + tokenId: number, + gasLimit: number = this.gasLimitEstimate, + gasPrice: number = 0, + nonce?: number, + maxFeePerGas?: BigNumber, + maxPriorityFeePerGas?: BigNumber, + ): Promise { + const contract = this.getContract('nft', wallet); + const collectData = { + tokenId: tokenId, + recipient: constants.AddressZero, + amount0Max: MaxUint128, + amount1Max: MaxUint128, + }; + + if (wallet instanceof providers.StaticJsonRpcProvider) { + return await contract.callStatic.collect(collectData); + } else { + collectData.recipient = wallet.address; + if (nonce === undefined) { + nonce = await this.chain.nonceManager.getNextNonce(wallet.address); + } + return await contract.collect( + collectData, + this.generateOverrides( + gasLimit, + gasPrice, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + ), + ); + } + } + + generateOverrides( + gasLimit: number, + gasPrice: number, + nonce?: number, + maxFeePerGas?: BigNumber, + maxPriorityFeePerGas?: BigNumber, + value?: string, + ): Overrides { + const overrides: Overrides = { + gasLimit: BigNumber.from(String(gasLimit.toFixed(0))), + }; + if (maxFeePerGas && maxPriorityFeePerGas) { + overrides.maxFeePerGas = maxFeePerGas; + overrides.maxPriorityFeePerGas = maxPriorityFeePerGas; + } else { + overrides.gasPrice = BigNumber.from(String((gasPrice * 1e9).toFixed(0))); + } + if (nonce) overrides.nonce = BigNumber.from(String(nonce)); + if (value) overrides.value = BigNumber.from(value); + return overrides; + } +} diff --git a/src/connectors/etcswap/etcswap.ts b/src/connectors/etcswap/etcswap.ts new file mode 100644 index 0000000000..9f13ae8e33 --- /dev/null +++ b/src/connectors/etcswap/etcswap.ts @@ -0,0 +1,520 @@ +import { UniswapishPriceError } from '../../services/error-handler'; +import { isFractionString } from '../../services/validators'; +import { + ContractInterface, + ContractTransaction, +} from '@ethersproject/contracts'; +import { AlphaRouter } from '@_etcswap/smart-order-router'; +import routerAbi from '../uniswap/uniswap_v2_router_abi.json'; +import { + FeeAmount, + MethodParameters, + Pool, + SwapQuoter, + Trade as EtcswapV3Trade, + Route, +} from '@_etcswap/smart-order-router/node_modules/@uniswap/v3-sdk'; +import { + SwapRouter, + Trade, +} from '@_etcswap/smart-order-router/node_modules/@uniswap/router-sdk'; +import { abi as IEtcswapV3PoolABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Pool.sol/IUniswapV3Pool.json'; +import { abi as IEtcswapV3FactoryABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/IUniswapV3Factory.sol/IUniswapV3Factory.json'; +import { + Token, + Currency, + CurrencyAmount, + TradeType, + Percent, +} from '@_etcswap/smart-order-router/node_modules/@uniswap/sdk-core'; +import { + BigNumber, + Transaction, + Wallet, + Contract, + utils, + constants, +} from 'ethers'; +import { logger } from '../../services/logger'; +import { percentRegexp } from '../../services/config-manager-v2'; +import { + ExpectedTrade, + Uniswapish, + UniswapishTrade, +} from '../../services/common-interfaces'; +import { getAddress } from 'ethers/lib/utils'; +import { EthereumClassicChain } from '../../chains/ethereum-classic/ethereum-classic'; +import { ETCSwapConfig } from './etcswap.config'; + +export class ETCSwap implements Uniswapish { + private static _instances: { [name: string]: ETCSwap }; + private chain: EthereumClassicChain; + private _alphaRouter: AlphaRouter | null; + private _router: string; + private _routerAbi: ContractInterface; + private _gasLimitEstimate: number; + private _ttl: number; + private _maximumHops: number; + private chainId; + private tokenList: Record = {}; + private _ready: boolean = false; + private readonly _useRouter: boolean; + private readonly _feeTier: FeeAmount; + private readonly _quoterContractAddress: string; + private readonly _factoryAddress: string; + + private constructor(_chain: string, network: string) { + const config = ETCSwapConfig.config; + this.chain = EthereumClassicChain.getInstance(network); + + this.chainId = this.chain.chainId; + this._ttl = ETCSwapConfig.config.ttl; + this._maximumHops = ETCSwapConfig.config.maximumHops; + + this._alphaRouter = new AlphaRouter({ + chainId: this.chainId, + provider: this.chain.provider, + }); + this._routerAbi = routerAbi.abi; + this._gasLimitEstimate = ETCSwapConfig.config.gasLimitEstimate; + this._router = config.etcswapV3SmartOrderRouterAddress(network); + + if (config.useRouter === false && config.feeTier == null) { + throw new Error('Must specify fee tier if not using router'); + } + if (config.useRouter === false && config.quoterContractAddress == null) { + throw new Error( + 'Must specify quoter contract address if not using router', + ); + } + this._useRouter = config.useRouter ?? true; + this._feeTier = config.feeTier + ? FeeAmount[config.feeTier as keyof typeof FeeAmount] + : FeeAmount.MEDIUM; + this._quoterContractAddress = config.quoterContractAddress(network); + this._factoryAddress = config.etcswapV3FactoryAddress(network); + } + + public static getInstance(chain: string, network: string): ETCSwap { + if (ETCSwap._instances === undefined) { + ETCSwap._instances = {}; + } + if (!(chain + network in ETCSwap._instances)) { + ETCSwap._instances[chain + network] = new ETCSwap(chain, network); + } + + return ETCSwap._instances[chain + network]; + } + + /** + * Given a token's address, return the connector's native representation of + * the token. + * + * @param address Token address + */ + public getTokenByAddress(address: string): Token { + return this.tokenList[getAddress(address)]; + } + + public async init() { + if (!this.chain.ready()) { + await this.chain.init(); + } + for (const token of this.chain.storedTokenList) { + this.tokenList[token.address] = new Token( + this.chainId, + token.address, + token.decimals, + token.symbol, + token.name, + ); + } + this._ready = true; + } + + public ready(): boolean { + return this._ready; + } + + /** + * Router address. + */ + public get router(): string { + return this._router; + } + + /** + * AlphaRouter instance. + */ + public get alphaRouter(): AlphaRouter { + if (this._alphaRouter === null) { + throw new Error('AlphaRouter is not initialized'); + } + return this._alphaRouter; + } + + /** + * Router smart contract ABI. + */ + public get routerAbi(): ContractInterface { + return this._routerAbi; + } + + /** + * Default gas limit used to estimate gasCost for swap transactions. + */ + public get gasLimitEstimate(): number { + return this._gasLimitEstimate; + } + + /** + * Default time-to-live for swap transactions, in seconds. + */ + public get ttl(): number { + return this._ttl; + } + + /** + * Default maximum number of hops for to go through for a swap transactions. + */ + public get maximumHops(): number { + return this._maximumHops; + } + + /** + * Gets the allowed slippage percent from the optional parameter or the value + * in the configuration. + * + * @param allowedSlippageStr (Optional) should be of the form '1/10'. + */ + public getAllowedSlippage(allowedSlippageStr?: string): Percent { + if (allowedSlippageStr != null && isFractionString(allowedSlippageStr)) { + const fractionSplit = allowedSlippageStr.split('/'); + return new Percent(fractionSplit[0], fractionSplit[1]); + } + + const allowedSlippage = ETCSwapConfig.config.allowedSlippage; + const nd = allowedSlippage.match(percentRegexp); + if (nd) return new Percent(nd[1], nd[2]); + throw new Error( + 'Encountered a malformed percent string in the config for ALLOWED_SLIPPAGE.', + ); + } + + /** + * Given the amount of `baseToken` to put into a transaction, calculate the + * amount of `quoteToken` that can be expected from the transaction. + * + * This is typically used for calculating token sell prices. + * + * @param baseToken Token input for the transaction + * @param quoteToken Output from the transaction + * @param amount Amount of `baseToken` to put into the transaction + */ + async estimateSellTrade( + baseToken: Token, + quoteToken: Token, + amount: BigNumber, + allowedSlippage?: string, + poolId?: string, + ): Promise { + const nativeTokenAmount: CurrencyAmount = + CurrencyAmount.fromRawAmount(baseToken, amount.toString()); + + logger.info( + `Fetching trade data for ${baseToken.address}-${quoteToken.address}.`, + ); + + if (this._useRouter) { + if (this._alphaRouter === null) { + throw new Error('AlphaRouter is not initialized'); + } + const route = await this._alphaRouter.route( + nativeTokenAmount, + quoteToken, + TradeType.EXACT_INPUT, + undefined, + { + maxSwapsPerPath: this.maximumHops, + }, + ); + + if (!route) { + throw new UniswapishPriceError( + `priceSwapIn: no trade pair found for ${baseToken.address} to ${quoteToken.address}.`, + ); + } + logger.info( + `Best trade for ${baseToken.address}-${quoteToken.address}: ` + + `${route.trade.executionPrice.toFixed(6)}` + + `${baseToken.symbol}.`, + ); + const expectedAmount = route.trade.maximumAmountIn( + this.getAllowedSlippage(allowedSlippage), + ); + return { + trade: route.trade as unknown as UniswapishTrade, + expectedAmount, + }; + } else { + const pool = await this.getPool( + baseToken, + quoteToken, + this._feeTier, + poolId, + ); + if (!pool) { + throw new UniswapishPriceError( + `priceSwapIn: no trade pair found for ${baseToken.address} to ${quoteToken.address}.`, + ); + } + const swapRoute = new Route([pool], baseToken, quoteToken); + const quotedAmount = await this.getQuote( + swapRoute, + quoteToken, + nativeTokenAmount, + TradeType.EXACT_INPUT, + ); + const trade = EtcswapV3Trade.createUncheckedTrade({ + route: swapRoute, + inputAmount: nativeTokenAmount, + outputAmount: quotedAmount, + tradeType: TradeType.EXACT_INPUT, + }); + logger.info( + `Best trade for ${baseToken.address}-${quoteToken.address}: ` + + `${trade.executionPrice.toFixed(6)}` + + `${baseToken.symbol}.`, + ); + const expectedAmount = trade.minimumAmountOut( + this.getAllowedSlippage(allowedSlippage), + ); + return { trade: trade as unknown as UniswapishTrade, expectedAmount }; + } + } + + /** + * Given the amount of `baseToken` desired to acquire from a transaction, + * calculate the amount of `quoteToken` needed for the transaction. + * + * This is typically used for calculating token buy prices. + * + * @param quoteToken Token input for the transaction + * @param baseToken Token output from the transaction + * @param amount Amount of `baseToken` desired from the transaction + */ + async estimateBuyTrade( + quoteToken: Token, + baseToken: Token, + amount: BigNumber, + allowedSlippage?: string, + poolId?: string, + ): Promise { + const nativeTokenAmount: CurrencyAmount = + CurrencyAmount.fromRawAmount(baseToken, amount.toString()); + logger.info( + `Fetching pair data for ${quoteToken.address}-${baseToken.address}.`, + ); + + if (this._useRouter) { + if (this._alphaRouter === null) { + throw new Error('AlphaRouter is not initialized'); + } + const route = await this._alphaRouter.route( + nativeTokenAmount, + quoteToken, + TradeType.EXACT_OUTPUT, + undefined, + { + maxSwapsPerPath: this.maximumHops, + }, + ); + if (!route) { + throw new UniswapishPriceError( + `priceSwapOut: no trade pair found for ${quoteToken.address} to ${baseToken.address}.`, + ); + } + logger.info( + `Best trade for ${quoteToken.address}-${baseToken.address}: ` + + `${route.trade.executionPrice.invert().toFixed(6)} ` + + `${baseToken.symbol}.`, + ); + + const expectedAmount = route.trade.maximumAmountIn( + this.getAllowedSlippage(allowedSlippage), + ); + return { + trade: route.trade as unknown as UniswapishTrade, + expectedAmount, + }; + } else { + const pool = await this.getPool( + quoteToken, + baseToken, + this._feeTier, + poolId, + ); + if (!pool) { + throw new UniswapishPriceError( + `priceSwapOut: no trade pair found for ${quoteToken.address} to ${baseToken.address}.`, + ); + } + const swapRoute = new Route([pool], quoteToken, baseToken); + const quotedAmount = await this.getQuote( + swapRoute, + quoteToken, + nativeTokenAmount, + TradeType.EXACT_OUTPUT, + ); + const trade = EtcswapV3Trade.createUncheckedTrade({ + route: swapRoute, + inputAmount: quotedAmount, + outputAmount: nativeTokenAmount, + tradeType: TradeType.EXACT_OUTPUT, + }); + logger.info( + `Best trade for ${baseToken.address}-${quoteToken.address}: ` + + `${trade.executionPrice.invert().toFixed(6)}` + + `${baseToken.symbol}.`, + ); + const expectedAmount = trade.maximumAmountIn( + this.getAllowedSlippage(allowedSlippage), + ); + return { trade: trade as unknown as UniswapishTrade, expectedAmount }; + } + } + + /** + * Given a wallet and a ETCSwap trade, try to execute it on blockchain. + * + * @param wallet Wallet + * @param trade Expected trade + * @param gasPrice Base gas price, for pre-EIP1559 transactions + * @param etcswapRouter Router smart contract address + * @param ttl How long the swap is valid before expiry, in seconds + * @param _abi Router contract ABI + * @param gasLimit Gas limit + * @param nonce (Optional) EVM transaction nonce + * @param maxFeePerGas (Optional) Maximum total fee per gas you want to pay + * @param maxPriorityFeePerGas (Optional) Maximum tip per gas you want to pay + */ + async executeTrade( + wallet: Wallet, + trade: UniswapishTrade, + gasPrice: number, + etcswapRouter: string, + ttl: number, + _abi: ContractInterface, + gasLimit: number, + nonce?: number, + maxFeePerGas?: BigNumber, + maxPriorityFeePerGas?: BigNumber, + allowedSlippage?: string, + ): Promise { + const methodParameters: MethodParameters = SwapRouter.swapCallParameters( + trade as unknown as Trade, + { + deadlineOrPreviousBlockhash: Math.floor(Date.now() / 1000 + ttl), + recipient: wallet.address, + slippageTolerance: this.getAllowedSlippage(allowedSlippage), + }, + ); + + return this.chain.nonceManager.provideNonce( + nonce, + wallet.address, + async (nextNonce) => { + let tx: ContractTransaction; + if (maxFeePerGas !== undefined || maxPriorityFeePerGas !== undefined) { + tx = await wallet.sendTransaction({ + data: methodParameters.calldata, + to: etcswapRouter, + gasLimit: BigNumber.from(String(gasLimit.toFixed(0))), + value: BigNumber.from(methodParameters.value), + nonce: BigNumber.from(String(nextNonce)), + maxFeePerGas, + maxPriorityFeePerGas, + }); + } else { + tx = await wallet.sendTransaction({ + data: methodParameters.calldata, + to: etcswapRouter, + gasPrice: BigNumber.from(String((gasPrice * 1e9).toFixed(0))), + gasLimit: BigNumber.from(String(gasLimit.toFixed(0))), + value: BigNumber.from(methodParameters.value), + nonce: BigNumber.from(String(nextNonce)), + }); + } + logger.info(JSON.stringify(tx)); + return tx; + }, + ); + } + + private async getPool( + tokenA: Token, + tokenB: Token, + feeTier: FeeAmount, + poolId?: string, + ): Promise { + const etcswapFactory = new Contract( + this._factoryAddress, + IEtcswapV3FactoryABI, + this.chain.provider, + ); + // Use ETCSwap V3 factory to get pool address instead of `Pool.getAddress` to check if pool exists. + const poolAddress = + poolId || + (await etcswapFactory.getPool(tokenA.address, tokenB.address, feeTier)); + if ( + poolAddress === constants.AddressZero || + poolAddress === undefined || + poolAddress === '' + ) { + return null; + } + const poolContract = new Contract( + poolAddress, + IEtcswapV3PoolABI, + this.chain.provider, + ); + + const [liquidity, slot0, fee] = await Promise.all([ + poolContract.liquidity(), + poolContract.slot0(), + poolContract.fee(), + ]); + const [sqrtPriceX96, tick] = slot0; + + const pool = new Pool(tokenA, tokenB, fee, sqrtPriceX96, liquidity, tick); + + return pool; + } + + private async getQuote( + swapRoute: Route, + quoteToken: Token, + amount: CurrencyAmount, + tradeType: TradeType, + ) { + const { calldata } = await SwapQuoter.quoteCallParameters( + swapRoute, + amount, + tradeType, + { useQuoterV2: true }, + ); + const quoteCallReturnData = await this.chain.provider.call({ + to: this._quoterContractAddress, + data: calldata, + }); + const quoteTokenRawAmount = utils.defaultAbiCoder.decode( + ['uint256'], + quoteCallReturnData, + ); + const qouteTokenAmount = CurrencyAmount.fromRawAmount( + quoteToken, + quoteTokenRawAmount.toString(), + ); + return qouteTokenAmount; + } +} diff --git a/src/connectors/uniswap/uniswap.ts b/src/connectors/uniswap/uniswap.ts index 58382bcd44..44643a46cd 100644 --- a/src/connectors/uniswap/uniswap.ts +++ b/src/connectors/uniswap/uniswap.ts @@ -39,7 +39,7 @@ import { Ethereum } from '../../chains/ethereum/ethereum'; import { Avalanche } from '../../chains/avalanche/avalanche'; import { Polygon } from '../../chains/polygon/polygon'; import { BinanceSmartChain } from "../../chains/binance-smart-chain/binance-smart-chain"; -import { ExpectedTrade, Uniswapish } from '../../services/common-interfaces'; +import { ExpectedTrade, Uniswapish, UniswapishTrade } from '../../services/common-interfaces'; import { getAddress } from 'ethers/lib/utils'; import { Celo } from '../../chains/celo/celo'; @@ -261,7 +261,7 @@ export class Uniswap implements Uniswapish { const expectedAmount = route.trade.minimumAmountOut( this.getAllowedSlippage(allowedSlippage) ); - return { trade: route.trade, expectedAmount }; + return { trade: route.trade as unknown as UniswapishTrade, expectedAmount }; } else { const pool = await this.getPool(baseToken, quoteToken, this._feeTier, poolId); if (!pool) { @@ -344,7 +344,7 @@ export class Uniswap implements Uniswapish { const expectedAmount = route.trade.maximumAmountIn( this.getAllowedSlippage(allowedSlippage) ); - return { trade: route.trade, expectedAmount }; + return { trade: route.trade as unknown as UniswapishTrade, expectedAmount }; } else { const pool = await this.getPool(quoteToken, baseToken, this._feeTier, poolId); if (!pool) { diff --git a/src/network/network.controllers.ts b/src/network/network.controllers.ts index c4887fa531..a18ea4e64e 100644 --- a/src/network/network.controllers.ts +++ b/src/network/network.controllers.ts @@ -21,6 +21,8 @@ import { } from '../services/connection-manager'; import { Osmosis } from '../chains/osmosis/osmosis'; +import { EthereumClassicChain } from '../chains/ethereum-classic/ethereum-classic'; + export async function getStatus( req: StatusRequest, ): Promise { @@ -108,6 +110,11 @@ export async function getStatus( connections = connections.concat( osmosisConnections ? Object.values(osmosisConnections) : [], ); + + const etcConnections = EthereumClassicChain.getConnectedInstances(); + connections = connections.concat( + etcConnections ? Object.values(etcConnections) : [] + ); } for (const connection of connections) { diff --git a/src/services/connection-manager.ts b/src/services/connection-manager.ts index fc9799fcec..c5b62533ef 100644 --- a/src/services/connection-manager.ts +++ b/src/services/connection-manager.ts @@ -35,6 +35,9 @@ import { Curve } from '../connectors/curve/curve'; import { PancakeswapLP } from '../connectors/pancakeswap/pancakeswap.lp'; import { Carbonamm } from '../connectors/carbon/carbonAMM'; import { Balancer } from '../connectors/balancer/balancer'; +import { ETCSwapLP } from '../connectors/etcswap/etcswap.lp'; +import { EthereumClassicChain } from '../chains/ethereum-classic/ethereum-classic'; +import { ETCSwap } from '../connectors/etcswap/etcswap'; export type ChainUnion = | Algorand @@ -119,6 +122,8 @@ export async function getChainInstance( connection = Tezos.getInstance(network); } else if (chain === 'telos') { connection = Telos.getInstance(network); + } else if (chain === 'ethereum-classic') { + connection = EthereumClassicChain.getInstance(network); } else { connection = undefined; } @@ -184,6 +189,10 @@ export async function getConnector( connectorInstance = Tinyman.getInstance(network); } else if (connector === 'plenty') { connectorInstance = Plenty.getInstance(network); + } else if (chain === 'ethereum-classic' && connector === 'etcswap') { + connectorInstance = ETCSwap.getInstance(chain, network); + } else if (chain === 'ethereum-classic' && connector === 'etcswapLP') { + connectorInstance = ETCSwapLP.getInstance(chain, network); } else { throw new Error('unsupported chain or connector'); } diff --git a/src/services/schema/etcswap-schema.json b/src/services/schema/etcswap-schema.json new file mode 100644 index 0000000000..5ae1f35a96 --- /dev/null +++ b/src/services/schema/etcswap-schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "allowedSlippage": { "type": "string" }, + "gasLimitEstimate": { "type": "integer" }, + "ttl": { "type": "integer" }, + "maximumHops": { "type": "integer" }, + "useRouter": { "type": "boolean" }, + "feeTier": { + "enum": ["LOWEST", "LOW", "MEDIUM", "HIGH"] + }, + "contractAddresses": { + "type": "object", + "patternProperties": { + "^\\w+$": { + "type": "object", + "properties": { + "routerAddress": { "type": "string" }, + "etcswapV3SmartOrderRouterAddress": { "type": "string" }, + "etcswapV3NftManagerAddress": { "type": "string" }, + "etcswapV3QuoterV2ContractAddress": { "type": "string" }, + "etcswapV3FactoryAddress": { + "type": "string" + } + }, + "required": [ + "routerAddress", + "etcswapV3SmartOrderRouterAddress", + "etcswapV3NftManagerAddress", + "etcswapV3FactoryAddress" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": [ + "allowedSlippage", + "gasLimitEstimate", + "ttl", + "maximumHops", + "contractAddresses" + ] +} diff --git a/src/services/wallet/wallet.validators.ts b/src/services/wallet/wallet.validators.ts index 3cdddde7ee..5609b50d18 100644 --- a/src/services/wallet/wallet.validators.ts +++ b/src/services/wallet/wallet.validators.ts @@ -122,6 +122,11 @@ export const validatePrivateKey: Validator = mkSelectingValidator( invalidEthPrivateKeyError, (val) => typeof val === 'string' && isEthPrivateKey(val), ), + 'ethereum-classic': mkValidator( + 'privateKey', + invalidEthPrivateKeyError, + (val) => typeof val === 'string' && isEthPrivateKey(val), + ), }, ); @@ -155,7 +160,8 @@ export const validateChain: Validator = mkValidator( val === 'osmosis' || val === 'binance-smart-chain' || val === 'tezos' || - val === 'telos'), + val === 'telos' || + val === 'ethereum-classic'), ); export const validateNetwork: Validator = mkValidator( diff --git a/src/templates/etcswap.yml b/src/templates/etcswap.yml new file mode 100644 index 0000000000..e0794de6f1 --- /dev/null +++ b/src/templates/etcswap.yml @@ -0,0 +1,32 @@ +# how much the execution price is allowed to move unfavorably from the trade +# execution price. It uses a rational number for precision. +allowedSlippage: '1/100' + +# the maximum gas used to estimate gasCost for a etcswap trade. +gasLimitEstimate: 150688 + +# how long a trade is valid in seconds. After time passes etcswap will not +# perform the trade, but the gas will still be spent. +ttl: 86400 + +# For each swap, the maximum number of hops to consider. +# Note: More hops will increase latency of the algorithm. +maximumHops: 4 + +# Use etcswap Router or Quoter to quote prices. +# true - use Smart Order Router +# false - use QuoterV2 Contract +useRouter: true + +# Fee tier to use for the etcswap Quoter. +# Required if `useRouter` is false. +# Available options: 'LOWEST', 'LOW', 'MEDIUM', 'HIGH'. +feeTier: 'MEDIUM' + +contractAddresses: + mainnet: + routerAddress: '0xEd88EDD995b00956097bF90d39C9341BBde324d1' + etcswapV3SmartOrderRouterAddress: '0xEd88EDD995b00956097bF90d39C9341BBde324d1' + etcswapV3NftManagerAddress: '0x3CEDe6562D6626A04d7502CC35720901999AB699' + etcswapV3QuoterV2ContractAddress: '0x4d8c163400CB87Cbe1bae76dBf36A09FED85d39B' + etcswapV3FactoryAddress: '0x2624E907BcC04f93C8f29d7C7149a8700Ceb8cDC' diff --git a/src/templates/ethereum-classic.yml b/src/templates/ethereum-classic.yml new file mode 100644 index 0000000000..1f4f299b24 --- /dev/null +++ b/src/templates/ethereum-classic.yml @@ -0,0 +1,11 @@ +networks: + mainnet: + chainID: 61 + nodeURL: 'https://etc.rivet.link' + tokenListType: URL + tokenListSource: https://raw.githubusercontent.com/etcswap/tokens/refs/heads/main/ethereum-classic/all.json + nativeCurrencySymbol: 'ETC' + gasPriceRefreshInterval: 60 + +manualGasPrice: 100 +gasLimitTransaction: 3000000 diff --git a/src/templates/lists/ethereum-classic.json b/src/templates/lists/ethereum-classic.json new file mode 100644 index 0000000000..461257d300 --- /dev/null +++ b/src/templates/lists/ethereum-classic.json @@ -0,0 +1,18 @@ +{ + "tokens": [ + { + "address": "0x1953cab0e5bfa6d4a9bad6e05fd46c1cc6527a5a", + "symbol": "WETC", + "name": "Wrapped Ether", + "chainId": 61, + "decimals": 18 + }, + { + "address": "0xde093684c796204224bc081f937aa059d903c52a", + "symbol": "USC", + "name": "Classic USD", + "chainId": 61, + "decimals": 6 + } + ] +} \ No newline at end of file diff --git a/src/templates/root.yml b/src/templates/root.yml index 72a77fb285..735a258931 100644 --- a/src/templates/root.yml +++ b/src/templates/root.yml @@ -112,6 +112,14 @@ configurations: configurationPath: balancer.yml schemaPath: cronos-connector-schema.json + $namespace ethereum-classic: + configurationPath: ethereum-classic.yml + schemaPath: ethereum-schema.json + + $namespace etcswap: + configurationPath: etcswap.yml + schemaPath: etcswap-schema.json + $namespace telos: configurationPath: telos.yml schemaPath: ethereum-schema.json diff --git a/test-bronze/chains/ethereum-classic/ethereum-classic.test.ts b/test-bronze/chains/ethereum-classic/ethereum-classic.test.ts new file mode 100644 index 0000000000..b7dd4bea3e --- /dev/null +++ b/test-bronze/chains/ethereum-classic/ethereum-classic.test.ts @@ -0,0 +1,357 @@ +import request from 'supertest'; +import { patch, unpatch } from '../../../test/services/patch'; +import { gatewayApp } from '../../../src/app'; +import { + NETWORK_ERROR_CODE, + NETWORK_ERROR_MESSAGE, + UNKNOWN_ERROR_ERROR_CODE, + UNKNOWN_ERROR_MESSAGE, +} from '../../../src/services/error-handler'; +import * as transactionSuccesful from '../../../test/chains/ethereum/fixtures/transaction-succesful.json'; +import * as transactionSuccesfulReceipt from '../../../test/chains/ethereum//fixtures/transaction-succesful-receipt.json'; +import * as transactionOutOfGas from '../../../test/chains/ethereum//fixtures/transaction-out-of-gas.json'; +import { patchEVMNonceManager } from '../../../test/evm.nonce.mock'; +import { EthereumClassicChain } from '../../../src/chains/ethereum-classic/ethereum-classic'; + +let etc: EthereumClassicChain; + +beforeAll(async () => { + etc = EthereumClassicChain.getInstance('mainnet'); + + patchEVMNonceManager(etc.nonceManager); + + await etc.init(); +}); + +beforeEach(() => { + patchEVMNonceManager(etc.nonceManager); +}); + +afterEach(() => { + unpatch(); +}); + +afterAll(async () => { + await etc.close(); +}); + +const address: string = '0x242532ebDfcc760f2Ddfe8378eB51f5F847CE5bD'; + +const patchGetWallet = () => { + patch(etc, 'getWallet', () => { + return { + address, + }; + }); +}; + +const patchGetNonce = () => { + patch(etc.nonceManager, 'getNonce', () => 0); +}; + +const patchGetTokenBySymbol = () => { + patch(etc, 'getTokenBySymbol', () => { + return { + chainId: 97, + address: '0xae13d989dac2f0debff460ac112a837c89baa7cd', + decimals: 18, + name: 'WBNB Token', + symbol: 'WBNB', + logoURI: + 'https://exchange.pancakeswap.finance/images/coins/0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c.png', + }; + }); +}; + +const patchApproveERC20 = () => { + patch(etc, 'approveERC20', () => { + return { + type: 2, + chainId: 97, + nonce: 0, + maxPriorityFeePerGas: { toString: () => '106000000000' }, + maxFeePerGas: { toString: () => '106000000000' }, + gasPrice: { toString: () => null }, + gasLimit: { toString: () => '66763' }, + to: '0x8babbb98678facc7342735486c851abd7a0d17ca', + value: { toString: () => '0' }, + data: '0x095ea7b30000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', // noqa: mock + accessList: [], + hash: '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + v: 229, + r: '0x8800b16cbc6d468acad057dd5f724944d6aa48543cd90472e28dd5c6e90268b1', // noqa: mock + s: '0x662ed86bb86fb40911738ab67785f6e6c76f1c989d977ca23c504ef7a4796d08', // noqa: mock + from: '0x242532ebdfcc760f2ddfe8378eb51f5f847ce5bd', + confirmations: 98, + }; + }); +}; + +const patchGetERC20Allowance = () => { + patch(etc, 'getERC20Allowance', () => ({ value: 1, decimals: 3 })); +}; + +const patchGetNativeBalance = () => { + patch(etc, 'getNativeBalance', () => ({ value: 1, decimals: 3 })); +}; + +const patchGetERC20Balance = () => { + patch(etc, 'getERC20Balance', () => ({ value: 1, decimals: 3 })); +}; + +describe('POST /chain/approve', () => { + it('should return 200', async () => { + patchGetWallet(); + etc.getContract = jest.fn().mockReturnValue({ + address, + }); + patchGetNonce(); + patchGetTokenBySymbol(); + patchApproveERC20(); + + await request(gatewayApp) + .post(`/chain/approve`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address, + spender: address, + token: 'BNB', + }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .then((res: any) => { + expect(res.body.nonce).toEqual(0); + }); + }); + + it('should return 404 when parameters are invalid', async () => { + await request(gatewayApp) + .post(`/chain/approve`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address, + spender: address, + token: 123, + nonce: '23', + }) + .expect(404); + }); +}); + +describe('POST /chain/nonce', () => { + it('should return 200', async () => { + patchGetWallet(); + patchGetNonce(); + + await request(gatewayApp) + .post(`/chain/nonce`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address, + }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body.nonce).toBe(0)); + }); +}); + +describe('POST /chain/allowances', () => { + it('should return 200 asking for allowances', async () => { + patchGetWallet(); + patchGetTokenBySymbol(); + const spender = '0x242532ebDfcc760f2Ddfe8378eB51f5F847CE5bD'; + etc.getSpender = jest.fn().mockReturnValue(spender); + etc.getContract = jest.fn().mockReturnValue({ + address: '0x242532ebDfcc760f2Ddfe8378eB51f5F847CE5bD', + }); + patchGetERC20Allowance(); + + await request(gatewayApp) + .post(`/chain/allowances`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address: '0x242532ebDfcc760f2Ddfe8378eB51f5F847CE5bD', + spender: spender, + tokenSymbols: ['BNB', 'DAI'], + }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body.spender).toEqual(spender)) + .expect((res) => expect(res.body.approvals.BNB).toEqual('0.001')) + .expect((res) => expect(res.body.approvals.DAI).toEqual('0.001')); + }); +}); + +describe('POST /chain/balances', () => { + it('should return 200 asking for supported tokens', async () => { + patchGetWallet(); + patchGetTokenBySymbol(); + patchGetNativeBalance(); + patchGetERC20Balance(); + etc.getContract = jest.fn().mockReturnValue({ + address: '0x242532ebDfcc760f2Ddfe8378eB51f5F847CE5bD', + }); + + await request(gatewayApp) + .post(`/chain/balances`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address: '0x242532ebDfcc760f2Ddfe8378eB51f5F847CE5bD', + tokenSymbols: ['WETH', 'DAI'], + }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .expect((res) => expect(res.body.balances.WETH).toBeDefined()) + .expect((res) => expect(res.body.balances.DAI).toBeDefined()); + }); +}); + +describe('POST /chain/cancel', () => { + it('should return 200', async () => { + // override getWallet (network call) + etc.getWallet = jest.fn().mockReturnValue({ + address, + }); + + etc.cancelTx = jest.fn().mockReturnValue({ + hash: '0xf6b9e7cec507cb3763a1179ff7e2a88c6008372e3a6f297d9027a0b39b0fff77', // noqa: mock + }); + + await request(gatewayApp) + .post(`/chain/cancel`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address, + nonce: 23, + }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .then((res: any) => { + expect(res.body.txHash).toEqual( + '0xf6b9e7cec507cb3763a1179ff7e2a88c6008372e3a6f297d9027a0b39b0fff77' // noqa: mock + ); + }); + }); + + it('should return 404 when parameters are invalid', async () => { + await request(gatewayApp) + .post(`/chain/cancel`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + address: '', + nonce: '23', + }) + .expect(404); + }); +}); + +describe('POST /chain/poll', () => { + it('should get a NETWORK_ERROR_CODE when the network is unavailable', async () => { + patch(etc, 'getCurrentBlockNumber', () => { + const error: any = new Error('something went wrong'); + error.code = 'NETWORK_ERROR'; + throw error; + }); + + const res = await request(gatewayApp).post('/chain/poll').send({ + chain: 'ethereum-classic', + network: 'mainnet', + txHash: + '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + }); + + expect(res.statusCode).toEqual(503); + expect(res.body.errorCode).toEqual(NETWORK_ERROR_CODE); + expect(res.body.message).toEqual(NETWORK_ERROR_MESSAGE); + }); + + it('should get a UNKNOWN_ERROR_ERROR_CODE when an unknown error is thrown', async () => { + patch(etc, 'getCurrentBlockNumber', () => { + throw new Error(); + }); + + const res = await request(gatewayApp).post('/chain/poll').send({ + chain: 'ethereum-classic', + network: 'mainnet', + txHash: + '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + }); + + expect(res.statusCode).toEqual(503); + expect(res.body.errorCode).toEqual(UNKNOWN_ERROR_ERROR_CODE); + }); + + it('should get a null in txReceipt for Tx in the mempool', async () => { + patch(etc, 'getCurrentBlockNumber', () => 1); + patch(etc, 'getTransaction', () => transactionOutOfGas); + patch(etc, 'getTransactionReceipt', () => null); + const res = await request(gatewayApp).post('/chain/poll').send({ + chain: 'ethereum-classic', + network: 'mainnet', + txHash: + '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + }); + expect(res.statusCode).toEqual(200); + expect(res.body.txReceipt).toEqual(null); + expect(res.body.txData).toBeDefined(); + }); + + it('should get a null in txReceipt and txData for Tx that didnt reach the mempool and TxReceipt is null', async () => { + patch(etc, 'getCurrentBlockNumber', () => 1); + patch(etc, 'getTransaction', () => null); + patch(etc, 'getTransactionReceipt', () => null); + const res = await request(gatewayApp).post('/chain/poll').send({ + chain: 'ethereum-classic', + network: 'mainnet', + txHash: + '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + }); + expect(res.statusCode).toEqual(200); + expect(res.body.txReceipt).toEqual(null); + expect(res.body.txData).toEqual(null); + }); + + it('should get txStatus = 1 for a succesful query', async () => { + patch(etc, 'getCurrentBlockNumber', () => 1); + patch(etc, 'getTransaction', () => transactionSuccesful); + patch(etc, 'getTransactionReceipt', () => transactionSuccesfulReceipt); + const res = await request(gatewayApp).post('/chain/poll').send({ + chain: 'ethereum-classic', + network: 'mainnet', + txHash: + '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + }); + expect(res.statusCode).toEqual(200); + expect(res.body.txReceipt).toBeDefined(); + expect(res.body.txData).toBeDefined(); + }); + + it('should get unknown error', async () => { + patch(etc, 'getCurrentBlockNumber', () => { + const error: any = new Error('something went wrong'); + error.code = -32006; + throw error; + }); + const res = await request(gatewayApp).post('/chain/poll').send({ + chain: 'ethereum-classic', + network: 'mainnet', + txHash: + '0xffdb7b393b46d3795b82c94b8d836ad6b3087a914244634fa89c3abbbf00ed72', // noqa: mock + }); + expect(res.statusCode).toEqual(503); + expect(res.body.errorCode).toEqual(UNKNOWN_ERROR_ERROR_CODE); + expect(res.body.message).toEqual(UNKNOWN_ERROR_MESSAGE); + }); +}); diff --git a/test-bronze/connectors/etcSwap/etcSwap.lp.routes.test.ts b/test-bronze/connectors/etcSwap/etcSwap.lp.routes.test.ts new file mode 100644 index 0000000000..b20531c1c8 --- /dev/null +++ b/test-bronze/connectors/etcSwap/etcSwap.lp.routes.test.ts @@ -0,0 +1,442 @@ +import express from 'express'; +import { Express } from 'express-serve-static-core'; +import request from 'supertest'; +import { EthereumClassicChain } from '../../../src/chains/ethereum-classic/ethereum-classic'; +import { AmmLiquidityRoutes } from '../../../src/amm/amm.routes'; +import { patch, unpatch } from '../../../test/services/patch'; +import { ETCSwapLP } from '../../../src/connectors/etcswap/etcswap.lp'; +import { patchEVMNonceManager } from '../../../test/evm.nonce.mock'; + +let app: Express; +let ethereumclassic: EthereumClassicChain; +let etcSwap: ETCSwapLP; + +beforeAll(async () => { + app = express(); + app.use(express.json()); + ethereumclassic = EthereumClassicChain.getInstance('mainnet'); + patchEVMNonceManager(ethereumclassic.nonceManager); + await ethereumclassic.init(); + + etcSwap = ETCSwapLP.getInstance('ethereum-classic', 'mainnet'); + await etcSwap.init(); + app.use('/amm/liquidity', AmmLiquidityRoutes.router); +}); + +beforeEach(() => { + patchEVMNonceManager(ethereumclassic.nonceManager); +}); + +afterEach(() => { + unpatch(); +}); + +afterAll(async () => { + await ethereumclassic.close(); +}); + +const address: string = '0xFaA12FD102FE8623C9299c72B03E45107F2772B5'; + +const patchGetWallet = () => { + patch(ethereumclassic, 'getWallet', () => { + return { + address: '0xFaA12FD102FE8623C9299c72B03E45107F2772B5', + }; + }); +}; + +const patchInit = () => { + patch(etcSwap, 'init', async () => { + return; + }); +}; + +const patchStoredTokenList = () => { + patch(ethereumclassic, 'tokenList', () => { + return [ + { + chainId: 61, + name: 'WETH', + symbol: 'WETH', + address: '0x1953cab0e5bfa6d4a9bad6e05fd46c1cc6527a5a', + decimals: 18, + }, + { + chainId: 61, + name: 'DAI', + symbol: 'DAI', + address: '0xde093684c796204224bc081f937aa059d903c52a', + decimals: 18, + }, + ]; + }); +}; + +const patchGetTokenBySymbol = () => { + patch(ethereumclassic, 'getTokenBySymbol', (symbol: string) => { + if (symbol === 'WETH') { + return { + chainId: 61, + name: 'WETH', + symbol: 'WETH', + address: '0x1953cab0e5bfa6d4a9bad6e05fd46c1cc6527a5a', + decimals: 18, + }; + } else { + return { + chainId: 61, + name: 'DAI', + symbol: 'DAI', + address: '0xde093684c796204224bc081f937aa059d903c52a', + decimals: 18, + }; + } + }); +}; + +const patchGetTokenByAddress = () => { + patch(etcSwap, 'getTokenByAddress', () => { + return { + chainId: 61, + name: 'WETH', + symbol: 'WETH', + address: '0x1953cab0e5bfa6d4a9bad6e05fd46c1cc6527a5a', + decimals: 18, + }; + }); +}; + +const patchGasPrice = () => { + patch(ethereumclassic, 'gasPrice', () => 100); +}; + +const patchGetNonce = () => { + patch(ethereumclassic.nonceManager, 'getNonce', () => 21); +}; + +const patchAddPosition = () => { + patch(etcSwap, 'addPosition', () => { + return { nonce: 21, hash: '000000000000000' }; + }); +}; + +const patchRemovePosition = () => { + patch(etcSwap, 'reducePosition', () => { + return { nonce: 21, hash: '000000000000000' }; + }); +}; + +const patchCollectFees = () => { + patch(etcSwap, 'collectFees', () => { + return { nonce: 21, hash: '000000000000000' }; + }); +}; + +const patchPosition = () => { + patch(etcSwap, 'getPosition', () => { + return { + token0: 'DAI', + token1: 'WETH', + fee: 300, + lowerPrice: '1', + upperPrice: '5', + amount0: '1', + amount1: '1', + unclaimedToken0: '1', + unclaimedToken1: '1', + }; + }); +}; + +describe('POST /liquidity/add', () => { + it('should return 200 when all parameter are OK', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchGasPrice(); + patchAddPosition(); + patchGetNonce(); + + await request(app) + .post(`/amm/liquidity/add`) + .send({ + address: address, + token0: 'DAI', + token1: 'WETH', + amount0: '1', + amount1: '1', + fee: 'LOW', + lowerPrice: '1', + upperPrice: '5', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 500 for unrecognized token0 symbol', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + + await request(app) + .post(`/amm/liquidity/add`) + .send({ + address: address, + token0: 'DOGE', + token1: 'WETH', + amount0: '1', + amount1: '1', + fee: 'LOW', + lowerPrice: '1', + upperPrice: '5', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 404 for invalid fee tier', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/liquidity/add`) + .send({ + address: address, + token0: 'DAI', + token1: 'WETH', + amount0: '1', + amount1: '1', + fee: 300, + lowerPrice: '1', + upperPrice: '5', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(404); + }); + + it('should return 500 when the helper operation fails', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patch(etcSwap, 'addPositionHelper', () => { + return 'error'; + }); + + await request(app) + .post(`/amm/liquidity/add`) + .send({ + address: address, + token0: 'DAI', + token1: 'WETH', + amount0: '1', + amount1: '1', + fee: 'LOW', + lowerPrice: '1', + upperPrice: '5', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(500); + }); +}); + +describe('POST /liquidity/remove', () => { + const patchForBuy = () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchGasPrice(); + patchRemovePosition(); + patchGetNonce(); + }; + it('should return 200 when all parameter are OK', async () => { + patchForBuy(); + await request(app) + .post(`/amm/liquidity/remove`) + .send({ + address: address, + tokenId: 2732, + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 404 when the tokenId is invalid', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/liquidity/remove`) + .send({ + address: address, + tokenId: 'Invalid', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(404); + }); +}); + +describe('POST /liquidity/collect_fees', () => { + const patchForBuy = () => { + patchGetWallet(); + patchInit(); + patchGasPrice(); + patchCollectFees(); + patchGetNonce(); + }; + it('should return 200 when all parameter are OK', async () => { + patchForBuy(); + await request(app) + .post(`/amm/liquidity/collect_fees`) + .send({ + address: address, + tokenId: 2732, + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 404 when the tokenId is invalid', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/liquidity/collect_fees`) + .send({ + address: address, + tokenId: 'Invalid', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(404); + }); +}); + +describe('POST /liquidity/position', () => { + it('should return 200 when all parameter are OK', async () => { + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchPosition(); + + await request(app) + .post(`/amm/liquidity/position`) + .send({ + tokenId: 2732, + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 404 when the tokenId is invalid', async () => { + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/liquidity/position`) + .send({ + tokenId: 'Invalid', + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(404); + }); +}); + +describe('POST /liquidity/price', () => { + const patchForBuy = () => { + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patch(etcSwap, 'poolPrice', () => { + return ['100', '105']; + }); + }; + it('should return 200 when all parameter are OK', async () => { + patchForBuy(); + await request(app) + .post(`/amm/liquidity/price`) + .send({ + token0: 'DAI', + token1: 'WETH', + fee: 'LOW', + period: 120, + interval: 60, + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 404 when the fee is invalid', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/liquidity/price`) + .send({ + token0: 'DAI', + token1: 'WETH', + fee: 11, + period: 120, + interval: 60, + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswapLP', + }) + .set('Accept', 'application/json') + .expect(404); + }); +}); diff --git a/test-bronze/connectors/etcSwap/etcSwap.lp.test.ts b/test-bronze/connectors/etcSwap/etcSwap.lp.test.ts new file mode 100644 index 0000000000..855a42537f --- /dev/null +++ b/test-bronze/connectors/etcSwap/etcSwap.lp.test.ts @@ -0,0 +1,277 @@ +jest.useFakeTimers(); +import { Token } from '@uniswap/sdk-core'; +import * as uniV3 from '@uniswap/v3-sdk'; +import { BigNumber, Transaction, Wallet } from 'ethers'; +import { patch, unpatch } from '../../../test/services/patch'; +import { EthereumClassicChain } from '../../../src/chains/ethereum-classic/ethereum-classic'; +import { ETCSwapLP } from '../../../src/connectors/etcswap/etcswap.lp'; +import { patchEVMNonceManager } from '../../../test/evm.nonce.mock'; +let ethereumC: EthereumClassicChain; +let etcSwapLP: ETCSwapLP; +let wallet: Wallet; + +const WETH = new Token( + 61, + '0x1953cab0e5bfa6d4a9bad6e05fd46c1cc6527a5a', + 18, + 'WETH' +); + +const DAI = new Token( + 61, + '0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60', + 18, + 'DAI' +); + +const USDC = new Token( + 61, + '0xde093684c796204224bc081f937aa059d903c52a', + 6, + 'USDC' +); + +const TX = { + type: 2, + chainId: 61, + nonce: 115, + maxPriorityFeePerGas: { toString: () => '106000000000' }, + maxFeePerGas: { toString: () => '106000000000' }, + gasPrice: { toString: () => null }, + gasLimit: { toString: () => '100000' }, + to: '0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60', + value: { toString: () => '0' }, + data: '0x095ea7b30000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', // noqa: mock + accessList: [], + hash: '0x75f98675a8f64dcf14927ccde9a1d59b67fa09b72cc2642ad055dae4074853d9', // noqa: mock + v: 0, + r: '0xbeb9aa40028d79b9fdab108fcef5de635457a05f3a254410414c095b02c64643', // noqa: mock + s: '0x5a1506fa4b7f8b4f3826d8648f27ebaa9c0ee4bd67f569414b8cd8884c073100', // noqa: mock + from: '0xFaA12FD102FE8623C9299c72B03E45107F2772B5', + confirmations: 0, +}; + +const POOL_SQRT_RATIO_START = uniV3.encodeSqrtRatioX96(100e6, 100e18); + +const POOL_TICK_CURRENT = uniV3.TickMath.getTickAtSqrtRatio( + POOL_SQRT_RATIO_START +); + +const DAI_USDC_POOL = new uniV3.Pool( + DAI, + USDC, + 500, + POOL_SQRT_RATIO_START, + 0, + POOL_TICK_CURRENT, + [] +); + +beforeAll(async () => { + ethereumC = EthereumClassicChain.getInstance('mainnet'); + patchEVMNonceManager(ethereumC.nonceManager); + await ethereumC.init(); + + wallet = new Wallet( + '0000000000000000000000000000000000000000000000000000000000000002', // noqa: mock + ethereumC.provider + ); + etcSwapLP = ETCSwapLP.getInstance('ethereum-classis', 'mainnet'); + await etcSwapLP.init(); +}); + +beforeEach(() => { + patchEVMNonceManager(ethereumC.nonceManager); +}); + +afterEach(() => { + unpatch(); +}); + +afterAll(async () => { + await ethereumC.close(); +}); + +const patchPoolState = () => { + patch(etcSwapLP, 'getPoolContract', () => { + return { + liquidity() { + return DAI_USDC_POOL.liquidity; + }, + slot0() { + return [ + DAI_USDC_POOL.sqrtRatioX96, + DAI_USDC_POOL.tickCurrent, + 0, + 1, + 1, + 0, + true, + ]; + }, + ticks() { + return ['-118445039955967015140', '118445039955967015140']; + }, + }; + }); +}; + +const patchContract = () => { + patch(etcSwapLP, 'getContract', () => { + return { + estimateGas: { + multicall() { + return BigNumber.from(5); + }, + }, + positions() { + return { + token0: WETH.address, + token1: USDC.address, + fee: 500, + tickLower: 0, + tickUpper: 23030, + liquidity: '6025055903594410671025', + }; + }, + multicall() { + return TX; + }, + collect() { + return TX; + }, + }; + }); +}; + +const patchWallet = () => { + patch(wallet, 'sendTransaction', () => { + return TX; + }); +}; + +describe('verify ETCSwapLP Nft functions', () => { + it('Should return correct contract addresses', async () => { + expect(etcSwapLP.router).toEqual( + '0xEd88EDD995b00956097bF90d39C9341BBde324d1' + ); + expect(etcSwapLP.nftManager).toEqual( + '0x3CEDe6562D6626A04d7502CC35720901999AB699' + ); + }); + + it('Should return correct contract abi', async () => { + expect(Array.isArray(etcSwapLP.routerAbi)).toEqual(true); + expect(Array.isArray(etcSwapLP.nftAbi)).toEqual(true); + expect(Array.isArray(etcSwapLP.poolAbi)).toEqual(true); + }); + + it('addPositionHelper returns calldata and value', async () => { + patchPoolState(); + + const callData = await etcSwapLP.addPositionHelper( + wallet, + DAI, + WETH, + '10', + '10', + 500, + 1, + 10 + ); + expect(callData).toHaveProperty('calldata'); + expect(callData).toHaveProperty('value'); + }); + + it('reducePositionHelper returns calldata and value', async () => { + patchPoolState(); + patchContract(); + + const callData = await etcSwapLP.reducePositionHelper(wallet, 1, 100); + expect(callData).toHaveProperty('calldata'); + expect(callData).toHaveProperty('value'); + }); + + it('basic functions should work', async () => { + patchContract(); + patchPoolState(); + + expect(etcSwapLP.ready()).toEqual(true); + expect(etcSwapLP.gasLimitEstimate).toBeGreaterThan(0); + expect(typeof etcSwapLP.getContract('nft', ethereumC.provider)).toEqual( + 'object' + ); + expect( + typeof etcSwapLP.getPoolContract( + '0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa', + wallet + ) + ).toEqual('object'); + }); + + it('generateOverrides returns overrides correctly', async () => { + const overrides = etcSwapLP.generateOverrides( + 1, + 2, + 3, + BigNumber.from(4), + BigNumber.from(5), + '6' + ); + expect(overrides.gasLimit).toEqual(BigNumber.from('1')); + expect(overrides.gasPrice).toBeUndefined(); + expect(overrides.nonce).toEqual(BigNumber.from(3)); + expect(overrides.maxFeePerGas as BigNumber).toEqual(BigNumber.from(4)); + expect(overrides.maxPriorityFeePerGas as BigNumber).toEqual( + BigNumber.from(5) + ); + expect(overrides.value).toEqual(BigNumber.from('6')); + }); + + it('reducePosition should work', async () => { + patchPoolState(); + patchContract(); + + const reduceTx = (await etcSwapLP.reducePosition( + wallet, + 1, + 100, + 50000, + 10 + )) as Transaction; + expect(reduceTx.hash).toEqual( + '0x75f98675a8f64dcf14927ccde9a1d59b67fa09b72cc2642ad055dae4074853d9' // noqa: mock + ); + }); + + it('addPosition should work', async () => { + patchPoolState(); + patchWallet(); + + const addTx = await etcSwapLP.addPosition( + wallet, + DAI, + WETH, + '10', + '10', + 'LOWEST', + 1, + 10, + 0, + 1, + 1 + ); + expect(addTx.hash).toEqual( + '0x75f98675a8f64dcf14927ccde9a1d59b67fa09b72cc2642ad055dae4074853d9' // noqa: mock + ); + }); + + it('collectFees should work', async () => { + patchContract(); + + const collectTx = (await etcSwapLP.collectFees(wallet, 1)) as Transaction; + expect(collectTx.hash).toEqual( + '0x75f98675a8f64dcf14927ccde9a1d59b67fa09b72cc2642ad055dae4074853d9' // noqa: mock + ); + }); +}); diff --git a/test-bronze/connectors/etcSwap/etcSwap.routes.test.ts b/test-bronze/connectors/etcSwap/etcSwap.routes.test.ts new file mode 100644 index 0000000000..77711753c0 --- /dev/null +++ b/test-bronze/connectors/etcSwap/etcSwap.routes.test.ts @@ -0,0 +1,678 @@ +import express from 'express'; +import { Express } from 'express-serve-static-core'; +import request from 'supertest'; +import { AmmRoutes } from '../../../src/amm/amm.routes'; +import { patch, unpatch } from '../../../test/services/patch'; +import { gasCostInEthString } from '../../../src/services/base'; +import { ETCSwap } from '../../../src/connectors/etcswap/etcswap'; +import { EthereumClassicChain } from '../../../src/chains/ethereum-classic/ethereum-classic'; +import { patchEVMNonceManager } from '../../../test/evm.nonce.mock'; +let app: Express; +let ethereumclassic: EthereumClassicChain; +let etcSwap: ETCSwap; + +beforeAll(async () => { + app = express(); + app.use(express.json()); + + ethereumclassic = EthereumClassicChain.getInstance('mainnet'); + patchEVMNonceManager(ethereumclassic.nonceManager); + await ethereumclassic.init(); + + etcSwap = ETCSwap.getInstance('ethereum-classic', 'mainnet'); + await etcSwap.init(); + + app.use('/amm', AmmRoutes.router); +}); + +beforeEach(() => { + patchEVMNonceManager(ethereumclassic.nonceManager); +}); + +afterEach(() => { + unpatch(); +}); + +afterAll(async () => { + await ethereumclassic.close(); +}); + +const address: string = '0xFaA12FD102FE8623C9299c72B03E45107F2772B5'; + +const patchGetWallet = () => { + patch(ethereumclassic, 'getWallet', () => { + return { + address: '0xFaA12FD102FE8623C9299c72B03E45107F2772B5', + }; + }); +}; + +const patchInit = () => { + patch(etcSwap, 'init', async () => { + return; + }); +}; + +const patchStoredTokenList = () => { + patch(ethereumclassic, 'tokenList', () => { + return [ + { + chainId: 61, + name: 'WETC', + symbol: 'WETC', + address: '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', + decimals: 18, + }, + { + chainId: 61, + name: 'DAI', + symbol: 'DAI', + address: '0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60', + decimals: 18, + }, + ]; + }); +}; + +const patchGetTokenBySymbol = () => { + patch(ethereumclassic, 'getTokenBySymbol', (symbol: string) => { + if (symbol === 'WETC') { + return { + chainId: 61, + name: 'WETC', + symbol: 'WETC', + address: '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', + decimals: 18, + }; + } else { + return { + chainId: 61, + name: 'DAI', + symbol: 'DAI', + address: '0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60', + decimals: 18, + }; + } + }); +}; + +const patchGetTokenByAddress = () => { + patch(etcSwap, 'getTokenByAddress', () => { + return { + chainId: 61, + name: 'WETC', + symbol: 'WETC', + address: '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', + decimals: 18, + }; + }); +}; + +const patchGasPrice = () => { + patch(ethereumclassic, 'gasPrice', () => 100); +}; + +const patchEstimateBuyTrade = () => { + patch(etcSwap, 'estimateBuyTrade', () => { + return { + expectedAmount: { + toSignificant: () => 100, + }, + trade: { + executionPrice: { + invert: jest.fn().mockReturnValue({ + toSignificant: () => 100, + toFixed: () => '100', + }), + }, + }, + }; + }); +}; + +const patchEstimateSellTrade = () => { + patch(etcSwap, 'estimateSellTrade', () => { + return { + expectedAmount: { + toSignificant: () => 100, + }, + trade: { + executionPrice: { + toSignificant: () => 100, + toFixed: () => '100', + }, + }, + }; + }); +}; + +const patchGetNonce = () => { + patch(ethereumclassic.nonceManager, 'getNonce', () => 21); +}; + +const patchExecuteTrade = () => { + patch(etcSwap, 'executeTrade', () => { + return { nonce: 21, hash: '000000000000000' }; + }); +}; + +describe('POST /amm/price', () => { + it('should return 200 for BUY', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchGasPrice(); + patchEstimateBuyTrade(); + patchGetNonce(); + patchExecuteTrade(); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + side: 'BUY', + }) + .set('Accept', 'application/json') + .expect(200) + .then((res: any) => { + expect(res.body.amount).toEqual('10000.000000000000000000'); + expect(res.body.rawAmount).toEqual('10000000000000000000000'); + }); + }); + + it('should return 200 for SELL', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchGasPrice(); + patchEstimateSellTrade(); + patchGetNonce(); + patchExecuteTrade(); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + side: 'SELL', + }) + .set('Accept', 'application/json') + .expect(200) + .then((res: any) => { + expect(res.body.amount).toEqual('10000.000000000000000000'); + expect(res.body.rawAmount).toEqual('10000000000000000000000'); + }); + }); + + it('should return 500 for unrecognized quote symbol', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DOGE', + base: 'WETC', + amount: '10000', + side: 'SELL', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 for unrecognized base symbol', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'SHIBA', + amount: '10000', + side: 'SELL', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 for unrecognized base symbol with decimals in the amount and SELL', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'SHIBA', + amount: '10.000', + side: 'SELL', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 for unrecognized base symbol with decimals in the amount and BUY', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'SHIBA', + amount: '10.000', + side: 'BUY', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 when the priceSwapIn operation fails', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patch(etcSwap, 'priceSwapIn', () => { + return 'error'; + }); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DOGE', + base: 'WETC', + amount: '10000', + side: 'SELL', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 when the priceSwapOut operation fails', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patch(etcSwap, 'priceSwapOut', () => { + return 'error'; + }); + + await request(app) + .post(`/amm/price`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DOGE', + base: 'WETC', + amount: '10000', + side: 'BUY', + }) + .set('Accept', 'application/json') + .expect(500); + }); +}); + +describe('POST /amm/trade', () => { + const patchForBuy = () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchGasPrice(); + patchEstimateBuyTrade(); + patchGetNonce(); + patchExecuteTrade(); + }; + it('should return 200 for BUY', async () => { + patchForBuy(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'BUY', + nonce: 21, + }) + .set('Accept', 'application/json') + .expect(200) + .then((res: any) => { + expect(res.body.nonce).toEqual(21); + }); + }); + + it('should return 200 for BUY without nonce parameter', async () => { + patchForBuy(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'BUY', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 200 for BUY with maxFeePerGas and maxPriorityFeePerGas', async () => { + patchForBuy(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'BUY', + nonce: 21, + maxFeePerGas: '5000000000', + maxPriorityFeePerGas: '5000000000', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + const patchForSell = () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patchGasPrice(); + patchEstimateSellTrade(); + patchGetNonce(); + patchExecuteTrade(); + }; + it('should return 200 for SELL', async () => { + patchForSell(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'SELL', + nonce: 21, + }) + .set('Accept', 'application/json') + .expect(200) + .then((res: any) => { + expect(res.body.nonce).toEqual(21); + }); + }); + + it('should return 200 for SELL with maxFeePerGas and maxPriorityFeePerGas', async () => { + patchForSell(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'SELL', + nonce: 21, + maxFeePerGas: '5000000000', + maxPriorityFeePerGas: '5000000000', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 200 for SELL with limitPrice', async () => { + patchForSell(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'SELL', + nonce: 21, + limitPrice: '9', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 200 for BUY with limitPrice', async () => { + patchForBuy(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'BUY', + nonce: 21, + limitPrice: '999999999999999999999', + }) + .set('Accept', 'application/json') + .expect(200); + }); + + it('should return 500 for BUY with price smaller than limitPrice', async () => { + patchForBuy(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'BUY', + nonce: 21, + limitPrice: '9', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 for SELL with price higher than limitPrice', async () => { + patchForSell(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'SELL', + nonce: 21, + limitPrice: '99999999999', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 404 when parameters are incorrect', async () => { + patchInit(); + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: 10000, + address: 'da8', + side: 'comprar', + }) + .set('Accept', 'application/json') + .expect(404); + }); + it('should return 500 when the priceSwapIn operation fails', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patch(etcSwap, 'priceSwapIn', () => { + return 'error'; + }); + + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'SELL', + nonce: 21, + maxFeePerGas: '5000000000', + maxPriorityFeePerGas: '5000000000', + }) + .set('Accept', 'application/json') + .expect(500); + }); + + it('should return 500 when the priceSwapOut operation fails', async () => { + patchGetWallet(); + patchInit(); + patchStoredTokenList(); + patchGetTokenBySymbol(); + patchGetTokenByAddress(); + patch(etcSwap, 'priceSwapOut', () => { + return 'error'; + }); + + await request(app) + .post(`/amm/trade`) + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + quote: 'DAI', + base: 'WETC', + amount: '10000', + address, + side: 'BUY', + nonce: 21, + maxFeePerGas: '5000000000', + maxPriorityFeePerGas: '5000000000', + }) + .set('Accept', 'application/json') + .expect(500); + }); +}); + +describe('POST /amm/estimateGas', () => { + it('should return 200 for valid connector', async () => { + patchInit(); + patchGasPrice(); + + await request(app) + .post('/amm/estimateGas') + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: 'etcswap', + }) + .set('Accept', 'application/json') + .expect(200) + .then((res: any) => { + expect(res.body.network).toEqual('mainnet'); + expect(res.body.gasPrice).toEqual(100); + expect(res.body.gasCost).toEqual( + gasCostInEthString(100, etcSwap.gasLimitEstimate), + ); + }); + }); + + it('should return 500 for invalid connector', async () => { + patchInit(); + patchGasPrice(); + + await request(app) + .post('/amm/estimateGas') + .send({ + chain: 'ethereum-classic', + network: 'mainnet', + connector: '-1', + }) + .set('Accept', 'application/json') + .expect(500); + }); +}); diff --git a/test-bronze/connectors/etcSwap/etcSwap.test.ts b/test-bronze/connectors/etcSwap/etcSwap.test.ts new file mode 100644 index 0000000000..0143b2f7f5 --- /dev/null +++ b/test-bronze/connectors/etcSwap/etcSwap.test.ts @@ -0,0 +1,306 @@ +jest.useFakeTimers(); +const { MockProvider } = require('mock-ethers-provider'); +import { patch, unpatch } from '../../../test/services/patch'; +import { UniswapishPriceError } from '../../../src/services/error-handler'; +import { + CurrencyAmount, + TradeType, + Token, +} from '@_etcswap/smart-order-router/node_modules/@uniswap/sdk-core'; +import { + Pair, + Route, +} from '@_etcswap/smart-order-router/node_modules/@uniswap/router-sdk/node_modules/@uniswap/v2-sdk'; +import { Trade } from '@_etcswap/smart-order-router/node_modules/@uniswap/router-sdk'; +import { BigNumber, constants, utils } from 'ethers'; +import { + FACTORY_ADDRESS, + TickMath, + encodeSqrtRatioX96, + Pool as EtcswapV3Pool, + FeeAmount, +} from '@_etcswap/smart-order-router/node_modules/@uniswap/v3-sdk'; +import { EthereumClassicChain } from '../../../src/chains/ethereum-classic/ethereum-classic'; +import { ETCSwap } from '../../../src/connectors/etcswap/etcswap'; +import { ETCSwapConfig } from '../../../src/connectors/etcswap/etcswap.config'; +import { patchEVMNonceManager } from '../../../test/evm.nonce.mock'; + +let ethereumclassic: EthereumClassicChain; +let etcSwap: ETCSwap; +let mockProvider: typeof MockProvider; + +const WETC = new Token( + 3, + '0xd0A1E359811322d97991E03f863a0C30C2cF029C', + 18, + 'WETC', +); + +const DAI = new Token( + 3, + '0x4f96fe3b7a6cf9725f59d353f723c1bdb64ca6aa', + 18, + 'DAI', +); + +const DAI_WETH_POOL_ADDRESS = '0xBEff876AC507446457C2A6bDA9F7021A97A8547f'; +const POOL_SQRT_RATIO_START = encodeSqrtRatioX96(100e6, 100e18); +const POOL_TICK_CURRENT = TickMath.getTickAtSqrtRatio(POOL_SQRT_RATIO_START); +const POOL_LIQUIDITY = 0; +const DAI_WETH_POOL = new EtcswapV3Pool( + WETC, + DAI, + FeeAmount.MEDIUM, + POOL_SQRT_RATIO_START, + POOL_LIQUIDITY, + POOL_TICK_CURRENT, +); + +beforeAll(async () => { + ethereumclassic = EthereumClassicChain.getInstance('mainnet'); + patchEVMNonceManager(ethereumclassic.nonceManager); + await ethereumclassic.init(); +}); + +beforeEach(() => { + patchEVMNonceManager(ethereumclassic.nonceManager); +}); + +afterEach(() => { + unpatch(); +}); + +afterAll(async () => { + await ethereumclassic.close(); +}); + +const patchTrade = (_key: string, error?: Error) => { + patch(etcSwap, '_alphaRouter', { + route() { + if (error) return false; + const WETH_DAI = new Pair( + CurrencyAmount.fromRawAmount(WETC, '2000000000000000000'), + CurrencyAmount.fromRawAmount(DAI, '1000000000000000000'), + ); + const DAI_TO_WETH = new Route([WETH_DAI], DAI, WETC); + return { + quote: CurrencyAmount.fromRawAmount(DAI, '1000000000000000000'), + quoteGasAdjusted: CurrencyAmount.fromRawAmount( + DAI, + '1000000000000000000', + ), + estimatedGasUsed: utils.parseEther('100'), + estimatedGasUsedQuoteToken: CurrencyAmount.fromRawAmount( + DAI, + '1000000000000000000', + ), + estimatedGasUsedUSD: CurrencyAmount.fromRawAmount( + DAI, + '1000000000000000000', + ), + gasPriceWei: utils.parseEther('100'), + trade: new Trade({ + v2Routes: [ + { + routev2: DAI_TO_WETH, + inputAmount: CurrencyAmount.fromRawAmount( + DAI, + '1000000000000000000', + ), + outputAmount: CurrencyAmount.fromRawAmount( + WETC, + '2000000000000000000', + ), + }, + ], + v3Routes: [], + tradeType: TradeType.EXACT_INPUT, + }), + route: [], + blockNumber: BigNumber.from(5000), + }; + }, + }); +}; + +const patchMockProvider = () => { + mockProvider.setMockContract( + FACTORY_ADDRESS, + require('@_etcswap/smart-order-router/node_modules/@uniswap/v3-core/artifacts/contracts/UniswapV3Factory.sol/UniswapV3Factory.json') + .abi, + ); + mockProvider.stub(FACTORY_ADDRESS, 'getPool', DAI_WETH_POOL_ADDRESS); + + mockProvider.setMockContract( + ETCSwapConfig.config.quoterContractAddress('mainnet'), + // require('@uniswap/swap-router-contracts/artifacts/contracts/lens/QuoterV2.sol/QuoterV2.json') + require('@_etcswap/smart-order-router/node_modules/@uniswap/swap-router-contracts/artifacts/contracts/lens/QuoterV2.sol/QuoterV2.json') + .abi, + ); + mockProvider.stub( + ETCSwapConfig.config.quoterContractAddress('mainnet'), + 'quoteExactInputSingle', + /* amountOut */ 1, + /* sqrtPriceX96After */ 0, + /* initializedTicksCrossed */ 0, + /* gasEstimate */ 0, + ); + mockProvider.stub( + ETCSwapConfig.config.quoterContractAddress('mainnet'), + 'quoteExactOutputSingle', + /* amountIn */ 1, + /* sqrtPriceX96After */ 0, + /* initializedTicksCrossed */ 0, + /* gasEstimate */ 0, + ); + + mockProvider.setMockContract( + DAI_WETH_POOL_ADDRESS, + require('@_etcswap/smart-order-router/node_modules/@uniswap/v3-core/artifacts/contracts/UniswapV3Pool.sol/UniswapV3Pool.json') + .abi, + ); + mockProvider.stub( + DAI_WETH_POOL_ADDRESS, + 'slot0', + DAI_WETH_POOL.sqrtRatioX96.toString(), + DAI_WETH_POOL.tickCurrent, + /* observationIndex */ 0, + /* observationCardinality */ 1, + /* observationCardinalityNext */ 1, + /* feeProtocol */ 0, + /* unlocked */ true, + ); + mockProvider.stub(DAI_WETH_POOL_ADDRESS, 'liquidity', 0); + mockProvider.stub(DAI_WETH_POOL_ADDRESS, 'fee', FeeAmount.LOW); + patch(ethereumclassic, 'provider', () => { + return mockProvider; + }); +}; + +const patchGetPool = (address: string | null) => { + mockProvider.setMockContract( + FACTORY_ADDRESS, + require('@_etcswap/smart-order-router/node_modules/@uniswap/v3-core/artifacts/contracts/UniswapV3Factory.sol/UniswapV3Factory.json') + .abi, + ); + mockProvider.stub(FACTORY_ADDRESS, 'getPool', address); +}; + +const useRouter = async () => { + const config = ETCSwapConfig.config; + config.useRouter = true; + + patch(ETCSwap, '_instances', () => ({})); + etcSwap = ETCSwap.getInstance('ethereum-classic', 'mainnet'); + await etcSwap.init(); +}; + +const useQouter = async () => { + const config = ETCSwapConfig.config; + config.useRouter = false; + config.feeTier = 'MEDIUM'; + + patch(ETCSwap, '_instances', () => ({})); + etcSwap = ETCSwap.getInstance('ethereum-classic', 'mainnet'); + await etcSwap.init(); + + mockProvider = new MockProvider(); + patchMockProvider(); +}; + +describe('verify ETCSwap estimateSellTrade', () => { + describe('when using router', () => { + beforeAll(async () => { + await useRouter(); + }); + + it('Should return an ExpectedTrade when available', async () => { + patchTrade('bestTradeExactIn'); + + const expectedTrade = await etcSwap.estimateSellTrade( + WETC, + DAI, + BigNumber.from(1), + ); + expect(expectedTrade).toHaveProperty('trade'); + expect(expectedTrade).toHaveProperty('expectedAmount'); + }); + + it('Should throw an error if no pair is available', async () => { + patchTrade('bestTradeExactIn', new Error('error getting trade')); + + await expect(async () => { + await etcSwap.estimateSellTrade(WETC, DAI, BigNumber.from(1)); + }).rejects.toThrow(UniswapishPriceError); + }); + }); + + describe('when using qouter', () => { + beforeEach(async () => { + await useQouter(); + }); + + // it('Should return an ExpectedTrade when available', async () => { + // patchGetPool(DAI_WETH_POOL_ADDRESS); + + // const expectedTrade = await etcSwap.estimateSellTrade( + // WETC, + // DAI, + // BigNumber.from(1) + // ); + + // expect(expectedTrade).toHaveProperty('trade'); + // expect(expectedTrade).toHaveProperty('expectedAmount'); + // }); + + it('Should throw an error if no pair is available', async () => { + patchGetPool(constants.AddressZero); + + await expect(async () => { + await etcSwap.estimateSellTrade(WETC, DAI, BigNumber.from(1)); + }).rejects.toThrow(Error); + }); + }); +}); + +describe('verify ETCSwap estimateBuyTrade', () => { + describe('when using router', () => { + beforeAll(async () => { + await useRouter(); + }); + + it('Should return an ExpectedTrade when available', async () => { + patchTrade('bestTradeExactOut'); + + const expectedTrade = await etcSwap.estimateBuyTrade( + WETC, + DAI, + BigNumber.from(1), + ); + expect(expectedTrade).toHaveProperty('trade'); + expect(expectedTrade).toHaveProperty('expectedAmount'); + }); + + it('Should return an error if no pair is available', async () => { + patchTrade('bestTradeExactOut', new Error('error getting trade')); + + await expect(async () => { + await etcSwap.estimateBuyTrade(WETC, DAI, BigNumber.from(1)); + }).rejects.toThrow(UniswapishPriceError); + }); + }); + + describe('when using qouter', () => { + beforeEach(async () => { + await useQouter(); + }); + + it('Should throw an error if no pair is available', async () => { + patchGetPool(constants.AddressZero); + + await expect(async () => { + await etcSwap.estimateBuyTrade(WETC, DAI, BigNumber.from(1)); + }).rejects.toThrow(Error); + }); + }); +}); diff --git a/test/connectors/uniswap/uniswap.test.ts b/test/connectors/uniswap/uniswap.test.ts index 5006a4f99c..a20ae76fee 100644 --- a/test/connectors/uniswap/uniswap.test.ts +++ b/test/connectors/uniswap/uniswap.test.ts @@ -112,7 +112,7 @@ const patchTrade = (_key: string, error?: Error) => { route: [], blockNumber: BigNumber.from(5000), }; - } + } }); }; diff --git a/yarn.lock b/yarn.lock index 1a13698ca7..01a0587386 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,34 @@ # yarn lockfile v1 +"@_etcswap/smart-order-router@^3.15.2": + version "3.15.2" + resolved "https://registry.yarnpkg.com/@_etcswap/smart-order-router/-/smart-order-router-3.15.2.tgz#989653df5d98158aaa0971a6493ea9a4efc16a3e" + integrity sha512-/XlbjLeWtQEg4HfvebPU5CWbGbq2e9y19YQY2BXtKsjyHli1YNx4yvR3kawFZPhePgENdBYp7l8NFrq2Ovjbcw== + dependencies: + "@uniswap/default-token-list" "^11.2.0" + "@uniswap/permit2-sdk" "^1.2.0" + "@uniswap/router-sdk" "git+https://github.com/etcswap/router-sdk.git#etcswap" + "@uniswap/sdk-core" "git+https://github.com/etcswap/sdk-core.git#etcswap" + "@uniswap/swap-router-contracts" "^1.3.0" + "@uniswap/token-lists" "^1.0.0-beta.31" + "@uniswap/universal-router" "^1.0.1" + "@uniswap/universal-router-sdk" "git+https://github.com/etcswap/universal-router-sdk.git#etcswap" + "@uniswap/v2-sdk" "git+https://github.com/etcswap/v2-sdk.git#etcswap" + "@uniswap/v3-sdk" "git+https://github.com/etcswap/v3-sdk.git#etcswap" + async-retry "^1.3.1" + await-timeout "^1.1.1" + axios "^0.21.1" + bunyan "^1.8.15" + bunyan-blackhole "^1.1.1" + ethers "^5.7.2" + graphql "^15.5.0" + graphql-request "^3.4.0" + lodash "^4.17.21" + mnemonist "^0.38.3" + node-cache "^5.1.2" + stats-lite "^2.2.0" + "@aashutoshrathi/word-wrap@^1.2.3": version "1.2.6" resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" @@ -1743,7 +1771,7 @@ "@ethersproject-xdc/properties" "file:../Library/Caches/Yarn/v6/npm-@ethersproject-xdc-wordlists-5.7.0-df2eb78a-b14e-4a1a-802f-a6fb42448b37-1727142683604/node_modules/@ethersproject-xdc/properties" "@ethersproject-xdc/strings" "file:../Library/Caches/Yarn/v6/npm-@ethersproject-xdc-wordlists-5.7.0-df2eb78a-b14e-4a1a-802f-a6fb42448b37-1727142683604/node_modules/@ethersproject-xdc/strings" -"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.1.2", "@ethersproject/abi@^5.4.0", "@ethersproject/abi@^5.5.0", "@ethersproject/abi@^5.6.3", "@ethersproject/abi@^5.7.0": +"@ethersproject/abi@5.7.0", "@ethersproject/abi@^5.0.12", "@ethersproject/abi@^5.1.2", "@ethersproject/abi@^5.4.0", "@ethersproject/abi@^5.5.0", "@ethersproject/abi@^5.6.3", "@ethersproject/abi@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.7.0.tgz#b3f3e045bbbeed1af3947335c247ad625a44e449" integrity sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA== @@ -1782,7 +1810,7 @@ "@ethersproject/logger" "^5.7.0" "@ethersproject/properties" "^5.7.0" -"@ethersproject/address@5.7.0", "@ethersproject/address@^5.0.2", "@ethersproject/address@^5.4.0", "@ethersproject/address@^5.7.0": +"@ethersproject/address@5.7.0", "@ethersproject/address@^5.0.0", "@ethersproject/address@^5.0.2", "@ethersproject/address@^5.4.0", "@ethersproject/address@^5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.7.0.tgz#19b56c4d74a3b0a46bfdbb6cfcc0a153fc697f37" integrity sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA== @@ -2046,7 +2074,7 @@ elliptic "6.5.4" hash.js "1.1.7" -"@ethersproject/solidity@5.7.0", "@ethersproject/solidity@^5.0.9", "@ethersproject/solidity@^5.4.0": +"@ethersproject/solidity@5.7.0", "@ethersproject/solidity@^5.0.0", "@ethersproject/solidity@^5.0.9", "@ethersproject/solidity@^5.4.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/solidity/-/solidity-5.7.0.tgz#5e9c911d8a2acce2a5ebb48a5e2e0af20b631cb8" integrity sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA== @@ -4921,7 +4949,7 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@uniswap/default-token-list@^11.13.0": +"@uniswap/default-token-list@^11.13.0", "@uniswap/default-token-list@^11.2.0": version "11.19.0" resolved "https://registry.yarnpkg.com/@uniswap/default-token-list/-/default-token-list-11.19.0.tgz#12d4e40f6c79f794d3e3a71e2d4d9784fb6c967b" integrity sha512-H/YLpxeZUrzT4Ki8mi4k5UiadREiLHg7WUqCv0Qt/VkOjX2mIBhrxCj1Wh61/J7lK0XqOjksfpm6RG1+YErPoQ== @@ -4931,7 +4959,7 @@ resolved "https://registry.yarnpkg.com/@uniswap/lib/-/lib-4.0.1-alpha.tgz#2881008e55f075344675b3bca93f020b028fbd02" integrity sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA== -"@uniswap/permit2-sdk@^1.3.0": +"@uniswap/permit2-sdk@^1.2.0", "@uniswap/permit2-sdk@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@uniswap/permit2-sdk/-/permit2-sdk-1.3.0.tgz#b54124e570f0adbaca9d39b2de3054fd7d3798a1" integrity sha512-LstYQWP47dwpQrgqBJ+ysFstne9LgI5FGiKHc2ewjj91MTY8Mq1reocu6U/VDncdR5ef30TUOcZ7gPExRY8r6Q== @@ -4939,18 +4967,41 @@ ethers "^5.7.0" tiny-invariant "^1.1.0" -"@uniswap/router-sdk@^1.9.2", "@uniswap/router-sdk@^1.9.3": - version "1.9.3" - resolved "https://registry.yarnpkg.com/@uniswap/router-sdk/-/router-sdk-1.9.3.tgz#0721d1d5eadf220632b062ec34044eadababdd6c" - integrity sha512-vKhYDN+Ne8XLFay97pW3FyMJbmbS4eiQfiTVpv7EblDKUYG2Co0OSaH+kPAuXcvHvcflbyBpp94NCyePjlVltw== +"@uniswap/router-sdk@^1.10.0": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@uniswap/router-sdk/-/router-sdk-1.11.1.tgz#bf3553e10021c2a6d9c87cbd8e3324d0cee52822" + integrity sha512-Nz0h5YUhEUusZ06C8nWIjQssvwche1dxtVY7EEx9eplgQqB9flGHJ8hxqnpEx+h1GB78bo7Qt/aFIiEVL28R8Q== dependencies: "@ethersproject/abi" "^5.5.0" "@uniswap/sdk-core" "^5.3.1" "@uniswap/swap-router-contracts" "^1.3.0" "@uniswap/v2-sdk" "^4.3.2" "@uniswap/v3-sdk" "^3.11.2" + "@uniswap/v4-sdk" "^1.0.0" -"@uniswap/sdk-core@^5.3.0", "@uniswap/sdk-core@^5.3.1": +"@uniswap/router-sdk@git+https://github.com/etcswap/router-sdk.git#etcswap": + version "1.6.0" + resolved "git+https://github.com/etcswap/router-sdk.git#942b98a36bad29ee7fe4ca362f4d02062dc62c44" + dependencies: + "@ethersproject/abi" "^5.5.0" + "@uniswap/sdk-core" "git+https://github.com/etcswap/sdk-core.git#etcswap" + "@uniswap/swap-router-contracts" "git+https://github.com/etcswap/swap-router-contracts.git#etcswap" + "@uniswap/v2-sdk" "^3.2.0" + "@uniswap/v3-sdk" "git+https://github.com/etcswap/v3-sdk.git#etcswap" + +"@uniswap/sdk-core@^4.0.7": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@uniswap/sdk-core/-/sdk-core-4.2.1.tgz#7b8c6fee48446bb67a4e6f2e9cb94c862034a6c3" + integrity sha512-hr7vwYrXScg+V8/rRc2UL/Ixc/p0P7yqe4D/OxzUdMRYr8RZd+8z5Iu9+WembjZT/DCdbTjde6lsph4Og0n1BQ== + dependencies: + "@ethersproject/address" "^5.0.2" + big.js "^5.2.2" + decimal.js-light "^2.5.0" + jsbi "^3.1.4" + tiny-invariant "^1.1.0" + toformat "^2.0.0" + +"@uniswap/sdk-core@^5.0.0", "@uniswap/sdk-core@^5.3.0", "@uniswap/sdk-core@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@uniswap/sdk-core/-/sdk-core-5.3.1.tgz#22d753e9ef8666c2f3b4d9a89b9658d169be4ce8" integrity sha512-XLJY8PcMZnKYBGLABJnLXcr3EgWql3mmnmpHyV1/MmEh9pLJLHYz4HLwVHb8pGDCqpOFX0e+Ei44/qhC7Q5Dsg== @@ -4965,6 +5016,17 @@ tiny-invariant "^1.1.0" toformat "^2.0.0" +"@uniswap/sdk-core@git+https://github.com/etcswap/sdk-core.git#etcswap": + version "4.0.10" + resolved "git+https://github.com/etcswap/sdk-core.git#af7b64fd4dfb0b1de5375ffd3aed6c152b726331" + dependencies: + "@ethersproject/address" "^5.0.2" + big.js "^5.2.2" + decimal.js-light "^2.5.0" + jsbi "^3.1.4" + tiny-invariant "^1.1.0" + toformat "^2.0.0" + "@uniswap/sdk@3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@uniswap/sdk/-/sdk-3.0.3.tgz#8201c7c72215d0030cb99acc7e661eff895c18a9" @@ -4978,23 +5040,24 @@ tiny-warning "^1.0.3" toformat "^2.0.0" -"@uniswap/smart-order-router@^3.39.0": - version "3.39.0" - resolved "https://registry.yarnpkg.com/@uniswap/smart-order-router/-/smart-order-router-3.39.0.tgz#83fe53e66d3bb9442f1e7049100381298655f129" - integrity sha512-6PHMeJvXp7lpJvX4rE66ofHIJa/OB0s+TSQ802qu7cljj7E0SRDG/QAi3WBXIX3QlTyn1pp4Yvkqk7crtMRkgw== +"@uniswap/smart-order-router@^3.46.1": + version "3.46.2" + resolved "https://registry.yarnpkg.com/@uniswap/smart-order-router/-/smart-order-router-3.46.2.tgz#25923a8652d62cc13b871754c4eba53e1997863d" + integrity sha512-4UoQ3r6dlVaZc+RQzRBk9smzeUqqAbUUO8IafrW0wlZGNazhvZw/yJS0STu5exheDZ+YNwqewnmUAukKG2XB1Q== dependencies: "@eth-optimism/sdk" "^3.2.2" "@types/brotli" "^1.3.4" "@uniswap/default-token-list" "^11.13.0" "@uniswap/permit2-sdk" "^1.3.0" - "@uniswap/router-sdk" "^1.9.2" + "@uniswap/router-sdk" "^1.10.0" "@uniswap/sdk-core" "^5.3.0" "@uniswap/swap-router-contracts" "^1.3.1" "@uniswap/token-lists" "^1.0.0-beta.31" "@uniswap/universal-router" "^1.6.0" - "@uniswap/universal-router-sdk" "^2.2.0" + "@uniswap/universal-router-sdk" "^2.2.4" "@uniswap/v2-sdk" "^4.3.2" "@uniswap/v3-sdk" "^3.13.0" + "@uniswap/v4-sdk" "^1.0.0" async-retry "^1.3.1" await-timeout "^1.1.1" axios "^0.21.1" @@ -5033,26 +5096,59 @@ dotenv "^14.2.0" hardhat-watcher "^2.1.1" +"@uniswap/swap-router-contracts@git+https://github.com/etcswap/swap-router-contracts.git#etcswap": + version "1.1.0" + resolved "git+https://github.com/etcswap/swap-router-contracts.git#d02515054efae1c8c1c8843b437138cb7966ca2f" + dependencies: + "@openzeppelin/contracts" "3.4.1-solc-0.7-2" + "@uniswap/v2-core" "1.0.1" + "@uniswap/v3-core" "git+https://github.com/etcswap/v3-core.git#etcswap" + "@uniswap/v3-periphery" "git+https://github.com/etcswap/v3-periphery.git#etcswap" + hardhat-watcher "^2.1.1" + "@uniswap/token-lists@^1.0.0-beta.31": version "1.0.0-beta.34" resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.34.tgz#879461f5d4009327a24259bbab797e0f22db58c8" integrity sha512-Hc3TfrFaupg0M84e/Zv7BoF+fmMWDV15mZ5s8ZQt2qZxUcNw2GQW+L6L/2k74who31G+p1m3GRYbJpAo7d1pqA== -"@uniswap/universal-router-sdk@^2.2.0": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@uniswap/universal-router-sdk/-/universal-router-sdk-2.2.2.tgz#1199b9bf492f496a17175c7aaadbb92c67845790" - integrity sha512-RYW2d+NlAjZJ1ZpJTPTXGgGlyBHnXShNbRkz5ueP3m0CzRAS+1P9Czub1SO8ZgcbZ/y4Po/SW9JXT/j3gnI/XA== +"@uniswap/universal-router-sdk@^2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@uniswap/universal-router-sdk/-/universal-router-sdk-2.2.4.tgz#28a7520791d991e5f12a46310f81bb09faac3433" + integrity sha512-6+ErgDDtCJLM2ro/krCKtu6ucUpcaQEEPRrAPuJiMTWbR0UyR+6Otp+KdBcT9LmyzSoXuSHhIRr+6s25no1J6A== dependencies: "@uniswap/permit2-sdk" "^1.3.0" - "@uniswap/router-sdk" "^1.9.3" + "@uniswap/router-sdk" "^1.10.0" "@uniswap/sdk-core" "^5.3.1" "@uniswap/universal-router" "1.6.0" "@uniswap/v2-sdk" "^4.4.1" "@uniswap/v3-sdk" "^3.13.1" + "@uniswap/v4-sdk" "^1.0.0" bignumber.js "^9.0.2" ethers "^5.7.0" -"@uniswap/universal-router@1.6.0", "@uniswap/universal-router@^1.6.0": +"@uniswap/universal-router-sdk@git+https://github.com/etcswap/universal-router-sdk.git#etcswap": + version "2.0.1" + resolved "git+https://github.com/etcswap/universal-router-sdk.git#e50ac7aa92ae651dfa462e769133e55dc547253a" + dependencies: + "@uniswap/permit2-sdk" "^1.2.0" + "@uniswap/router-sdk" "git+https://github.com/etcswap/router-sdk.git#etcswap" + "@uniswap/sdk-core" "git+https://github.com/etcswap/sdk-core.git#etcswap" + "@uniswap/universal-router" "1.5.1" + "@uniswap/v2-sdk" "^3.2.0" + "@uniswap/v3-sdk" "git+https://github.com/etcswap/v3-sdk.git#etcswap" + bignumber.js "^9.0.2" + ethers "^5.3.1" + +"@uniswap/universal-router@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@uniswap/universal-router/-/universal-router-1.5.1.tgz#2ce832485eb85093b0cb94a53be20661e1aece70" + integrity sha512-+htTC/nHQXKfY/c+9C1XHMRs7Jz0bX9LQfYn9Hb7WZKZ/YHWhOsCZQylYhksieLYTRam5sQheow747hOZ+QpZQ== + dependencies: + "@openzeppelin/contracts" "4.7.0" + "@uniswap/v2-core" "1.0.1" + "@uniswap/v3-core" "1.0.0" + +"@uniswap/universal-router@1.6.0", "@uniswap/universal-router@^1.0.1", "@uniswap/universal-router@^1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@uniswap/universal-router/-/universal-router-1.6.0.tgz#3d7372e98a0303c70587802ee6841b8b6b42fc6f" integrity sha512-Gt0b0rtMV1vSrgXY3vz5R1RCZENB+rOkbOidY9GvcXrK1MstSrQSOAc+FCr8FSgsDhmRAdft0lk5YUxtM9i9Lg== @@ -5066,6 +5162,17 @@ resolved "https://registry.yarnpkg.com/@uniswap/v2-core/-/v2-core-1.0.1.tgz#af8f508bf183204779938969e2e54043e147d425" integrity sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q== +"@uniswap/v2-sdk@^3.2.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@uniswap/v2-sdk/-/v2-sdk-3.3.0.tgz#76c95d234fe73ca6ad34ba9509f7451955ee0ce7" + integrity sha512-cf5PjoNQN5tNELIOVJsqV4/VeuDtxFw6Zl8oFmFJ6PNoQ8sx+XnGoO0aGKTB/o5II3oQ7820xtY3k47UsXgd6A== + dependencies: + "@ethersproject/address" "^5.0.0" + "@ethersproject/solidity" "^5.0.0" + "@uniswap/sdk-core" "^4.0.7" + tiny-invariant "^1.1.0" + tiny-warning "^1.0.3" + "@uniswap/v2-sdk@^4.3.2", "@uniswap/v2-sdk@^4.4.1": version "4.4.1" resolved "https://registry.yarnpkg.com/@uniswap/v2-sdk/-/v2-sdk-4.4.1.tgz#d0859a2d943cfcf66ec3cd48c2019e393af256a1" @@ -5077,15 +5184,24 @@ tiny-invariant "^1.1.0" tiny-warning "^1.0.3" +"@uniswap/v2-sdk@git+https://github.com/etcswap/v2-sdk.git#etcswap": + version "4.2.2" + resolved "git+https://github.com/etcswap/v2-sdk.git#f466988787bae2f04a2daa6792df4ef75a90bbab" + dependencies: + "@ethersproject/address" "^5.0.0" + "@ethersproject/solidity" "^5.0.0" + "@uniswap/sdk-core" "git+https://github.com/etcswap/sdk-core.git#etcswap" + tiny-invariant "^1.1.0" + tiny-warning "^1.0.3" + "@uniswap/v3-core@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@uniswap/v3-core/-/v3-core-1.0.0.tgz#6c24adacc4c25dceee0ba3ca142b35adbd7e359d" integrity sha512-kSC4djMGKMHj7sLMYVnn61k9nu+lHjMIxgg9CDQT+s2QYLoA56GbSK9Oxr+qJXzzygbkrmuY6cwgP6cW2JXPFA== -"@uniswap/v3-core@^1.0.0", "@uniswap/v3-core@^1.0.1": +"@uniswap/v3-core@^1.0.0", "@uniswap/v3-core@^1.0.1", "@uniswap/v3-core@git+https://github.com/etcswap/v3-core.git#etcswap": version "1.0.1" - resolved "https://registry.yarnpkg.com/@uniswap/v3-core/-/v3-core-1.0.1.tgz#b6d2bdc6ba3c3fbd610bdc502395d86cd35264a0" - integrity sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ== + resolved "git+https://github.com/etcswap/v3-core.git#9200d0cbd1aca285004f53811c29674bc9c5e798" "@uniswap/v3-periphery@1.4.1": version "1.4.1" @@ -5110,17 +5226,30 @@ "@uniswap/v3-core" "1.0.0" base64-sol "1.0.1" -"@uniswap/v3-periphery@^1.4.4": +"@uniswap/v3-periphery@^1.4.4", "@uniswap/v3-periphery@git+https://github.com/etcswap/v3-periphery.git#etcswap": version "1.4.4" - resolved "https://registry.yarnpkg.com/@uniswap/v3-periphery/-/v3-periphery-1.4.4.tgz#d2756c23b69718173c5874f37fd4ad57d2f021b7" - integrity sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw== + resolved "git+https://github.com/etcswap/v3-periphery.git#b6ce8ef8a553597d89b700a604096cac1d0c9485" dependencies: "@openzeppelin/contracts" "3.4.2-solc-0.7" "@uniswap/lib" "^4.0.1-alpha" "@uniswap/v2-core" "^1.0.1" - "@uniswap/v3-core" "^1.0.0" + "@uniswap/v3-core" "git+https://github.com/etcswap/v3-core.git#etcswap" base64-sol "1.0.1" +"@uniswap/v3-sdk@3.12.0": + version "3.12.0" + resolved "https://registry.yarnpkg.com/@uniswap/v3-sdk/-/v3-sdk-3.12.0.tgz#2d819aa777578b747c880e0bc86a9718354140c5" + integrity sha512-mUCg9HLKl20h6W8+QtELqN/uaO47/KDSf+EOht+W3C6jt2eGuzSANqS2CY7i8MsAsnZ+MjPhmN+JTOIvf7azfA== + dependencies: + "@ethersproject/abi" "^5.5.0" + "@ethersproject/solidity" "^5.0.9" + "@uniswap/sdk-core" "^5.0.0" + "@uniswap/swap-router-contracts" "^1.3.0" + "@uniswap/v3-periphery" "^1.1.1" + "@uniswap/v3-staker" "1.0.0" + tiny-invariant "^1.1.0" + tiny-warning "^1.0.3" + "@uniswap/v3-sdk@^3.11.2", "@uniswap/v3-sdk@^3.13.0", "@uniswap/v3-sdk@^3.13.1": version "3.13.1" resolved "https://registry.yarnpkg.com/@uniswap/v3-sdk/-/v3-sdk-3.13.1.tgz#67421727b18bb9c449bdf3c92cf3d01530ff3f8f" @@ -5135,6 +5264,19 @@ tiny-invariant "^1.1.0" tiny-warning "^1.0.3" +"@uniswap/v3-sdk@git+https://github.com/etcswap/v3-sdk.git#etcswap": + version "3.10.1" + resolved "git+https://github.com/etcswap/v3-sdk.git#ea339f3d6d28814d02b2618b9a8ef045a40d5f7d" + dependencies: + "@ethersproject/abi" "^5.0.12" + "@ethersproject/solidity" "^5.0.9" + "@uniswap/sdk-core" "git+https://github.com/etcswap/sdk-core.git#etcswap" + "@uniswap/swap-router-contracts" "git+https://github.com/etcswap/swap-router-contracts.git#etcswap" + "@uniswap/v3-periphery" "git+https://github.com/etcswap/v3-periphery.git#etcswap" + "@uniswap/v3-staker" "git+https://github.com/etcswap/v3-staker.git#etcswap" + tiny-invariant "^1.1.0" + tiny-warning "^1.0.3" + "@uniswap/v3-staker@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@uniswap/v3-staker/-/v3-staker-1.0.0.tgz#9a6915ec980852479dfc903f50baf822ff8fa66e" @@ -5144,6 +5286,25 @@ "@uniswap/v3-core" "1.0.0" "@uniswap/v3-periphery" "^1.0.1" +"@uniswap/v3-staker@git+https://github.com/etcswap/v3-staker.git#etcswap": + version "1.0.2" + resolved "git+https://github.com/etcswap/v3-staker.git#615e09790d2ec9f2bc079756eb888a2bd2078be6" + dependencies: + "@openzeppelin/contracts" "3.4.1-solc-0.7-2" + "@uniswap/v3-core" "git+https://github.com/etcswap/v3-core.git#etcswap" + "@uniswap/v3-periphery" "git+https://github.com/etcswap/v3-periphery.git#etcswap" + +"@uniswap/v4-sdk@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@uniswap/v4-sdk/-/v4-sdk-1.0.0.tgz#0ea85dd48dec7d83eaa4ff96a347364f6b731317" + integrity sha512-zC4cfOY9pFA6PUOARvmkAndOR0r5yiAwwcaFBxOoZe2kXLoh5wGH3svDZCQ4ZLpiPOevUPl+NXXC/KCEErbw2g== + dependencies: + "@ethersproject/solidity" "^5.0.9" + "@uniswap/sdk-core" "^5.3.1" + "@uniswap/v3-sdk" "3.12.0" + tiny-invariant "^1.1.0" + tiny-warning "^1.0.3" + "@vespaiach/axios-fetch-adapter@github:ecadlabs/axios-fetch-adapter": version "0.3.1" resolved "https://codeload.github.com/ecadlabs/axios-fetch-adapter/tar.gz/167684f522e90343b9f3439d9a43ac571e2396f6" @@ -8252,7 +8413,7 @@ ethers@4.0.0-beta.3: uuid "2.0.1" xmlhttprequest "1.8.0" -ethers@5.7.2, ethers@^5.0.19, ethers@^5.6.1, ethers@^5.6.2, ethers@^5.7.0, ethers@^5.7.2: +ethers@5.7.2, ethers@^5.0.19, ethers@^5.3.1, ethers@^5.6.1, ethers@^5.6.2, ethers@^5.7.0, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg==