diff --git a/.eslintrc.js b/.eslintrc.js index 1ed68fee1c..3c8cce54a3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,7 +25,10 @@ module.exports = { "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" - ] + ], + rules: { + "@typescript-eslint/no-var-requires": 0 + } } ], settings: { diff --git a/package.json b/package.json index 960b9c9294..5e994a2927 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ } }, "lint-staged": { - "*.js": "eslint --cache --fix" + "*.{js,ts,tsx}": "eslint --cache --fix" }, "workspaces": [ "packages/*" diff --git a/packages/core/contracts/trader/uniswap-broker/UniswapBroker.sol b/packages/core/contracts/trader/uniswap-broker/UniswapBroker.sol index d811dc8e45..2b27f42dd0 100644 --- a/packages/core/contracts/trader/uniswap-broker/UniswapBroker.sol +++ b/packages/core/contracts/trader/uniswap-broker/UniswapBroker.sol @@ -23,57 +23,56 @@ contract UniswapBroker { * possible to the truePrice. * @dev True price is expressed in the ratio of token A to token B. * @dev The caller must approve this contract to spend whichever token is intended to be swapped. + * @param tradingAsEOA bool to indicate if the UniswapBroker is being called by a DSProxy or an EOA. * @param uniswapRouter address of the uniswap router used to facilate trades. * @param uniswapFactory address of the uniswap factory used to fetch current pair reserves. - * @param tokenA address of the first token in the uniswap pair. - * @param tokenA address of the second token in the uniswap pair. - * @param truePriceTokenA the nominator of the true price. - * @param truePriceTokenB the denominatornominator of the true price. - * @param maxSpendTokenA maximum to spend in tokenA. Note can be set to zero, thereby limiting the direction of trade. - * @param maxSpendTokenA maximum to spend in tokenB. Note can be set to zero, thereby limiting the direction of trade. + * @param swappedTokens array of addresses which are to be swapped. The order does not matter as the function will figure + * out which tokens need to be exchanged to move the market to the desired "true" price. + * @param truePriceTokens array of unit used to represent the true price. 0th value is the numerator of the true price + * and the 1st value is the the denominator of the true price. + * @param maxSpendTokens array of unit to represent the max to spend in the two tokens. * @param to recipient of the trade proceeds. - * @param to deadline to limit when the trade can execute. If the tx is mined after this timestamp then revert. + * @param deadline to limit when the trade can execute. If the tx is mined after this timestamp then revert. */ - function swapToPrice( + bool tradingAsEOA, address uniswapRouter, address uniswapFactory, - address tokenA, - address tokenB, - uint256 truePriceTokenA, - uint256 truePriceTokenB, - uint256 maxSpendTokenA, - uint256 maxSpendTokenB, + address[2] memory swappedTokens, + uint256[2] memory truePriceTokens, + uint256[2] memory maxSpendTokens, address to, uint256 deadline ) public { IUniswapV2Router01 router = IUniswapV2Router01(uniswapRouter); // true price is expressed as a ratio, so both values must be non-zero - require(truePriceTokenA != 0 && truePriceTokenB != 0, "SwapToPrice: ZERO_PRICE"); + require(truePriceTokens[0] != 0 && truePriceTokens[1] != 0, "SwapToPrice: ZERO_PRICE"); // caller can specify 0 for either if they wish to swap in only one direction, but not both - require(maxSpendTokenA != 0 || maxSpendTokenB != 0, "SwapToPrice: ZERO_SPEND"); + require(maxSpendTokens[0] != 0 || maxSpendTokens[1] != 0, "SwapToPrice: ZERO_SPEND"); bool aToB; uint256 amountIn; { - (uint256 reserveA, uint256 reserveB) = getReserves(uniswapFactory, tokenA, tokenB); - (aToB, amountIn) = computeTradeToMoveMarket(truePriceTokenA, truePriceTokenB, reserveA, reserveB); + (uint256 reserveA, uint256 reserveB) = getReserves(uniswapFactory, swappedTokens[0], swappedTokens[1]); + (aToB, amountIn) = computeTradeToMoveMarket(truePriceTokens[0], truePriceTokens[1], reserveA, reserveB); } require(amountIn > 0, "SwapToPrice: ZERO_AMOUNT_IN"); // spend up to the allowance of the token in - uint256 maxSpend = aToB ? maxSpendTokenA : maxSpendTokenB; + uint256 maxSpend = aToB ? maxSpendTokens[0] : maxSpendTokens[1]; if (amountIn > maxSpend) { amountIn = maxSpend; } - address tokenIn = aToB ? tokenA : tokenB; - address tokenOut = aToB ? tokenB : tokenA; - TransferHelper.safeTransferFrom(tokenIn, msg.sender, address(this), amountIn); + address tokenIn = aToB ? swappedTokens[0] : swappedTokens[1]; + address tokenOut = aToB ? swappedTokens[1] : swappedTokens[0]; + TransferHelper.safeApprove(tokenIn, address(router), amountIn); + if (tradingAsEOA) TransferHelper.safeTransferFrom(tokenIn, msg.sender, address(this), amountIn); + address[] memory path = new address[](2); path[0] = tokenIn; path[1] = tokenOut; diff --git a/packages/core/test/trader/uniswap-broker/UniswapBroker.js b/packages/core/test/trader/uniswap-broker/UniswapBroker.js index e19b5c3b31..4a1e677555 100644 --- a/packages/core/test/trader/uniswap-broker/UniswapBroker.js +++ b/packages/core/test/trader/uniswap-broker/UniswapBroker.js @@ -1,9 +1,10 @@ const { MAX_UINT_VAL } = require("@uma/common"); const { toWei, toBN, fromWei } = web3.utils; const { getTruffleContract } = require("@uma/core"); +const truffleContract = require("@truffle/contract"); // Tested Contract -const UniswapBroker = artifacts.require("UniswapBroker"); +const UniswapBroker = getTruffleContract("UniswapBroker", web3); const Token = getTruffleContract("ExpandedERC20", web3); const WETH9 = getTruffleContract("WETH9", web3); @@ -22,8 +23,7 @@ let pairAddress; // Takes in a json object from a compiled contract and returns a truffle contract instance that can be deployed. const createContractObjectFromJson = contractJsonObject => { - const contract = require("@truffle/contract"); - let truffleContractCreator = contract(contractJsonObject); + let truffleContractCreator = truffleContract(contractJsonObject); truffleContractCreator.setProvider(web3.currentProvider); return truffleContractCreator; }; @@ -49,7 +49,7 @@ const getAmountOut = async (amountIn, aToB) => { contract("UniswapBroker", function(accounts) { const deployer = accounts[0]; - const trader = accounts[0]; + const trader = accounts[1]; before(async () => { const WETH = await WETH9.new(); // deploy Uniswap V2 Factory & router. @@ -72,8 +72,8 @@ contract("UniswapBroker", function(accounts) { await tokenA.mint(trader, toWei("100000000000000")); await tokenB.mint(trader, toWei("100000000000000")); - await tokenA.approve(router.address, toWei("100000000000000")); - await tokenB.approve(router.address, toWei("100000000000000")); + await tokenA.approve(router.address, toWei("100000000000000"), { from: trader }); + await tokenB.approve(router.address, toWei("100000000000000"), { from: trader }); await tokenA.approve(uniswapBroker.address, MAX_UINT_VAL, { from: trader }); await tokenB.approve(uniswapBroker.address, MAX_UINT_VAL, { from: trader }); @@ -147,14 +147,12 @@ contract("UniswapBroker", function(accounts) { // Now we can actually execute the swapToPrice method to ensure that the contract correctly modifies the spot price. await uniswapBroker.swapToPrice( + true, // The swap is being executed as an EOA. This ensures that the correct token transfers are done. router.address, factory.address, - tokenA.address, - tokenB.address, - "1000", // The "true" price of the pair is expressed as the ratio of token A to token B. A price of 1000 is simply 1000/1. - "1", - MAX_UINT_VAL, // Set to the max posable value as we want to let the broker trade as much as needed in this example. - MAX_UINT_VAL, + [tokenA.address, tokenB.address], + ["1000", "1"], // The "true" price of the pair is expressed as the ratio of token A to token B. A price of 1000 is simply 1000/1. + [MAX_UINT_VAL, MAX_UINT_VAL], // Set to the max posable value as we want to let the broker trade as much as needed in this example. trader, (await web3.eth.getBlock("latest")).timestamp + 10, { from: trader } @@ -221,14 +219,12 @@ contract("UniswapBroker", function(accounts) { // Now we can actually execute the swapToPrice method to ensure that the contract correctly modifies the spot price. await uniswapBroker.swapToPrice( + true, // The swap is being executed as an EOA. This ensures that the correct token transfers are done. router.address, factory.address, - tokenA.address, - tokenB.address, - "1000", // The "true" price of the pair is expressed as the ratio of token A to token B. A price of 1000 is simply 1000/1. - "1", - MAX_UINT_VAL, // Set to the max posable value as we want to let the broker trade as much as needed in this example. - MAX_UINT_VAL, + [tokenA.address, tokenB.address], + ["1000", "1"], // The "true" price of the pair is expressed as the ratio of token A to token B. A price of 1000 is simply 1000/1. + [MAX_UINT_VAL, MAX_UINT_VAL], // Set to the max posable value as we want to let the broker trade as much as needed in this example. trader, (await web3.eth.getBlock("latest")).timestamp + 10, { from: trader } diff --git a/packages/financial-templates-lib/index.js b/packages/financial-templates-lib/index.js index 1db817dd7f..91cc2a414e 100644 --- a/packages/financial-templates-lib/index.js +++ b/packages/financial-templates-lib/index.js @@ -8,9 +8,11 @@ module.exports = { ...require("./src/helpers/GasEstimator"), ...require("./src/logger/Logger"), ...require("./src/logger/SpyTransport"), + ...require("./src/price-feed/UniswapPriceFeed"), ...require("./src/price-feed/CreatePriceFeed"), ...require("./src/price-feed/Networker"), ...require("./src/price-feed/PriceFeedMock"), ...require("./src/price-feed/PriceFeedMockScaled"), - ...require("./src/price-feed/InvalidPriceFeedMock") + ...require("./src/price-feed/InvalidPriceFeedMock"), + ...require("./src/proxy-transaction-handler/DSProxyManager") }; diff --git a/packages/financial-templates-lib/src/price-feed/CreatePriceFeed.js b/packages/financial-templates-lib/src/price-feed/CreatePriceFeed.js index bee7d4765c..70b1d2f018 100644 --- a/packages/financial-templates-lib/src/price-feed/CreatePriceFeed.js +++ b/packages/financial-templates-lib/src/price-feed/CreatePriceFeed.js @@ -573,6 +573,12 @@ async function createUniswapPriceFeedForFinancialContract( const userConfig = config || {}; + // Check if there is an override for the getTime method in the price feed config. Specifically, we can replace the + // get time method with the current block time. + if (userConfig.getTimeOverride?.useBlockTime) { + getTime = async () => (await web3.eth.getBlock("latest")).timestamp; + } + logger.debug({ at: "createUniswapPriceFeedForFinancialContract", message: "Inferred default config from identifier or Financial Contract address", @@ -695,21 +701,20 @@ async function createReferencePriceFeedForFinancialContract( } // Check if there is an override for the getTime method in the price feed config. Specifically, we can replace the // get time method with the current block time. - if (combinedConfig.getTimeOverride) { - if (combinedConfig.getTimeOverride.useBlockTime) { - getTime = async () => - web3.eth.getBlock("latest").then(block => { - return block.timestamp; - }); - } + if (combinedConfig.getTimeOverride?.useBlockTime) { + getTime = async () => (await web3.eth.getBlock("latest")).timestamp; } } return await createPriceFeed(logger, web3, networker, getTime, combinedConfig); } function getFinancialContractIdentifierAtAddress(web3, financialContractAddress) { - const ExpiringMultiParty = getTruffleContract("ExpiringMultiParty", web3, "1.2.0"); - return new web3.eth.Contract(ExpiringMultiParty.abi, financialContractAddress); + try { + const ExpiringMultiParty = getTruffleContract("ExpiringMultiParty", web3, "1.2.0"); + return new web3.eth.Contract(ExpiringMultiParty.abi, financialContractAddress); + } catch (error) { + throw new Error({ message: "Something went wrong in fetching the financial contract identifier", error }); + } } module.exports = { diff --git a/packages/financial-templates-lib/src/proxy-transaction-handler/DSProxyManager.js b/packages/financial-templates-lib/src/proxy-transaction-handler/DSProxyManager.js index eb7c61ace6..969ee1b9dd 100644 --- a/packages/financial-templates-lib/src/proxy-transaction-handler/DSProxyManager.js +++ b/packages/financial-templates-lib/src/proxy-transaction-handler/DSProxyManager.js @@ -128,8 +128,8 @@ class DSProxyManager { this.logger.debug({ at: "DSProxyManager", message: "Executing function on library that will be deployed in the same transaction", - callCode, - callData + callData, + callCode }); const executeTransaction = await this.dsProxy.methods["execute(bytes,bytes)"](callCode, callData).send({ @@ -139,7 +139,7 @@ class DSProxyManager { this.logger.info({ at: "DSProxyManager", - message: "Executed function on a freshly minted library, created in the same tx 🤗", + message: "Executed function on a freshly deployed library, created in the same tx 🤗", callCodeHash: web3.utils.soliditySha3(callCode), callData, tx: executeTransaction.transactionHash diff --git a/packages/trader/package.json b/packages/trader/package.json index b09d2e7276..c65e721736 100644 --- a/packages/trader/package.json +++ b/packages/trader/package.json @@ -6,7 +6,10 @@ "dependencies": { "@uma/common": "^2.0.1", "async-retry": "^1.3.1", - "dotenv": "^8.2.0" + "dotenv": "^8.2.0", + "@uma/core": "2.0.1", + "@uma/financial-templates-lib": "^1.2.0", + "@umaprotocol/ynatm": "^0.0.1" }, "devDependencies": { "@nomiclabs/hardhat-truffle5": "^2.0.0", diff --git a/packages/trader/src/RangeTrader.ts b/packages/trader/src/RangeTrader.ts new file mode 100644 index 0000000000..6b0eed3e84 --- /dev/null +++ b/packages/trader/src/RangeTrader.ts @@ -0,0 +1,169 @@ +import winston from "winston"; +import Web3 from "web3"; +const { toWei, toBN } = Web3.utils; +const toBNWei = (number: string | number) => toBN(toWei(number.toString()).toString()); +import BigNumber from "bignumber.js"; +const ExchangeAdapterInterface = require("./exchange-adapters/ExchangeAdapterInterface"); + +const { ConvertDecimals, createFormatFunction, createObjectFromDefaultProps } = require("@uma/common"); +import assert from "assert"; + +export class RangeTrader { + readonly normalizePriceFeedDecimals: any; + readonly formatDecimalString: any; + + readonly tradeExecutionThreshold: any; + readonly targetPriceSpread: any; + readonly fixedPointAdjustment: any; + + constructor( + /** + * @notice Constructs new Range Trader. + * @param {Object} logger Module used to send logs. + * @param {Object} web3 Provider from Truffle/node to connect to Ethereum network. + * @param {Object} tokenPriceFeed Price feed to fetch the current synthetic token trading price. EG a Dex price feed. + * @param {Object} referencePriceFeed Price feed to fetch the "real" identifier price. EG a Cryptowatch price feed. + * @param {Object} exchangeAdapter Interface to interact with on-chain exchange. EG: Uniswap. + * @param {Object} rangeTraderConfig: Config to parameterize the range trader. Expected: + * { tradeExecutionThreshold: 0.2, -> error amount which must be exceeded for a correcting trade to be executed. + targetPriceSpread: 0.05 } -> target price that should be present after a correcting trade has concluded. + */ + readonly logger: winston.Logger, + readonly web3: Web3, + readonly tokenPriceFeed: any, + readonly referencePriceFeed: any, + readonly exchangeAdapter: typeof ExchangeAdapterInterface, + readonly rangeTraderConfig: { + tradeExecutionThreshold: number; + targetPriceSpread: number; + } + ) { + assert(tokenPriceFeed.getPriceFeedDecimals() === referencePriceFeed.getPriceFeedDecimals(), "decimals must match"); + + this.logger = logger; + this.web3 = web3; + this.tokenPriceFeed = tokenPriceFeed; + this.referencePriceFeed = referencePriceFeed; + this.exchangeAdapter = exchangeAdapter; + + this.normalizePriceFeedDecimals = ConvertDecimals(tokenPriceFeed.getPriceFeedDecimals(), 18, this.web3); + + // Formats an 18 decimal point string with a define number of decimals and precision for use in message generation. + this.formatDecimalString = createFormatFunction(this.web3, 2, 4, false); + + // Default config settings. + const defaultConfig = { + tradeExecutionThreshold: { + value: 0.2, + isValid: (x: number) => { + return x > 0; + } + }, + targetPriceSpread: { + value: 0.05, + isValid: (x: number) => { + return x > 0 && x <= 1; + } + } + }; + + // Validate and set config settings to class state. + const configWithDefaults = createObjectFromDefaultProps(rangeTraderConfig, defaultConfig); + Object.assign(this, configWithDefaults); + + this.fixedPointAdjustment = toBN(toWei("1")); + } + + async checkRangeMovementsAndTrade() { + this.logger.debug({ + at: "RangeTrader", + message: "Checking if the priceFeed error exceeds the threshold", + tradeExecutionThreshold: this.tradeExecutionThreshold, + targetPriceSpread: this.targetPriceSpread + }); + const currentTokenPrice = this.tokenPriceFeed.getCurrentPrice(); + const currentReferencePrice = this.referencePriceFeed.getCurrentPrice(); + + if (!currentTokenPrice || !currentReferencePrice) { + this.logger.warn({ + at: "RangeTrader", + message: "Failed to get either the currentTokenPrice or the currentReferencePrice!", + currentTokenPrice: currentTokenPrice ? currentTokenPrice.toString() : "no data returned", + currentReferencePrice: currentReferencePrice ? currentReferencePrice.toString() : "no data returned" + }); + return; + } + const deviationError = this._calculateDeviationError(currentTokenPrice, currentReferencePrice); + + const commonLogObject = { + tradeExecutionThreshold: this.tradeExecutionThreshold * 100 + "%", + targetPriceSpread: this.targetPriceSpread * 100 + "%", + preTradeTokenPrice: this.formatDecimalString(this.normalizePriceFeedDecimals(currentTokenPrice)), + preTradeReferencePrice: this.formatDecimalString(this.normalizePriceFeedDecimals(currentReferencePrice)), + preTradePriceDeviation: this.formatDecimalString(deviationError.muln(100)) + "%" + }; + // If the deviation error is less then the threshold, then log and return. Else, enter trade execution logic. + if (deviationError.abs().lt(toBNWei(this.tradeExecutionThreshold))) { + this.logger.debug({ + at: "RangeTrader", + message: "The deviationError is less than the threshold to execute a trade", + ...commonLogObject + }); + return; + } + // Calculate the desired deviation off from the targetPrice feed, as a scalar quantity. If deviationError > 0 then + // scalar = targetPriceSpread + 1. For example, if the traded price of a token is 1250 with a "true" price of 1000 + // then the deviation error is δ = (observed - expected) / expected = (1250 - 1000) / 1000 = 0.25. + // As the error is positive (and larger than the threshold) the scalar = 1 + 0.05 = 1.05. The bot will therefore + // try to trade the price down to 1.05x the desired price, or 1050. Similarly, if deviationError < 0 then + // scalar = targetPriceSpread - 1. If the synthetic was trading at 800 then δ = (750 - 1000) / 1000 = -0.25 then the + // the scalar = 1 - 0.05 = 0.95. Therefore the bot will trade the price up to 950. + const priceScalar = deviationError.gte(toBN("0")) ? 1 + this.targetPriceSpread : 1 - this.targetPriceSpread; + + const desiredPrice = currentReferencePrice.mul(toBNWei(priceScalar)).div(this.fixedPointAdjustment); + + this.logger.debug({ + at: "RangeTrader", + message: "The deviationError is greater than the threshold to execute a trade. Executing a correcting trade", + ...commonLogObject, + priceScalar, + desiredPrice: this.formatDecimalString(this.normalizePriceFeedDecimals(desiredPrice)) + }); + + const tradeExecutionTransaction = await this.exchangeAdapter.tradeMarketToDesiredPrice(desiredPrice.toString()); + if (tradeExecutionTransaction instanceof Error) { + this.logger.error({ + at: "RangeTrader", + message: "The exchange adapter returned an error in execution", + ...commonLogObject, + error: tradeExecutionTransaction + }); + return; + } + const exchangeSpotPriceAfterTrade = await this.exchangeAdapter.getExchangeSpotPrice(); + + const postTradePriceDeviationError = this._calculateDeviationError( + exchangeSpotPriceAfterTrade, + currentReferencePrice + ); + + this.logger.info({ + at: "RangeTrader", + message: "The exchange adapter has executed a trade successfully 👉 👈", + ...commonLogObject, + postTradeSpotPrice: this.formatDecimalString(exchangeSpotPriceAfterTrade), + postTradePriceDeviationError: this.formatDecimalString(postTradePriceDeviationError.muln(100)) + "%", + tx: tradeExecutionTransaction.transactionHash + }); + } + + // TODO: this method was taken from the SyntheticPegMonitor verbatim. Ideally it should be refactored into a common utility that both can use. + _calculateDeviationError(observedValue: BigNumber, expectedValue: BigNumber) { + return this.normalizePriceFeedDecimals(observedValue) + .sub(this.normalizePriceFeedDecimals(expectedValue)) + .mul(this.fixedPointAdjustment) // Scale the numerator before division + .div(this.normalizePriceFeedDecimals(expectedValue)); + } +} + +module.exports = { RangeTrader }; diff --git a/packages/trader/src/TraderConfig.ts b/packages/trader/src/TraderConfig.ts index 6aab58671d..e25bcd46d0 100644 --- a/packages/trader/src/TraderConfig.ts +++ b/packages/trader/src/TraderConfig.ts @@ -7,10 +7,37 @@ export interface ProcessEnv { export class TraderConfig { readonly financialContractAddress: string; + readonly dsProxyFactoryAddress: string | null; + + readonly pollingDelay: number; + readonly errorRetries: number; + readonly errorRetriesTimeout: number; + + readonly tokenPriceFeedConfig: any; + readonly referencePriceFeedConfig: any; + readonly exchangeAdapterConfig: any; constructor(env: ProcessEnv) { - const { EMP_ADDRESS } = env; + const { + EMP_ADDRESS, + TOKEN_PRICE_FEED_CONFIG, + POLLING_DELAY, + ERROR_RETRIES, + ERROR_RETRIES_TIMEOUT, + DS_PROXY_FACTORY_ADDRESS, + REFERENCE_PRICE_FEED_CONFIG, + EXCHANGE_ADAPTER_CONFIG + } = env; assert(EMP_ADDRESS, "EMP_ADDRESS required"); this.financialContractAddress = Web3.utils.toChecksumAddress(EMP_ADDRESS); + this.dsProxyFactoryAddress = DS_PROXY_FACTORY_ADDRESS + ? Web3.utils.toChecksumAddress(DS_PROXY_FACTORY_ADDRESS) + : null; + this.pollingDelay = POLLING_DELAY ? Number(POLLING_DELAY) : 60; + this.errorRetries = ERROR_RETRIES ? Number(ERROR_RETRIES) : 3; + this.errorRetriesTimeout = ERROR_RETRIES_TIMEOUT ? Number(ERROR_RETRIES_TIMEOUT) : 1; + this.tokenPriceFeedConfig = TOKEN_PRICE_FEED_CONFIG ? JSON.parse(TOKEN_PRICE_FEED_CONFIG) : null; + this.referencePriceFeedConfig = REFERENCE_PRICE_FEED_CONFIG ? JSON.parse(REFERENCE_PRICE_FEED_CONFIG) : null; + this.exchangeAdapterConfig = EXCHANGE_ADAPTER_CONFIG ? JSON.parse(EXCHANGE_ADAPTER_CONFIG) : null; } } diff --git a/packages/trader/src/exchange-adapters/CreateExchangeAdapter.ts b/packages/trader/src/exchange-adapters/CreateExchangeAdapter.ts new file mode 100644 index 0000000000..72a9ddaabc --- /dev/null +++ b/packages/trader/src/exchange-adapters/CreateExchangeAdapter.ts @@ -0,0 +1,50 @@ +import winston from "winston"; +import Web3 from "web3"; + +const assert = require("assert"); +const { UniswapTrader } = require("./UniswapTrader"); + +async function createExchangeAdapter(logger: winston.Logger, web3: Web3, dsProxyManager: any, config: any) { + assert(config.type, "Exchange adapter must have a type. EG uniswap for a uniswap dex"); + + if (config.type === "uniswap") { + const requiredFields = ["tokenAAddress", "tokenBAddress"]; + if (isMissingField(config, requiredFields, logger)) return null; + + // TODO: refactor these to be pulled from a constants file somewhere. + const uniswapRouterAddress = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"; + const uniswapFactoryAddress = "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"; + config = { uniswapRouterAddress, uniswapFactoryAddress, ...config }; + + return new UniswapTrader( + logger, + web3, + config.uniswapRouterAddress, + config.uniswapFactoryAddress, + config.tokenAAddress, + config.tokenBAddress, + dsProxyManager + ); + } + return null; +} + +// TODO: this method was taken verbatim from the create price feed class. it should be refactored to a common util. +function isMissingField(config: { [key: string]: string }, requiredFields: Array, logger: winston.Logger) { + const missingField = requiredFields.find(field => config[field] === undefined); + if (missingField !== undefined) { + logger.error({ + at: "createPriceFeed", + message: "Config is missing field🚨", + priceFeedType: config.type, + requiredFields, + missingField, + config + }); + return true; + } + + return false; +} + +module.exports = { createExchangeAdapter }; diff --git a/packages/trader/src/exchange-adapters/ExchangeAdapterInterface.ts b/packages/trader/src/exchange-adapters/ExchangeAdapterInterface.ts new file mode 100644 index 0000000000..8bf6d3c679 --- /dev/null +++ b/packages/trader/src/exchange-adapters/ExchangeAdapterInterface.ts @@ -0,0 +1,8 @@ +import BigNumber from "bignumber.js"; +export default interface ExchangeAdapterInterface { + // Take in a desired price and execute the trades required to move the market from the current price to desiredPrice. + tradeMarketToDesiredPrice(desiredPrice: BigNumber): void; + + // Returns the current spot price within the exchange. + getExchangeSpotPrice: () => BigNumber; +} diff --git a/packages/trader/src/exchange-adapters/UniswapTrader.ts b/packages/trader/src/exchange-adapters/UniswapTrader.ts new file mode 100644 index 0000000000..1a7e649c3c --- /dev/null +++ b/packages/trader/src/exchange-adapters/UniswapTrader.ts @@ -0,0 +1,87 @@ +import winston from "winston"; +import Web3 from "web3"; +import BigNumber from "bignumber.js"; +const truffleContract = require("@truffle/contract"); + +const { MAX_UINT_VAL } = require("@uma/common"); +const ExchangeAdapterInterface = require("./ExchangeAdapterInterface"); +const { getTruffleContract } = require("@uma/core"); + +const IUniswapV2Factory = require("@uniswap/v2-core/build/IUniswapV2Factory.json"); +const IUniswapV2Pair = require("@uniswap/v2-core/build/IUniswapV2Pair.json"); + +class UniswapTrader implements InstanceType { + readonly tradeDeadline: number; + readonly UniswapBroker: any; + uniswapPair: any; + + constructor( + readonly logger: winston.Logger, + readonly web3: Web3, + readonly uniswapRouterAddress: string, + readonly uniswapFactoryAddress: string, + readonly tokenAAddress: string, + readonly tokenBAddress: string, + readonly dsProxyManager: any + ) { + this.logger = logger; + this.web3 = web3; + this.uniswapRouterAddress = uniswapRouterAddress; + this.uniswapFactoryAddress = uniswapFactoryAddress; + this.tokenAAddress = tokenAAddress; + this.tokenBAddress = tokenBAddress; + this.dsProxyManager = dsProxyManager; + + // TODO: add this as a parameter when configuring the uniswap trader. + this.tradeDeadline = 10 * 60 * 60; + + this.UniswapBroker = getTruffleContract("UniswapBroker", this.web3); + } + async tradeMarketToDesiredPrice(desiredPrice: BigNumber) { + const callCode = this.UniswapBroker.bytecode; + + const contract = new this.web3.eth.Contract(this.UniswapBroker.abi); + + const callData = contract.methods + .swapToPrice( + false, // tradingAsEOA. Set as false as this is executed as a DSProxy. + this.uniswapRouterAddress, + this.uniswapFactoryAddress, + [this.tokenAAddress, this.tokenBAddress], // swappedTokens: The two exchanged + [desiredPrice.toString(), this.web3.utils.toWei("1").toString()], // truePriceTokens: ratio between these is the "true" price + [MAX_UINT_VAL, MAX_UINT_VAL], // maxSpendTokens: We dont want to limit how many tokens can be pulled. + this.dsProxyManager.getDSProxyAddress(), // to: the output of the trade will send the tokens to the DSProxy. + Number((await this.web3.eth.getBlock("latest")).timestamp) + this.tradeDeadline // Deadline in the future + ) + .encodeABI(); + + try { + return await this.dsProxyManager.callFunctionOnNewlyDeployedLibrary(callCode, callData); + } catch (error) { + return error; + } + } + + async getExchangeSpotPrice() { + if (!this.uniswapPair) { + const uniswapFactory = await this.createContractObjectFromJson(IUniswapV2Factory).at(this.uniswapFactoryAddress); + const pairAddress = await uniswapFactory.getPair(this.tokenBAddress, this.tokenAAddress); + this.uniswapPair = await this.createContractObjectFromJson(IUniswapV2Pair).at(pairAddress); + } + + const reserves = await this.uniswapPair?.getReserves(); + + return reserves.reserve1.mul(this.web3.utils.toBN(this.web3.utils.toWei("1"))).div(reserves.reserve0); + } + + // TODO: This method was pulled from the uniswapBroker tests. it should be refactored to work with generic implementations. + // potentially the getTruffleContract method should be modified to enable creation of truffle contracts from json + // as a fallback keeping the getter interface generic. + createContractObjectFromJson(contractJsonObject: { [key: string]: string }) { + const truffleContractCreator = truffleContract(contractJsonObject); + truffleContractCreator.setProvider(this.web3.currentProvider); + return truffleContractCreator; + } +} + +module.exports = { UniswapTrader }; diff --git a/packages/trader/src/index.ts b/packages/trader/src/index.ts index 2602d01795..b39f02f225 100755 --- a/packages/trader/src/index.ts +++ b/packages/trader/src/index.ts @@ -1,33 +1,140 @@ +import winston from "winston"; +import Web3 from "web3"; + import { config } from "dotenv"; -config(); + import retry from "async-retry"; +const { getWeb3 } = require("@uma/common"); +const { getAbi } = require("@uma/core"); +const { + GasEstimator, + Networker, + Logger, + createReferencePriceFeedForFinancialContract, + createTokenPriceFeedForFinancialContract, + waitForLogger, + delay, + DSProxyManager +} = require("@uma/financial-templates-lib"); + +const { RangeTrader } = require("./RangeTrader"); +const { createExchangeAdapter } = require("./exchange-adapters/CreateExchangeAdapter"); import { TraderConfig } from "./TraderConfig"; +config(); + +export async function run(logger: winston.Logger, web3: Web3): Promise { + try { + const getTime = () => Math.round(new Date().getTime() / 1000); + const config = new TraderConfig(process.env); + + // If pollingDelay === 0 then the bot is running in serverless mode and should send a `debug` level log. + // Else, if running in loop mode (pollingDelay != 0), then it should send a `info` level log. + logger[config.pollingDelay === 0 ? "debug" : "info"]({ + at: "Trader#index", + message: "Trader started 🚜", + financialContractAddress: config.financialContractAddress, + pollingDelay: config.pollingDelay, + errorRetries: config.errorRetries, + errorRetriesTimeout: config.errorRetriesTimeout, + tokenPriceFeedConfig: config.tokenPriceFeedConfig, + referencePriceFeedConfig: config.referencePriceFeedConfig, + exchangeAdapterConfig: config.exchangeAdapterConfig + }); + + // Load unlocked web3 accounts, get the networkId and set up price feed. + const networker = new Networker(logger); + const accounts = await web3.eth.getAccounts(); + + const gasEstimator = new GasEstimator(logger); + + const dsProxyManager = new DSProxyManager({ + logger, + web3, + gasEstimator, + account: accounts[0], + dsProxyFactoryAddress: config.dsProxyFactoryAddress, + dsProxyFactoryAbi: getAbi("DSProxyFactory"), + dsProxyAbi: getAbi("DSProxy") + }); + + await dsProxyManager.initializeDSProxy(); + const [tokenPriceFeed, referencePriceFeed, exchangeAdapter] = await Promise.all([ + createTokenPriceFeedForFinancialContract( + logger, + web3, + networker, + getTime, + config.financialContractAddress, + config.tokenPriceFeedConfig + ), + + createReferencePriceFeedForFinancialContract( + logger, + web3, + networker, + getTime, + config.financialContractAddress, + config.referencePriceFeedConfig + ), + createExchangeAdapter(logger, web3, dsProxyManager, config.exchangeAdapterConfig) + ]); + const rangeTrader = new RangeTrader(logger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter); + for (;;) { + await retry( + async () => { + // Update the price feeds & gasEstimator. + await Promise.all([tokenPriceFeed.update(), referencePriceFeed.update(), gasEstimator.update()]); -export async function run(): Promise { - // Config Processing - // const config = new TraderConfig(process.env); - await retry( - async () => { - // Trading logic here. - }, - { - retries: 3, - minTimeout: 5 * 1000, // delay between retries in ms - randomize: false, - onRetry: (error: Error, attempt: number) => { - console.log(error, attempt); + // Check if a trade should be done. If so, trade. + await rangeTrader.checkRangeMovementsAndTrade(); + }, + { + retries: config.errorRetries, + minTimeout: config.errorRetriesTimeout * 1000, // delay between retries in ms + randomize: false, + onRetry: error => { + logger.debug({ + at: "Trader#index", + message: "An error was thrown in the execution loop - retrying", + error: typeof error === "string" ? new Error(error) : error + }); + } + } + ); + // If the polling delay is set to 0 then the script will terminate the bot after one full run. + if (config.pollingDelay === 0) { + logger.debug({ + at: "Trader#index", + message: "End of serverless execution loop - terminating process" + }); + await waitForLogger(logger); + await delay(2); // waitForLogger does not always work 100% correctly in serverless. add a delay to ensure logs are captured upstream. + break; } + logger.debug({ + at: "Trader#index", + message: "End of execution loop - waiting polling delay", + pollingDelay: `${config.pollingDelay} (s)` + }); + await delay(Number(config.pollingDelay)); } - ); + } catch (error) { + // If any error is thrown, catch it and bubble up to the main try-catch for error processing in the Poll function. + throw typeof error === "string" ? new Error(error) : error; + } } if (require.main === module) { - run() + run(Logger, getWeb3()) .then(() => { process.exit(0); }) - .catch(err => { - console.error(err); + .catch(error => { + Logger.error({ + at: "Trader#index", + message: "Trader execution error🚨", + error: typeof error === "string" ? new Error(error) : error + }); process.exit(1); }); } diff --git a/packages/trader/src/tsconfig.json b/packages/trader/src/tsconfig.json index b9614148a1..0bf41b97d4 100644 --- a/packages/trader/src/tsconfig.json +++ b/packages/trader/src/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@tsconfig/node14/tsconfig.json", - "include": ["./**/*.ts"], + "include": ["./**/*.ts", "exchange-adapters/ExchangeAdapterInterface.ts"], "compilerOptions": { "outDir": "../dist", "rootDir": "../", diff --git a/packages/trader/test/RangeTrader.ts b/packages/trader/test/RangeTrader.ts new file mode 100644 index 0000000000..000e537d74 --- /dev/null +++ b/packages/trader/test/RangeTrader.ts @@ -0,0 +1,339 @@ +import { web3, assert } from "hardhat"; + +const { toWei, toBN, fromWei } = web3.utils; + +const { getTruffleContract } = require("@uma/core"); + +// Script to test +const { RangeTrader } = require("../src/RangeTrader"); + +// Helper scripts +const { createExchangeAdapter } = require("../src/exchange-adapters/CreateExchangeAdapter"); + +const UniswapBroker = getTruffleContract("UniswapBroker"); +const Token = getTruffleContract("ExpandedERC20", web3); +const WETH9 = getTruffleContract("WETH9", web3); +const DSProxyFactory = getTruffleContract("DSProxyFactory", web3, "latest"); +const DSProxy = getTruffleContract("DSProxy", web3, "latest"); + +// Helper Contracts +const UniswapV2Factory = require("@uniswap/v2-core/build/UniswapV2Factory.json"); +const IUniswapV2Pair = require("@uniswap/v2-core/build/IUniswapV2Pair.json"); +const UniswapV2Router02 = require("@uniswap/v2-periphery/build/UniswapV2Router02.json"); + +const winston = require("winston"); +const sinon = require("sinon"); +const { + SpyTransport, + spyLogIncludes, + PriceFeedMock, + DSProxyManager, + GasEstimator, + UniswapPriceFeed +} = require("@uma/financial-templates-lib"); + +let accounts: string[]; +let deployer: string; +let trader: string; +let externalTrader: string; +let traderDSProxyAddress: string; +let spyLogger: any; +let spy: any; +let gasEstimator: any; +let rangeTrader: any; +let tokenPriceFeed: any; +let referencePriceFeed: any; +let dsProxyManager: any; +let exchangeAdapter: any; +let mockTime: any = 0; + +let tokenA: any; +let tokenB: any; +let uniswapFactory: any; +let uniswapRouter: any; +let uniswapBroker: any; +let pair: any; +let pairAddress: any; +let WETH: any; +let dsProxyFactory: any; + +// Takes in a json object from a compiled contract and returns a truffle contract instance that can be deployed. +// TODO: these methods are taken from the UniswapBroker tests verbatim. they should be refactored to a common util that +// can be re-used between different uniswap tests. +const createContractObjectFromJson = (contractJsonObject: any) => { + const contract = require("@truffle/contract"); + const truffleContractCreator = contract(contractJsonObject); + truffleContractCreator.setProvider(web3.currentProvider); + return truffleContractCreator; +}; + +// Returns the current spot price of a uniswap pool, scaled to 4 decimal points. +const getPoolSpotPrice = async () => { + const poolTokenABallance = await tokenA.balanceOf(pairAddress); + const poolTokenBBallance = await tokenB.balanceOf(pairAddress); + return Number(fromWei(poolTokenABallance.mul(toBN(toWei("1"))).div(poolTokenBBallance))).toFixed(4); +}; + +describe("index.js", function() { + before(async function() { + accounts = await web3.eth.getAccounts(); + deployer = accounts[0]; + trader = accounts[1]; + externalTrader = accounts[2]; + + dsProxyFactory = await DSProxyFactory.new(); + + WETH = await WETH9.new(); + // deploy Uniswap V2 Factory & router. + uniswapFactory = await createContractObjectFromJson(UniswapV2Factory).new(deployer, { from: deployer }); + uniswapRouter = await createContractObjectFromJson(UniswapV2Router02).new(uniswapFactory.address, WETH.address, { + from: deployer + }); + + // create a uniswapBroker + uniswapBroker = await UniswapBroker.new(); + }); + + beforeEach(async function() { + // deploy traded tokens + tokenA = await Token.new("TokenA", "TA", 18); + tokenB = await Token.new("TokenB", "TB", 18); + + await tokenA.addMember(1, deployer, { from: deployer }); + await tokenB.addMember(1, deployer, { from: deployer }); + + // initialize the Uniswap pair + await uniswapFactory.createPair(tokenA.address, tokenB.address, { from: deployer }); + pairAddress = await uniswapFactory.getPair(tokenA.address, tokenB.address); + pair = await createContractObjectFromJson(IUniswapV2Pair).at(pairAddress); + + // Create a sinon spy and give it to the SpyTransport as the winston logger. Use this to check all winston logs. + spy = sinon.spy(); // Create a new spy for each test. + spyLogger = winston.createLogger({ + level: "debug", + transports: [new SpyTransport({ level: "debug" }, { spy: spy })] + }); + + // Create the components needed for the RangeTrader. Create a "real" uniswap price feed, with the twapLength & + // historicalLookback set to 1 such the the twap will update very quickly. + tokenPriceFeed = new UniswapPriceFeed( + spyLogger, + IUniswapV2Pair.abi, + Token.abi, + web3, + pairAddress, + 1, + 1, + () => mockTime, + false + ); + referencePriceFeed = new PriceFeedMock(undefined, undefined, undefined, 18); + + gasEstimator = new GasEstimator(spyLogger); + + dsProxyManager = new DSProxyManager({ + logger: spyLogger, + web3, + gasEstimator, + account: trader, + dsProxyFactoryAddress: dsProxyFactory.address, + dsProxyFactoryAbi: DSProxyFactory.abi, + dsProxyAbi: DSProxy.abi + }); + + // Deploy a new DSProxy + await dsProxyManager.initializeDSProxy(); + + traderDSProxyAddress = dsProxyManager.getDSProxyAddress(); + + const exchangeAdapterConfig = { + type: "uniswap", + tokenAAddress: tokenA.address, + tokenBAddress: tokenB.address, + uniswapRouterAddress: uniswapRouter.address, + uniswapFactoryAddress: uniswapFactory.address + }; + + exchangeAdapter = await createExchangeAdapter(spyLogger, web3, dsProxyManager, exchangeAdapterConfig); + + // run the tests with no configs provided. Defaults to tradeExecutionThreshold = 5% and targetPriceSpread =5% + rangeTrader = new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, null); + + // Seed the dsProxy wallet. + await tokenA.mint(traderDSProxyAddress, toWei("100000000000000")); + await tokenB.mint(traderDSProxyAddress, toWei("100000000000000")); + + // Seed the externalTrader who is used to move the market around. + await tokenA.mint(externalTrader, toWei("100000000000000")); + await tokenB.mint(externalTrader, toWei("100000000000000")); + await tokenA.approve(uniswapRouter.address, toWei("100000000000000"), { + from: externalTrader + }); + await tokenB.approve(uniswapRouter.address, toWei("100000000000000"), { + from: externalTrader + }); + + // For these test, say the synthetic starts trading at uniswap at 1000 TokenA/TokenB. To set this up we will seed the + // pair with 1000x units of TokenA, relative to TokenB. + await tokenA.mint(pairAddress, toBN(toWei("1000")).muln(10000000)); + await tokenB.mint(pairAddress, toBN(toWei("1")).muln(10000000)); + await pair.sync({ from: deployer }); + mockTime = Number((await web3.eth.getBlock("latest")).timestamp) + 1; + referencePriceFeed.setCurrentPrice(toWei("1000")); + await tokenPriceFeed.update(); + }); + + it("Correctly detects overpriced tokens and executes trades", async function() { + // The default behavior of the bot is to preform a trade if and only if the absolute error is greater than 20%. If + // it is then trade the error down to 5%. To start with, the tokenPriceFeed and referencePriceFeed should both + // equal 1000, due to the seeing, where no trading should be done as no error between the feeds. + assert.equal(await getPoolSpotPrice(), "1000.0000"); // price should be exactly 1000 TokenA/TokenB. + await tokenPriceFeed.update(); + assert.equal(tokenPriceFeed.getLastBlockPrice(), toWei("1000")); + assert.equal(tokenPriceFeed.getCurrentPrice(), toWei("1000")); + assert.equal(referencePriceFeed.getCurrentPrice(), toWei("1000")); + + let blockNumberBefore = await web3.eth.getBlockNumber(); + await rangeTrader.checkRangeMovementsAndTrade(); + assert.equal(await web3.eth.getBlockNumber(), blockNumberBefore); // the block number should be the same as no trades done. + assert.isTrue(spyLogIncludes(spy, -2, "Checking if the priceFeed error exceeds the threshold")); + assert.isTrue(spyLogIncludes(spy, -1, "less than the threshold to execute a trade")); + + // Next, assume someone trades the synthetic up to a price, thereby introducing an error in the price larger + // than the tradeExecutionThreshold. We should expect to see the bot: 1) log this accordingly 2) execute a trade via + // the DSProxy manager 3) the resultant price being equal to the desired targetPriceSpread. + + // For this trade, swap a large number of tokenA into the pool for token B. This should push up the price. A trade of + // 1.5 billion token B should increase the price by ~ 321 USD, resiting in a price of ~1351 + await uniswapRouter.swapExactTokensForTokens( + toBN(toWei("1500000000")), // amountIn. We are selling tokenA for tokenB, therefore tokenA is "in" and tokenB is "out" + 0, // amountOutMin + [tokenA.address, tokenB.address], // path. We are trading from tokenA to tokenB (selling A for B) + externalTrader, // recipient of the trade + Number((await web3.eth.getBlock("latest")).timestamp) + 10, // deadline + { from: externalTrader } + ); + + // Double check the market moved correctly and that the uniswap Price feed correctly reports the price. + const currentSpotPrice = Number(await getPoolSpotPrice()); + assert.isTrue(currentSpotPrice > 1320 && currentSpotPrice < 1325); + mockTime = Number((await web3.eth.getBlock("latest")).timestamp) + 1; + await tokenPriceFeed.update(); + assert.equal(Number(fromWei(tokenPriceFeed.getCurrentPrice())).toFixed(4), currentSpotPrice.toString()); + + // Next, execute the bot's trading method and ensure the trade is executed as expected. Note that the default + // config for the bot is to try and trade the price back to within 5% of the reference price. After this trade the + // spot price should be 1050. + blockNumberBefore = await web3.eth.getBlockNumber(); + await rangeTrader.checkRangeMovementsAndTrade(); + assert.equal(await web3.eth.getBlockNumber(), blockNumberBefore + 1); // The block number should have been incremented by 1 as a trade was done + + // Validate that the correct log messages were produced. + assert.isTrue(spyLogIncludes(spy, -5, "Checking if the priceFeed error exceeds the threshold")); + assert.isTrue(spyLogIncludes(spy, -4, "The deviationError is greater than the threshold to execute a trade")); + assert.isTrue(spyLogIncludes(spy, -3, "Executing function on library")); + assert.isTrue(spyLogIncludes(spy, -2, "Executed function on a freshly deployed library")); + assert.isTrue(spyLogIncludes(spy, -1, "The exchange adapter has executed a trade successfully")); + + // Validate the last message contains the right spot price and deviation error. + const latestWinstonLog = spy.getCall(-1).lastArg; + // The resultant spot price within the log message should correctly embed the expected price of 1050. + assert.equal(parseFloat(latestWinstonLog.postTradeSpotPrice.replace(",", "")).toFixed(0), "1050"); + // The postTradePriceDeviationError compares the final spot price with the desired reference price. The threshold is + // set to 5% by default and so we should expect this error to be ~5%. + assert.equal(parseFloat(latestWinstonLog.postTradePriceDeviationError.replace("%", "")).toFixed(0), "5"); + // The default configuration for the range trader bot is to trade the price back to 5% off the current reference price. + // Seeing the reference price is set to 1000, the pool price should now be set to 1050 exactly after the correcting trade. + assert.equal(Number(await getPoolSpotPrice()).toFixed(0), "1050"); + // Equally, the price in the uniswap feed should report a price of 1050. + mockTime = Number((await web3.eth.getBlock("latest")).timestamp) + 1; + await tokenPriceFeed.update(); + assert.equal(Number(fromWei(tokenPriceFeed.getLastBlockPrice())).toFixed(4), await getPoolSpotPrice()); + + // If the checkRangeMovementsAndTrade is called again no trade should occur as the deviation error is less than 20%. + blockNumberBefore = await web3.eth.getBlockNumber(); + await rangeTrader.checkRangeMovementsAndTrade(); + assert.equal(await web3.eth.getBlockNumber(), blockNumberBefore); // the block number should be the same as no trades done. + assert.isTrue(spyLogIncludes(spy, -2, "Checking if the priceFeed error exceeds the threshold")); + assert.isTrue(spyLogIncludes(spy, -1, "less than the threshold to execute a trade")); + }); + it("Correctly detects underpriced tokens and executes trades", async function() { + // This test is very similar to the previous one but instead of setting the synth to be overpriced we set to to + // underpriced. To get directly to the test case we can simply set the reference price feed to be greater than the + // synthetic dex price + the threshold. Any price for the reference feed over 1250 should trigger a trade as the % + // error is calculated using δ = (observed - expected) / expected where δ = (1000 - 1250) / 1250 = 0.2. If we set it + // to 1249 we should not execute a trade as the price is right below the execution threshold of 20%. + referencePriceFeed.setCurrentPrice(toWei("1249")); + let blockNumberBefore = await web3.eth.getBlockNumber(); + await rangeTrader.checkRangeMovementsAndTrade(); + assert.equal(await web3.eth.getBlockNumber(), blockNumberBefore); // The block number should not have incremented as no trade. + assert.isTrue(spyLogIncludes(spy, -2, "Checking if the priceFeed error exceeds the threshold")); + assert.isTrue(spyLogIncludes(spy, -1, "less than the threshold to execute a trade")); + + // However, a price of 1250 is exactly on the threshold and we should see a trade. + referencePriceFeed.setCurrentPrice(toWei("1250")); + blockNumberBefore = await web3.eth.getBlockNumber(); + await rangeTrader.checkRangeMovementsAndTrade(); + assert.equal(await web3.eth.getBlockNumber(), blockNumberBefore + 1); // The block number should have incremented due to the trade. + + // Validate that the correct log messages were produced. + assert.isTrue(spyLogIncludes(spy, -5, "Checking if the priceFeed error exceeds the threshold")); + assert.isTrue(spyLogIncludes(spy, -4, "The deviationError is greater than the threshold to execute a trade")); + assert.isTrue(spyLogIncludes(spy, -3, "Executing function on library")); + assert.isTrue(spyLogIncludes(spy, -2, "Executed function on a freshly deployed library")); + assert.isTrue(spyLogIncludes(spy, -1, "The exchange adapter has executed a trade successfully")); + + // The spot price should be set to 5% below the reference price feed as the bot was trading up from the previous number. + // This yields 1250*0.95 ~= 1187 as the expected market price. + assert.equal(Number(await getPoolSpotPrice()).toFixed(0), "1187"); + // Check that the resultant post Trade Price Deviation is -5%, as we should be 5% below the reference price after the trade. + assert.equal(parseFloat(spy.getCall(-1).lastArg.postTradePriceDeviationError.replace("%", "")).toFixed(0), "-5"); + }); + + it("Correctly rejects invalid config and params", async function() { + // tradeExecutionThreshold should only be strictly larger than 0. + + assert.throws(() => { + new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, { + tradeExecutionThreshold: -1 + }); + }); + assert.throws(() => { + new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, { + tradeExecutionThreshold: 0 + }); + }); + + // targetPriceSpread should only be larger than 0 and smaller than or equal to 1. + assert.throws(() => { + new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, { + targetPriceSpread: -1 + }); + }); + assert.throws(() => { + new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, { + targetPriceSpread: 0 + }); + }); + assert.throws(() => { + new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, { + targetPriceSpread: 1.1 + }); + }); + + // rejects inconsistent price feed decimals + const nonStandardDecimalPriceFeed = new PriceFeedMock(undefined, undefined, undefined, 17); + assert.throws(() => { + new RangeTrader(spyLogger, web3, nonStandardDecimalPriceFeed, referencePriceFeed, exchangeAdapter); + }); + }); + it("Correctly respects custom trade threshold configs", async function() { + const customRangeTrader = new RangeTrader(spyLogger, web3, tokenPriceFeed, referencePriceFeed, exchangeAdapter, { + tradeExecutionThreshold: 0.5, // Only trade if price greater than 50%. + targetPriceSpread: 0.2 // Trade price back to within 20% of the "true" price. + }); + + assert.equal(customRangeTrader.tradeExecutionThreshold, 0.5); + assert.equal(customRangeTrader.targetPriceSpread, 0.2); + }); +}); diff --git a/packages/trader/test/index.ts b/packages/trader/test/index.ts index 90875cad9c..ddfb86b939 100644 --- a/packages/trader/test/index.ts +++ b/packages/trader/test/index.ts @@ -1,23 +1,188 @@ import { run } from "../src/index"; import { web3, assert } from "hardhat"; +const { toWei, utf8ToHex, padRight } = web3.utils; +const { + MAX_UINT_VAL, + interfaceName, + addGlobalHardhatTestingAddress, + createConstructorParamsForContractVersion, + TESTED_CONTRACT_VERSIONS +} = require("@uma/common"); + +const { getTruffleContract } = require("@uma/core"); + +const winston = require("winston"); +const sinon = require("sinon"); +const { SpyTransport, spyLogLevel, spyLogIncludes, FinancialContractClient } = require("@uma/financial-templates-lib"); + +const contractVersion = "latest"; + describe("index.js", function() { let accounts: string[]; + let contractCreator: string; + let spyLogger: any; + let spy: any; + let collateralToken: any; + let syntheticToken: any; + let financialContract: any; + let uniswap: any; + let store: any; + let timer: any; + let mockOracle: any; + let finder: any; + let identifierWhitelist: any; + let configStore: any; + let collateralWhitelist: any; + let optimisticOracle: any; + let defaultPriceFeedConfig: any; + let constructorParams: any; + let dsProxyFactory: any; + + let originalEnv: any; + + const pollingDelay = 0; // 0 polling delay creates a serverless bot that yields after one full execution. + const errorRetries = 1; + const errorRetriesTimeout = 0.1; // 100 milliseconds between preforming retries + const identifier = "TEST_IDENTIFIER"; + const fundingRateIdentifier = "TEST_FUNDiNG_IDENTIFIER"; + + const FinancialContract = getTruffleContract("Perpetual", web3, contractVersion); + const Finder = getTruffleContract("Finder", web3, contractVersion); + const IdentifierWhitelist = getTruffleContract("IdentifierWhitelist", web3, contractVersion); + const AddressWhitelist = getTruffleContract("AddressWhitelist", web3, contractVersion); + const MockOracle = getTruffleContract("MockOracle", web3, contractVersion); + const Token = getTruffleContract("ExpandedERC20", web3, contractVersion); + const SyntheticToken = getTruffleContract("SyntheticToken", web3, contractVersion); + const Timer = getTruffleContract("Timer", web3, contractVersion); + const UniswapMock = getTruffleContract("UniswapMock", web3, contractVersion); + const Store = getTruffleContract("Store", web3, contractVersion); + const ConfigStore = getTruffleContract("ConfigStore", web3, contractVersion); + const OptimisticOracle = getTruffleContract("OptimisticOracle", web3, contractVersion); + const DSProxyFactory = getTruffleContract("DSProxyFactory", web3, "latest"); + after(async function() { + process.env = originalEnv; + }); before(async function() { + originalEnv = process.env; accounts = await web3.eth.getAccounts(); + const contractCreator = accounts[0]; + finder = await Finder.new(); + // Create identifier whitelist and register the price tracking ticker with it. + identifierWhitelist = await IdentifierWhitelist.new(); + await identifierWhitelist.addSupportedIdentifier(utf8ToHex(identifier)); + await finder.changeImplementationAddress( + web3.utils.utf8ToHex(interfaceName.IdentifierWhitelist), + identifierWhitelist.address + ); + + timer = await Timer.new(); + + mockOracle = await MockOracle.new(finder.address, timer.address, { + from: contractCreator + }); + await finder.changeImplementationAddress(utf8ToHex(interfaceName.Oracle), mockOracle.address); + // Set the address in the global name space to enable disputer's index.js to access it. + addGlobalHardhatTestingAddress("Voting", mockOracle.address); + + store = await Store.new({ rawValue: "0" }, { rawValue: "0" }, timer.address); + await finder.changeImplementationAddress(utf8ToHex(interfaceName.Store), store.address); + + // Make the contract creator the admin to enable emergencyshutdown in tests. + await finder.changeImplementationAddress(utf8ToHex(interfaceName.FinancialContractsAdmin), contractCreator); + + dsProxyFactory = await DSProxyFactory.new(); }); - it("Runs with no errors", async function() { - const originalEnv = process.env; - process.env.EMP_ADDRESS = web3.utils.randomHex(20); + beforeEach(async function() { + // Create a sinon spy and give it to the SpyTransport as the winston logger. Use this to check all winston logs. + spy = sinon.spy(); // Create a new spy for each test. + spyLogger = winston.createLogger({ + level: "debug", + transports: [new SpyTransport({ level: "debug" }, { spy: spy })] + }); - // Nonsensical check just to use assert. - assert.isAbove(accounts.length, 0); + // Create a new synthetic token & collateral token. + syntheticToken = await SyntheticToken.new("Test Synthetic Token", "SYNTH", 18, { from: contractCreator }); + collateralToken = await Token.new("Wrapped Ether", "WETH", 18, { from: contractCreator }); - // Must not throw. - await run(); + collateralWhitelist = await AddressWhitelist.new(); + await finder.changeImplementationAddress( + web3.utils.utf8ToHex(interfaceName.CollateralWhitelist), + collateralWhitelist.address + ); + await collateralWhitelist.addToWhitelist(collateralToken.address); - process.env = originalEnv; + configStore = await ConfigStore.new( + { + timelockLiveness: 86400, // 1 day + rewardRatePerSecond: { rawValue: "0" }, + proposerBondPercentage: { rawValue: "0" }, + maxFundingRate: { rawValue: toWei("0.00001") }, + minFundingRate: { rawValue: toWei("-0.00001") }, + proposalTimePastLimit: 0 + }, + timer.address + ); + + await identifierWhitelist.addSupportedIdentifier(padRight(utf8ToHex(fundingRateIdentifier), 32)); + optimisticOracle = await OptimisticOracle.new(7200, finder.address, timer.address); + await finder.changeImplementationAddress(utf8ToHex(interfaceName.OptimisticOracle), optimisticOracle.address); + + // Deploy a new expiring multi party OR perpetual. + constructorParams = await createConstructorParamsForContractVersion( + { contractType: "Perpetual", contractVersion: "latest" }, + { + convertSynthetic: toWei, // These tests do not use convertSynthetic. Override this with toWei + finder, + collateralToken, + syntheticToken, + identifier, + fundingRateIdentifier, + timer, + store, + configStore: configStore || {} // if the contract type is not a perp this will be null. + }, + { expirationTimestamp: (await timer.getCurrentTime()).toNumber() + 100 } // config override expiration time. + ); + financialContract = await FinancialContract.new(constructorParams); + await syntheticToken.addMinter(financialContract.address); + await syntheticToken.addBurner(financialContract.address); + + syntheticToken = await Token.at(await financialContract.tokenCurrency()); + + uniswap = await UniswapMock.new(); + await uniswap.setTokens(syntheticToken.address, collateralToken.address); + + defaultPriceFeedConfig = { + type: "uniswap", + uniswapAddress: uniswap.address, + twapLength: 1, + lookback: 1, + getTimeOverride: { useBlockTime: true } // enable tests to run in hardhat + }; + + // Set two uniswap prices to give it a little history. + await uniswap.setPrice(toWei("1"), toWei("1")); + await uniswap.setPrice(toWei("1"), toWei("1")); + await uniswap.setPrice(toWei("1"), toWei("1")); + await uniswap.setPrice(toWei("1"), toWei("1")); + }); + + it("Runs with no errors", async function() { + process.env.EMP_ADDRESS = financialContract.address; + process.env.REFERENCE_PRICE_FEED_CONFIG = JSON.stringify(defaultPriceFeedConfig); + process.env.TOKEN_PRICE_FEED_CONFIG = JSON.stringify(defaultPriceFeedConfig); + process.env.DS_PROXY_FACTORY_ADDRESS = dsProxyFactory.address; + process.env.EXCHANGE_ADAPTER_CONFIG = JSON.stringify({ + type: "uniswap", + tokenAAddress: syntheticToken.address, + tokenBAddress: collateralToken.address + }); + process.env.POLLING_DELAY = "0"; + + // Must not throw. + await run(spyLogger, web3); }); }); diff --git a/yarn.lock b/yarn.lock index 3a3bbd26fe..e9bb660df1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5190,7 +5190,7 @@ "@uma/common" "^1.1.0" "@uma/core-1-1-0" "npm:@uma/core@1.1.0" -"@uma/core-1-2-2@npm:@uma/core@1.2.2": +"@uma/core-1-2-2@npm:@uma/core@1.2.2", "@uma/core@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@uma/core/-/core-1.2.2.tgz#40f8db9e986a6a8ae8968aa690a33de51298f322" integrity sha512-eBvUbjTJHUOq9iG2cW2oTkUA8KtVRgcnjk5x0oh4wBZoVuWAb5OH0xHEKpZkHozVi0Nw04pX3dAsBWBwR2JEOA== @@ -5199,6 +5199,25 @@ "@uma/common" "^1.1.0" "@uma/core-1-1-0" "npm:@uma/core@1.1.0" +"@uma/financial-templates-lib@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@uma/financial-templates-lib/-/financial-templates-lib-1.2.0.tgz#84b734836b40de4045a768116e74c41ab19068a4" + integrity sha512-CwIaNYEf3Ybysc/V+yecPGAm3QHzXRv8hMC4+2PIqXh89Fypn0DeO2OU1jItUN/y0qPIBRTWFnAC6qBRsLpW6Q== + dependencies: + "@ethersproject/bignumber" "^5.0.5" + "@google-cloud/logging-winston" "^3.0.6" + "@google-cloud/trace-agent" "^4.2.5" + "@uma/common" "^1.1.0" + "@uma/core" "^1.2.0" + "@uniswap/sdk" "^2.0.5" + bluebird "^3.7.2" + dotenv "^6.2.0" + minimist "^1.2.0" + node-fetch "^2.6.0" + node-pagerduty "^1.2.0" + winston "^3.2.1" + winston-transport "^4.3.0" + "@umaprotocol/react-plugin@^1.5.5": version "1.5.5" resolved "https://registry.yarnpkg.com/@umaprotocol/react-plugin/-/react-plugin-1.5.5.tgz#c378fe8534c9df9a0ec02e16250fd59891cbb37f"