├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bot.config.js ├── contracts ├── IERC20.sol ├── IEuler.sol └── LiquidationBot.sol ├── hardhat.config.js ├── package-lock.json ├── package.json ├── scripts ├── EulerToolClient.js ├── discordBot.js ├── mon.js ├── monLib.js ├── reporter.js ├── strategies │ ├── BotSwapAndRepay.js │ ├── EOASwapAndRepay.js │ └── index.js └── utils.js └── test ├── contract.js ├── lib ├── botTestLib.js ├── eTestLib.config.js └── helpers.js └── monitor.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /artifacts/ 3 | /cache/ 4 | /.env 5 | /log.txt -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM 310118226683.dkr.ecr.eu-west-1.amazonaws.com/node:16 2 | RUN apt-get update && apt-get install -y apt-transport-https ca-certificates curl gnupg && \ 3 | curl -sLf --retry 3 --tlsv1.2 --proto "=https" 'https://packages.doppler.com/public/cli/gpg.DE2A7741A397C129.key' | apt-key add - && \ 4 | echo "deb https://packages.doppler.com/public/cli/deb/debian any-version main" | tee /etc/apt/sources.list.d/doppler-cli.list && \ 5 | apt-get update && \ 6 | apt-get -y install doppler 7 | COPY . . 8 | RUN npm install 9 | ENTRYPOINT ["doppler", "run", "--"] 10 | CMD npm start 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Euler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Euler Liquidation Bot 2 | 3 | Basic bot performing liquidations on the Euler platform. [Liquidation docs.](https://docs.euler.finance/getting-started/white-paper#liquidations) 4 | 5 | ### Installation 6 | 7 | ```bash 8 | npm i 9 | ``` 10 | 11 | ### Configuration 12 | 13 | Configuration through `.env` file: 14 | 15 | - `MAINNET_JSON_RPC_URL` - your JSON RPC provider endpoint (Infura, Rivet, Alchemy etc.). 16 | - `MIN_ETH_YIELD` - minimum liquidation yield in ETH, taking into account gas cost. Default `0.05`. 17 | - `PRV_KEY` - private key of the account executing EOA liquidations. The account needs to hold ETH to execute liquidation transactions. 18 | - `RECEIVER_SUBACCOUNT_ID` - optional ID of a sub-account to which the yield will be transfered after liquidation. 19 | - `ONEINCH_API_URL` - optional [1inch swap](https://docs.1inch.io/docs/aggregation-protocol/api/swap-params) API URL. If set, the bot will try to swap as much collateral as possible first on 1inch, presumably at better rates, and the remainder on Uni V3 exact output. 20 | - `SKIP_ACCOUNTS_WITH_INSUFFICIENT_COLLATERAL` optional, if set to string `true`, skip processing accounts with largest deposited collateral value less than the MIN_ETH_YIELD. 21 | 22 | Optional - gas settings 23 | - `TX_FEE_MUL` - transaction fee multiplier. Default `maxFeePerGas` and `maxPriorityFeePerGas` [returned by provider](https://docs.ethers.io/v5/api/providers/provider/#Provider-getFeeData) will be multiplied by this value. 24 | - `TX_GAS_LIMIT` - custom `gasLimit`. 25 | 26 | Optional - the bot can be configured to push reports to Discord 27 | - `DISCORD_WEBHOOK` - discord webhook URL. 28 | - `REPORTER_INTERVAL` - reporting interval in seconds. 29 | 30 | Optional - send transactions through flashbots 31 | - `USE_FLASHBOTS` - if set to a string `true`, send the final liquidation tx through flashbots. Default `false`. 32 | - `FLASHBOTS_RELAY_SIGNING_KEY` - key used to identify the searcher in flashbots relay. If not set a random wallet will be generated. 33 | - `FLASHBOTS_MAX_BLOCKS` - sets the number of blocks during which flashbots will try to include the tx. If not set, flasbots default 25 blocks will be used. 34 | - `FLASHBOTS_DISABLE_FALLBACK` - by default, if flashbots call fails, the liquidation bot will attempt to send a regular tx. Set to string `true` to disable this behaviour. 35 | 36 | ### Running 37 | 38 | ```bash 39 | npm start 40 | ``` 41 | 42 | ### Tests 43 | 44 | ```bash 45 | npx hardhat test 46 | ``` 47 | 48 | ### Dependencies 49 | 50 | The bot depends on Eulerscan project, maintained by Euler, to receive updates about accounts in violation (healthscore < 1). Eulerscan provides a websocket connection through which JSON Patch updates to subscribed data are pushed. It is publicly available on `wss://escan-mainnet.euler.finance`. 51 | 52 | ### Bot algorithm 53 | 54 | The main bot logic simulates liquidations through multiple strategies and parameters to find the best candidate. Two strategies were explored, of which only EOA is currently executed: 55 | 56 | - EOA strategy. Liquidations are performed by constructing batch transactions to Euler's [Exec contract](https://github.com/euler-xyz/euler-contracts/blob/master/contracts/modules/Exec.sol), which are executed by EAO from `PRV_KEY` configuration. 57 | - Bot contract strategy. A rudimentary `LiquidationBot` contract is included in the repo. Only basic tests are available. Currently not used in production. 58 | -------------------------------------------------------------------------------- /bot.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | localhost: { 3 | eulerscan: { 4 | ws: 'ws://localhost:8900', 5 | } 6 | }, 7 | ropsten: { 8 | chainId: 3, 9 | jsonRpcUrl: process.env.ROPSTEN_JSON_RPC_URL, 10 | eulerscan: { 11 | ws: process.env.EULERSCAN_WS || 'wss://escan-ropsten.euler.finance', 12 | queryLimit: process.env.QUERY_LIMIT ? Number(process.env.QUERY_LIMIT) : 500, 13 | healthMax: process.env.QUERY_HEALTH_MAX ? Number(process.env.QUERY_HEALTH_MAX) : 1000000, 14 | }, 15 | minYield: process.env.MIN_ETH_YIELD || '0.05', 16 | skipInsufficientCollateral: process.env.SKIP_ACCOUNTS_WITH_INSUFFICIENT_COLLATERAL === 'true', 17 | }, 18 | goerli: { 19 | chainId: 5, 20 | jsonRpcUrl: process.env.GOERLI_JSON_RPC_URL, 21 | eulerscan: { 22 | ws: process.env.EULERSCAN_WS || 'wss://escan-goerli.euler.finance', 23 | queryLimit: process.env.QUERY_LIMIT ? Number(process.env.QUERY_LIMIT) : 500, 24 | healthMax: process.env.QUERY_HEALTH_MAX ? Number(process.env.QUERY_HEALTH_MAX) : 1000000, 25 | }, 26 | minYield: process.env.MIN_ETH_YIELD || '0.05', 27 | skipInsufficientCollateral: process.env.SKIP_ACCOUNTS_WITH_INSUFFICIENT_COLLATERAL === 'true', 28 | }, 29 | mainnet: { 30 | chainId: 1, 31 | jsonRpcUrl: process.env.MAINNET_JSON_RPC_URL, 32 | eulerscan: { 33 | ws: process.env.EULERSCAN_WS || 'wss://escan-mainnet.euler.finance', 34 | queryLimit: process.env.QUERY_LIMIT ? Number(process.env.QUERY_LIMIT) : 500, 35 | healthMax: process.env.QUERY_HEALTH_MAX ? Number(process.env.QUERY_HEALTH_MAX) : 1000000, 36 | }, 37 | reporter: { 38 | interval: process.env.REPORTER_INTERVAL ? Number(process.env.REPORTER_INTERVAL) : 60 * 60, 39 | logPath: process.env.REPORTER_LOG_PATH || './log.txt', 40 | }, 41 | minYield: process.env.MIN_ETH_YIELD || '0.05', 42 | skipInsufficientCollateral: process.env.SKIP_ACCOUNTS_WITH_INSUFFICIENT_COLLATERAL === 'true', 43 | }, 44 | hardhat: { 45 | jsonRpcUrl: "http://localhost:8545", 46 | minYield: process.env.MIN_ETH_YIELD || '0.05', 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /contracts/IERC20.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | interface IERC20 { 6 | event Approval(address indexed owner, address indexed spender, uint value); 7 | event Transfer(address indexed from, address indexed to, uint value); 8 | 9 | function name() external view returns (string memory); 10 | function symbol() external view returns (string memory); 11 | function decimals() external view returns (uint8); 12 | function totalSupply() external view returns (uint); 13 | function balanceOf(address owner) external view returns (uint); 14 | function allowance(address owner, address spender) external view returns (uint); 15 | 16 | function approve(address spender, uint value) external returns (bool); 17 | function transfer(address to, uint value) external returns (bool); 18 | function transferFrom(address from, address to, uint value) external returns (bool); 19 | } -------------------------------------------------------------------------------- /contracts/IEuler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity >=0.5.0; 3 | pragma abicoder v2; 4 | 5 | 6 | /// @notice Main storage contract for the Euler system 7 | interface IEuler { 8 | /// @notice Lookup the current implementation contract for a module 9 | /// @param moduleId Fixed constant that refers to a module type (ie MODULEID__ETOKEN) 10 | /// @return An internal address specifies the module's implementation code 11 | function moduleIdToImplementation(uint moduleId) external view returns (address); 12 | 13 | /// @notice Lookup a proxy that can be used to interact with a module (only valid for single-proxy modules) 14 | /// @param moduleId Fixed constant that refers to a module type (ie MODULEID__MARKETS) 15 | /// @return An address that should be cast to the appropriate module interface, ie IEulerMarkets(moduleIdToProxy(2)) 16 | function moduleIdToProxy(uint moduleId) external view returns (address); 17 | 18 | /// @notice Euler-related configuration for an asset 19 | struct AssetConfig { 20 | address eTokenAddress; 21 | bool borrowIsolated; 22 | uint32 collateralFactor; 23 | uint32 borrowFactor; 24 | uint24 twapWindow; 25 | } 26 | } 27 | 28 | 29 | /// @notice Activating and querying markets, and maintaining entered markets lists 30 | interface IEulerMarkets { 31 | /// @notice Create an Euler pool and associated EToken and DToken addresses. 32 | /// @param underlying The address of an ERC20-compliant token. There must be an initialised uniswap3 pool for the underlying/reference asset pair. 33 | /// @return The created EToken, or the existing EToken if already activated. 34 | function activateMarket(address underlying) external returns (address); 35 | 36 | /// @notice Create a pToken and activate it on Euler. pTokens are protected wrappers around assets that prevent borrowing. 37 | /// @param underlying The address of an ERC20-compliant token. There must already be an activated market on Euler for this underlying, and it must have a non-zero collateral factor. 38 | /// @return The created pToken, or an existing one if already activated. 39 | function activatePToken(address underlying) external returns (address); 40 | 41 | /// @notice Given an underlying, lookup the associated EToken 42 | /// @param underlying Token address 43 | /// @return EToken address, or address(0) if not activated 44 | function underlyingToEToken(address underlying) external view returns (address); 45 | 46 | /// @notice Given an underlying, lookup the associated DToken 47 | /// @param underlying Token address 48 | /// @return DToken address, or address(0) if not activated 49 | function underlyingToDToken(address underlying) external view returns (address); 50 | 51 | /// @notice Given an underlying, lookup the associated PToken 52 | /// @param underlying Token address 53 | /// @return PToken address, or address(0) if it doesn't exist 54 | function underlyingToPToken(address underlying) external view returns (address); 55 | 56 | /// @notice Looks up the Euler-related configuration for a token, and resolves all default-value placeholders to their currently configured values. 57 | /// @param underlying Token address 58 | /// @return Configuration struct 59 | function underlyingToAssetConfig(address underlying) external view returns (IEuler.AssetConfig memory); 60 | 61 | /// @notice Looks up the Euler-related configuration for a token, and returns it unresolved (with default-value placeholders) 62 | /// @param underlying Token address 63 | /// @return config Configuration struct 64 | function underlyingToAssetConfigUnresolved(address underlying) external view returns (IEuler.AssetConfig memory config); 65 | 66 | /// @notice Given an EToken address, looks up the associated underlying 67 | /// @param eToken EToken address 68 | /// @return underlying Token address 69 | function eTokenToUnderlying(address eToken) external view returns (address underlying); 70 | 71 | /// @notice Given an EToken address, looks up the associated DToken 72 | /// @param eToken EToken address 73 | /// @return dTokenAddr DToken address 74 | function eTokenToDToken(address eToken) external view returns (address dTokenAddr); 75 | 76 | /// @notice Looks up an asset's currently configured interest rate model 77 | /// @param underlying Token address 78 | /// @return Module ID that represents the interest rate model (IRM) 79 | function interestRateModel(address underlying) external view returns (uint); 80 | 81 | /// @notice Retrieves the current interest rate for an asset 82 | /// @param underlying Token address 83 | /// @return The interest rate in yield-per-second, scaled by 10**27 84 | function interestRate(address underlying) external view returns (int96); 85 | 86 | /// @notice Retrieves the current interest rate accumulator for an asset 87 | /// @param underlying Token address 88 | /// @return An opaque accumulator that increases as interest is accrued 89 | function interestAccumulator(address underlying) external view returns (uint); 90 | 91 | /// @notice Retrieves the reserve fee in effect for an asset 92 | /// @param underlying Token address 93 | /// @return Amount of interest that is redirected to the reserves, as a fraction scaled by RESERVE_FEE_SCALE (4e9) 94 | function reserveFee(address underlying) external view returns (uint32); 95 | 96 | /// @notice Retrieves the pricing config for an asset 97 | /// @param underlying Token address 98 | /// @return pricingType (1=pegged, 2=uniswap3, 3=forwarded) 99 | /// @return pricingParameters If uniswap3 pricingType then this represents the uniswap pool fee used, otherwise unused 100 | /// @return pricingForwarded If forwarded pricingType then this is the address prices are forwarded to, otherwise address(0) 101 | function getPricingConfig(address underlying) external view returns (uint16 pricingType, uint32 pricingParameters, address pricingForwarded); 102 | 103 | /// @notice Retrieves the list of entered markets for an account (assets enabled for collateral or borrowing) 104 | /// @param account User account 105 | /// @return List of underlying token addresses 106 | function getEnteredMarkets(address account) external view returns (address[] memory); 107 | 108 | /// @notice Add an asset to the entered market list, or do nothing if already entered 109 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 110 | /// @param newMarket Underlying token address 111 | function enterMarket(uint subAccountId, address newMarket) external; 112 | 113 | /// @notice Remove an asset from the entered market list, or do nothing if not already present 114 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 115 | /// @param oldMarket Underlying token address 116 | function exitMarket(uint subAccountId, address oldMarket) external; 117 | } 118 | 119 | 120 | /// @notice Definition of callback method that deferLiquidityCheck will invoke on your contract 121 | interface IDeferredLiquidityCheck { 122 | function onDeferredLiquidityCheck(bytes memory data) external; 123 | } 124 | 125 | /// @notice Batch executions, liquidity check deferrals, and interfaces to fetch prices and account liquidity 126 | interface IEulerExec { 127 | /// @notice Liquidity status for an account, either in aggregate or for a particular asset 128 | struct LiquidityStatus { 129 | uint collateralValue; 130 | uint liabilityValue; 131 | uint numBorrows; 132 | bool borrowIsolated; 133 | } 134 | 135 | /// @notice Aggregate struct for reporting detailed (per-asset) liquidity for an account 136 | struct AssetLiquidity { 137 | address underlying; 138 | LiquidityStatus status; 139 | } 140 | 141 | /// @notice Single item in a batch request 142 | struct EulerBatchItem { 143 | bool allowError; 144 | address proxyAddr; 145 | bytes data; 146 | } 147 | 148 | /// @notice Single item in a batch response 149 | struct EulerBatchItemResponse { 150 | bool success; 151 | bytes result; 152 | } 153 | 154 | /// @notice Compute aggregate liquidity for an account 155 | /// @param account User address 156 | /// @return status Aggregate liquidity (sum of all entered assets) 157 | function liquidity(address account) external returns (LiquidityStatus memory status); 158 | 159 | /// @notice Compute detailed liquidity for an account, broken down by asset 160 | /// @param account User address 161 | /// @return assets List of user's entered assets and each asset's corresponding liquidity 162 | function detailedLiquidity(address account) external returns (AssetLiquidity[] memory assets); 163 | 164 | /// @notice Retrieve Euler's view of an asset's price 165 | /// @param underlying Token address 166 | /// @return twap Time-weighted average price 167 | /// @return twapPeriod TWAP duration, either the twapWindow value in AssetConfig, or less if that duration not available 168 | function getPrice(address underlying) external returns (uint twap, uint twapPeriod); 169 | 170 | /// @notice Retrieve Euler's view of an asset's price, as well as the current marginal price on uniswap 171 | /// @param underlying Token address 172 | /// @return twap Time-weighted average price 173 | /// @return twapPeriod TWAP duration, either the twapWindow value in AssetConfig, or less if that duration not available 174 | /// @return currPrice The current marginal price on uniswap3 (informational: not used anywhere in the Euler protocol) 175 | function getPriceFull(address underlying) external returns (uint twap, uint twapPeriod, uint currPrice); 176 | 177 | /// @notice Defer liquidity checking for an account, to perform rebalancing, flash loans, etc. msg.sender must implement IDeferredLiquidityCheck 178 | /// @param account The account to defer liquidity for. Usually address(this), although not always 179 | /// @param data Passed through to the onDeferredLiquidityCheck() callback, so contracts don't need to store transient data in storage 180 | function deferLiquidityCheck(address account, bytes memory data) external; 181 | 182 | /// @notice Execute several operations in a single transaction 183 | /// @param items List of operations to execute 184 | /// @param deferLiquidityChecks List of user accounts to defer liquidity checks for 185 | /// @return List of operation results 186 | function batchDispatch(EulerBatchItem[] calldata items, address[] calldata deferLiquidityChecks) external returns (EulerBatchItemResponse[] memory); 187 | 188 | /// @notice Results of a batchDispatch, but with extra information 189 | struct EulerBatchExtra { 190 | EulerBatchItemResponse[] responses; 191 | uint gasUsed; 192 | AssetLiquidity[][] liquidities; 193 | } 194 | 195 | /// @notice Call batchDispatch, but return extra information. Only intended to be used with callStatic. 196 | /// @param items List of operations to execute 197 | /// @param deferLiquidityChecks List of user accounts to defer liquidity checks for 198 | /// @param queryLiquidity List of user accounts to return detailed liquidity information for 199 | /// @return output Structure with extra information 200 | function batchDispatchExtra(EulerBatchItem[] calldata items, address[] calldata deferLiquidityChecks, address[] calldata queryLiquidity) external returns (EulerBatchExtra memory output); 201 | 202 | /// @notice Enable average liquidity tracking for your account. Operations will cost more gas, but you may get additional benefits when performing liquidations 203 | /// @param subAccountId subAccountId 0 for primary, 1-255 for a sub-account. 204 | /// @param delegate An address of another account that you would allow to use the benefits of your account's average liquidity (use the null address if you don't care about this). The other address must also reciprocally delegate to your account. 205 | /// @param onlyDelegate Set this flag to skip tracking average liquidity and only set the delegate. 206 | function trackAverageLiquidity(uint subAccountId, address delegate, bool onlyDelegate) external; 207 | 208 | /// @notice Disable average liquidity tracking for your account and remove delegate 209 | /// @param subAccountId subAccountId 0 for primary, 1-255 for a sub-account 210 | function unTrackAverageLiquidity(uint subAccountId) external; 211 | 212 | /// @notice Retrieve the average liquidity for an account 213 | /// @param account User account (xor in subAccountId, if applicable) 214 | /// @return The average liquidity, in terms of the reference asset, and post risk-adjustment 215 | function getAverageLiquidity(address account) external returns (uint); 216 | 217 | /// @notice Retrieve the average liquidity for an account or a delegate account, if set 218 | /// @param account User account (xor in subAccountId, if applicable) 219 | /// @return The average liquidity, in terms of the reference asset, and post risk-adjustment 220 | function getAverageLiquidityWithDelegate(address account) external returns (uint); 221 | 222 | /// @notice Retrieve the account which delegates average liquidity for an account, if set 223 | /// @param account User account (xor in subAccountId, if applicable) 224 | /// @return The average liquidity delegate account 225 | function getAverageLiquidityDelegateAccount(address account) external view returns (address); 226 | 227 | /// @notice Transfer underlying tokens from sender's wallet into the pToken wrapper. Allowance should be set for the euler address. 228 | /// @param underlying Token address 229 | /// @param amount The amount to wrap in underlying units 230 | function pTokenWrap(address underlying, uint amount) external; 231 | 232 | /// @notice Transfer underlying tokens from the pToken wrapper to the sender's wallet. 233 | /// @param underlying Token address 234 | /// @param amount The amount to unwrap in underlying units 235 | function pTokenUnWrap(address underlying, uint amount) external; 236 | } 237 | 238 | 239 | /// @notice Tokenised representation of assets 240 | interface IEulerEToken { 241 | /// @notice Pool name, ie "Euler Pool: DAI" 242 | function name() external view returns (string memory); 243 | 244 | /// @notice Pool symbol, ie "eDAI" 245 | function symbol() external view returns (string memory); 246 | 247 | /// @notice Decimals, always normalised to 18. 248 | function decimals() external pure returns (uint8); 249 | 250 | /// @notice Sum of all balances, in internal book-keeping units (non-increasing) 251 | function totalSupply() external view returns (uint); 252 | 253 | /// @notice Sum of all balances, in underlying units (increases as interest is earned) 254 | function totalSupplyUnderlying() external view returns (uint); 255 | 256 | /// @notice Balance of a particular account, in internal book-keeping units (non-increasing) 257 | function balanceOf(address account) external view returns (uint); 258 | 259 | /// @notice Balance of a particular account, in underlying units (increases as interest is earned) 260 | function balanceOfUnderlying(address account) external view returns (uint); 261 | 262 | /// @notice Balance of the reserves, in internal book-keeping units (non-increasing) 263 | function reserveBalance() external view returns (uint); 264 | 265 | /// @notice Balance of the reserves, in underlying units (increases as interest is earned) 266 | function reserveBalanceUnderlying() external view returns (uint); 267 | 268 | /// @notice Updates interest accumulator and totalBorrows, credits reserves, re-targets interest rate, and logs asset status 269 | function touch() external; 270 | 271 | /// @notice Transfer underlying tokens from sender to the Euler pool, and increase account's eTokens 272 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 273 | /// @param amount In underlying units (use max uint256 for full underlying token balance) 274 | function deposit(uint subAccountId, uint amount) external; 275 | 276 | /// @notice Transfer underlying tokens from Euler pool to sender, and decrease account's eTokens 277 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 278 | /// @param amount In underlying units (use max uint256 for full pool balance) 279 | function withdraw(uint subAccountId, uint amount) external; 280 | 281 | /// @notice Mint eTokens and a corresponding amount of dTokens ("self-borrow") 282 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 283 | /// @param amount In underlying units 284 | function mint(uint subAccountId, uint amount) external; 285 | 286 | /// @notice Pay off dToken liability with eTokens ("self-repay") 287 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 288 | /// @param amount In underlying units (use max uint256 to repay the debt in full or up to the available underlying balance) 289 | function burn(uint subAccountId, uint amount) external; 290 | 291 | /// @notice Allow spender to access an amount of your eTokens in sub-account 0 292 | /// @param spender Trusted address 293 | /// @param amount Use max uint256 for "infinite" allowance 294 | function approve(address spender, uint amount) external returns (bool); 295 | 296 | /// @notice Allow spender to access an amount of your eTokens in a particular sub-account 297 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 298 | /// @param spender Trusted address 299 | /// @param amount Use max uint256 for "infinite" allowance 300 | function approveSubAccount(uint subAccountId, address spender, uint amount) external returns (bool); 301 | 302 | /// @notice Retrieve the current allowance 303 | /// @param holder Xor with the desired sub-account ID (if applicable) 304 | /// @param spender Trusted address 305 | function allowance(address holder, address spender) external view returns (uint); 306 | 307 | /// @notice Transfer eTokens to another address (from sub-account 0) 308 | /// @param to Xor with the desired sub-account ID (if applicable) 309 | /// @param amount In internal book-keeping units (as returned from balanceOf). Use max uint256 for full balance. 310 | function transfer(address to, uint amount) external returns (bool); 311 | 312 | /// @notice Transfer eTokens from one address to another 313 | /// @param from This address must've approved the to address, or be a sub-account of msg.sender 314 | /// @param to Xor with the desired sub-account ID (if applicable) 315 | /// @param amount In internal book-keeping units (as returned from balanceOf). Use max uint256 for full balance. 316 | function transferFrom(address from, address to, uint amount) external returns (bool); 317 | } 318 | 319 | 320 | /// @notice Tokenised representation of debts 321 | interface IEulerDToken { 322 | /// @notice Debt token name, ie "Euler Debt: DAI" 323 | function name() external view returns (string memory); 324 | 325 | /// @notice Debt token symbol, ie "dDAI" 326 | function symbol() external view returns (string memory); 327 | 328 | /// @notice Decimals, always normalised to 18. 329 | function decimals() external pure returns (uint8); 330 | 331 | /// @notice Sum of all outstanding debts, in underlying units (increases as interest is accrued) 332 | function totalSupply() external view returns (uint); 333 | 334 | /// @notice Sum of all outstanding debts, in underlying units with extra precision (increases as interest is accrued) 335 | function totalSupplyExact() external view returns (uint); 336 | 337 | /// @notice Debt owed by a particular account, in underlying units 338 | function balanceOf(address account) external view returns (uint); 339 | 340 | /// @notice Debt owed by a particular account, in underlying units with extra precision 341 | function balanceOfExact(address account) external view returns (uint); 342 | 343 | /// @notice Transfer underlying tokens from the Euler pool to the sender, and increase sender's dTokens 344 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 345 | /// @param amount In underlying units (use max uint256 for all available tokens) 346 | function borrow(uint subAccountId, uint amount) external; 347 | 348 | /// @notice Transfer underlying tokens from the sender to the Euler pool, and decrease sender's dTokens 349 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 350 | /// @param amount In underlying units (use max uint256 for full debt owed) 351 | function repay(uint subAccountId, uint amount) external; 352 | 353 | /// @notice Allow spender to send an amount of dTokens to a particular sub-account 354 | /// @param subAccountId 0 for primary, 1-255 for a sub-account 355 | /// @param spender Trusted address 356 | /// @param amount Use max uint256 for "infinite" allowance 357 | function approveDebt(uint subAccountId, address spender, uint amount) external returns (bool); 358 | 359 | /// @notice Retrieve the current debt allowance 360 | /// @param holder Xor with the desired sub-account ID (if applicable) 361 | /// @param spender Trusted address 362 | function debtAllowance(address holder, address spender) external view returns (uint); 363 | 364 | /// @notice Transfer dTokens to another address (from sub-account 0) 365 | /// @param to Xor with the desired sub-account ID (if applicable) 366 | /// @param amount In underlying units. Use max uint256 for full balance. 367 | function transfer(address to, uint amount) external returns (bool); 368 | 369 | /// @notice Transfer dTokens from one address to another 370 | /// @param from Xor with the desired sub-account ID (if applicable) 371 | /// @param to This address must've approved the from address, or be a sub-account of msg.sender 372 | /// @param amount In underlying. Use max uint256 for full balance. 373 | function transferFrom(address from, address to, uint amount) external returns (bool); 374 | } 375 | 376 | 377 | /// @notice Liquidate users who are in collateral violation to protect lenders 378 | interface IEulerLiquidation { 379 | /// @notice Information about a prospective liquidation opportunity 380 | struct LiquidationOpportunity { 381 | uint repay; 382 | uint yield; 383 | uint healthScore; 384 | 385 | // Only populated if repay > 0: 386 | uint baseDiscount; 387 | uint discount; 388 | uint conversionRate; 389 | } 390 | 391 | /// @notice Checks to see if a liquidation would be profitable, without actually doing anything 392 | /// @param liquidator Address that will initiate the liquidation 393 | /// @param violator Address that may be in collateral violation 394 | /// @param underlying Token that is to be repayed 395 | /// @param collateral Token that is to be seized 396 | /// @return liqOpp The details about the liquidation opportunity 397 | function checkLiquidation(address liquidator, address violator, address underlying, address collateral) external returns (LiquidationOpportunity memory liqOpp); 398 | 399 | /// @notice Attempts to perform a liquidation 400 | /// @param violator Address that may be in collateral violation 401 | /// @param underlying Token that is to be repayed 402 | /// @param collateral Token that is to be seized 403 | /// @param repay The amount of underlying DTokens to be transferred from violator to sender, in units of underlying 404 | /// @param minYield The minimum acceptable amount of collateral ETokens to be transferred from violator to sender, in units of collateral 405 | function liquidate(address violator, address underlying, address collateral, uint repay, uint minYield) external; 406 | } 407 | 408 | 409 | /// @notice Trading assets on Uniswap V3 and 1Inch V4 DEXs 410 | interface IEulerSwap { 411 | /// @notice Params for Uniswap V3 exact input trade on a single pool 412 | /// @param subAccountIdIn subaccount id to trade from 413 | /// @param subAccountIdOut subaccount id to trade to 414 | /// @param underlyingIn sold token address 415 | /// @param underlyingOut bought token address 416 | /// @param amountIn amount of token to sell 417 | /// @param amountOutMinimum minimum amount of bought token 418 | /// @param deadline trade must complete before this timestamp 419 | /// @param fee uniswap pool fee to use 420 | /// @param sqrtPriceLimitX96 maximum acceptable price 421 | struct SwapUniExactInputSingleParams { 422 | uint subAccountIdIn; 423 | uint subAccountIdOut; 424 | address underlyingIn; 425 | address underlyingOut; 426 | uint amountIn; 427 | uint amountOutMinimum; 428 | uint deadline; 429 | uint24 fee; 430 | uint160 sqrtPriceLimitX96; 431 | } 432 | 433 | /// @notice Params for Uniswap V3 exact input trade routed through multiple pools 434 | /// @param subAccountIdIn subaccount id to trade from 435 | /// @param subAccountIdOut subaccount id to trade to 436 | /// @param underlyingIn sold token address 437 | /// @param underlyingOut bought token address 438 | /// @param amountIn amount of token to sell 439 | /// @param amountOutMinimum minimum amount of bought token 440 | /// @param deadline trade must complete before this timestamp 441 | /// @param path list of pools to use for the trade 442 | struct SwapUniExactInputParams { 443 | uint subAccountIdIn; 444 | uint subAccountIdOut; 445 | uint amountIn; 446 | uint amountOutMinimum; 447 | uint deadline; 448 | bytes path; // list of pools to hop - constructed with uni SDK 449 | } 450 | 451 | /// @notice Params for Uniswap V3 exact output trade on a single pool 452 | /// @param subAccountIdIn subaccount id to trade from 453 | /// @param subAccountIdOut subaccount id to trade to 454 | /// @param underlyingIn sold token address 455 | /// @param underlyingOut bought token address 456 | /// @param amountOut amount of token to buy 457 | /// @param amountInMaximum maximum amount of sold token 458 | /// @param deadline trade must complete before this timestamp 459 | /// @param fee uniswap pool fee to use 460 | /// @param sqrtPriceLimitX96 maximum acceptable price 461 | struct SwapUniExactOutputSingleParams { 462 | uint subAccountIdIn; 463 | uint subAccountIdOut; 464 | address underlyingIn; 465 | address underlyingOut; 466 | uint amountOut; 467 | uint amountInMaximum; 468 | uint deadline; 469 | uint24 fee; 470 | uint160 sqrtPriceLimitX96; 471 | } 472 | 473 | /// @notice Params for Uniswap V3 exact output trade routed through multiple pools 474 | /// @param subAccountIdIn subaccount id to trade from 475 | /// @param subAccountIdOut subaccount id to trade to 476 | /// @param underlyingIn sold token address 477 | /// @param underlyingOut bought token address 478 | /// @param amountOut amount of token to buy 479 | /// @param amountInMaximum maximum amount of sold token 480 | /// @param deadline trade must complete before this timestamp 481 | /// @param path list of pools to use for the trade 482 | struct SwapUniExactOutputParams { 483 | uint subAccountIdIn; 484 | uint subAccountIdOut; 485 | uint amountOut; 486 | uint amountInMaximum; 487 | uint deadline; 488 | bytes path; 489 | } 490 | 491 | /// @notice Params for 1Inch trade 492 | /// @param subAccountIdIn subaccount id to trade from 493 | /// @param subAccountIdOut subaccount id to trade to 494 | /// @param underlyingIn sold token address 495 | /// @param underlyingOut bought token address 496 | /// @param amount amount of token to sell 497 | /// @param amountOutMinimum minimum amount of bought token 498 | /// @param payload call data passed to 1Inch contract 499 | struct Swap1InchParams { 500 | uint subAccountIdIn; 501 | uint subAccountIdOut; 502 | address underlyingIn; 503 | address underlyingOut; 504 | uint amount; 505 | uint amountOutMinimum; 506 | bytes payload; 507 | } 508 | 509 | /// @notice Execute Uniswap V3 exact input trade on a single pool 510 | /// @param params struct defining trade parameters 511 | function swapUniExactInputSingle(SwapUniExactInputSingleParams memory params) external; 512 | 513 | /// @notice Execute Uniswap V3 exact input trade routed through multiple pools 514 | /// @param params struct defining trade parameters 515 | function swapUniExactInput(SwapUniExactInputParams memory params) external; 516 | 517 | /// @notice Execute Uniswap V3 exact output trade on a single pool 518 | /// @param params struct defining trade parameters 519 | function swapUniExactOutputSingle(SwapUniExactOutputSingleParams memory params) external; 520 | 521 | /// @notice Execute Uniswap V3 exact output trade routed through multiple pools 522 | /// @param params struct defining trade parameters 523 | function swapUniExactOutput(SwapUniExactOutputParams memory params) external; 524 | 525 | /// @notice Trade on Uniswap V3 single pool and repay debt with bought asset 526 | /// @param params struct defining trade parameters (amountOut is ignored) 527 | /// @param targetDebt amount of debt that is expected to remain after trade and repay (0 to repay full debt) 528 | function swapAndRepayUniSingle(SwapUniExactOutputSingleParams memory params, uint targetDebt) external; 529 | 530 | /// @notice Trade on Uniswap V3 through multiple pools pool and repay debt with bought asset 531 | /// @param params struct defining trade parameters (amountOut is ignored) 532 | /// @param targetDebt amount of debt that is expected to remain after trade and repay (0 to repay full debt) 533 | function swapAndRepayUni(SwapUniExactOutputParams memory params, uint targetDebt) external; 534 | 535 | /// @notice Execute 1Inch V4 trade 536 | /// @param params struct defining trade parameters 537 | function swap1Inch(Swap1InchParams memory params) external; 538 | } 539 | 540 | 541 | /// @notice Protected Tokens are simple wrappers for tokens, allowing you to use tokens as collateral without permitting borrowing 542 | interface IEulerPToken { 543 | /// @notice PToken name, ie "Euler Protected DAI" 544 | function name() external view returns (string memory); 545 | 546 | /// @notice PToken symbol, ie "pDAI" 547 | function symbol() external view returns (string memory); 548 | 549 | /// @notice Number of decimals, which is same as the underlying's 550 | function decimals() external view returns (uint8); 551 | 552 | /// @notice Address of the underlying asset 553 | function underlying() external view returns (address); 554 | 555 | /// @notice Balance of an account's wrapped tokens 556 | function balanceOf(address who) external view returns (uint); 557 | 558 | /// @notice Sum of all wrapped token balances 559 | function totalSupply() external view returns (uint); 560 | 561 | /// @notice Retrieve the current allowance 562 | /// @param holder Address giving permission to access tokens 563 | /// @param spender Trusted address 564 | function allowance(address holder, address spender) external view returns (uint); 565 | 566 | /// @notice Transfer your own pTokens to another address 567 | /// @param recipient Recipient address 568 | /// @param amount Amount of wrapped token to transfer 569 | function transfer(address recipient, uint amount) external returns (bool); 570 | 571 | /// @notice Transfer pTokens from one address to another. The euler address is automatically granted approval. 572 | /// @param from This address must've approved the to address 573 | /// @param recipient Recipient address 574 | /// @param amount Amount to transfer 575 | function transferFrom(address from, address recipient, uint amount) external returns (bool); 576 | 577 | /// @notice Allow spender to access an amount of your pTokens. It is not necessary to approve the euler address. 578 | /// @param spender Trusted address 579 | /// @param amount Use max uint256 for "infinite" allowance 580 | function approve(address spender, uint amount) external returns (bool); 581 | 582 | /// @notice Convert underlying tokens to pTokens 583 | /// @param amount In underlying units (which are equivalent to pToken units) 584 | function wrap(uint amount) external; 585 | 586 | /// @notice Convert pTokens to underlying tokens 587 | /// @param amount In pToken units (which are equivalent to underlying units) 588 | function unwrap(uint amount) external; 589 | 590 | /// @notice Claim any surplus tokens held by the PToken contract. This should only be used by contracts. 591 | /// @param who Beneficiary to be credited for the surplus token amount 592 | function claimSurplus(address who) external; 593 | } 594 | 595 | 596 | library EulerAddrsMainnet { 597 | IEuler public constant euler = IEuler(0x27182842E098f60e3D576794A5bFFb0777E025d3); 598 | IEulerMarkets public constant markets = IEulerMarkets(0x3520d5a913427E6F0D6A83E07ccD4A4da316e4d3); 599 | IEulerLiquidation public constant liquidation = IEulerLiquidation(0xf43ce1d09050BAfd6980dD43Cde2aB9F18C85b34); 600 | IEulerExec public constant exec = IEulerExec(0x59828FdF7ee634AaaD3f58B19fDBa3b03E2D9d80); 601 | } 602 | 603 | library EulerAddrsRopsten { 604 | IEuler public constant euler = IEuler(0xfC3DD73e918b931be7DEfd0cc616508391bcc001); 605 | IEulerMarkets public constant markets = IEulerMarkets(0x60Ec84902908f5c8420331300055A63E6284F522); 606 | IEulerLiquidation public constant liquidation = IEulerLiquidation(0xf9773f2D869Bdbe0B6aC6D6fD7df82b82C998DC7); 607 | IEulerExec public constant exec = IEulerExec(0xF7B8611008Ed073Ef348FE130671688BBb20409d); 608 | } -------------------------------------------------------------------------------- /contracts/LiquidationBot.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.0; 4 | 5 | import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; 6 | import "./IEuler.sol"; 7 | import "./IERC20.sol"; 8 | import "hardhat/console.sol"; 9 | 10 | 11 | contract LiquidationBot { 12 | address immutable owner; 13 | 14 | constructor() { 15 | owner = msg.sender; 16 | } 17 | 18 | modifier onlyOwner() { 19 | require(msg.sender == owner, "not owner"); 20 | _; 21 | } 22 | 23 | struct LiquidationParams { 24 | address eulerAddr; 25 | address liquidationAddr; 26 | address execAddr; 27 | address marketsAddr; 28 | address swapAddr; 29 | 30 | bytes swapPath; 31 | 32 | address violator; 33 | address underlying; 34 | address collateral; 35 | } 36 | 37 | function liquidate(LiquidationParams memory liqParams) external onlyOwner { 38 | IEulerExec(liqParams.execAddr).deferLiquidityCheck(address(this), abi.encode(liqParams)); 39 | } 40 | 41 | function onDeferredLiquidityCheck(bytes memory encodedData) external { 42 | // TODO check caller? 43 | LiquidationParams memory liqParams = abi.decode(encodedData, (LiquidationParams)); 44 | 45 | IEulerLiquidation.LiquidationOpportunity memory liqOpp = IEulerLiquidation(liqParams.liquidationAddr).checkLiquidation(address(this), liqParams.violator, liqParams.underlying, liqParams.collateral); 46 | 47 | uint repay = liqOpp.repay; 48 | { 49 | //FIXME decimals 50 | //uint poolSize = IERC20(liqParams.collateral).balanceOf(liqParams.eulerAddr); 51 | //if (poolSize < liqOpp.yield) repay = poolSize * 1e18 / liqOpp.conversionRate; 52 | } 53 | 54 | IEulerLiquidation(liqParams.liquidationAddr).liquidate(liqParams.violator, liqParams.underlying, liqParams.collateral, repay, 0); 55 | 56 | IEulerSwap(liqParams.swapAddr).swapAndRepayUni( 57 | IEulerSwap.SwapUniExactOutputParams({ 58 | subAccountIdIn: 0, 59 | subAccountIdOut: 0, 60 | amountOut: 0, // amountOut is ignored by swap and repay 61 | amountInMaximum: type(uint).max, 62 | deadline: block.timestamp, // FIXME: deadline 63 | path: liqParams.swapPath 64 | }), 65 | 0 66 | ); 67 | 68 | IEulerMarkets(liqParams.marketsAddr).exitMarket(0, liqParams.underlying); 69 | } 70 | 71 | function testLiquidation(LiquidationParams memory liqParams) external onlyOwner returns (uint) { 72 | address eTokenAddr = IEulerMarkets(liqParams.marketsAddr).underlyingToEToken(liqParams.collateral); 73 | uint prevBalance = IEulerEToken(eTokenAddr).balanceOf(address(this)); 74 | 75 | IEulerExec(liqParams.execAddr).deferLiquidityCheck(address(this), abi.encode(liqParams)); 76 | 77 | uint balance = IEulerEToken(eTokenAddr).balanceOf(address(this)); 78 | 79 | return balance > prevBalance ? balance - prevBalance : 0; 80 | } 81 | 82 | function raw(address to, bytes calldata data, uint value) external onlyOwner { 83 | (bool success, bytes memory result) = to.call{ value: value }(data); 84 | if (!success) revertBytes(result); 85 | } 86 | 87 | function revertBytes(bytes memory errMsg) internal pure { 88 | if (errMsg.length > 0) { 89 | assembly { 90 | revert(add(32, errMsg), mload(errMsg)) 91 | } 92 | } 93 | 94 | revert("empty-error"); 95 | } 96 | 97 | 98 | function uint2str(uint _i) internal pure returns (string memory _uintAsString) { 99 | if (_i == 0) { 100 | return "0"; 101 | } 102 | uint j = _i; 103 | uint len; 104 | while (j != 0) { 105 | len++; 106 | j /= 10; 107 | } 108 | bytes memory bstr = new bytes(len); 109 | uint k = len; 110 | while (_i != 0) { 111 | k = k-1; 112 | uint8 temp = (48 + uint8(_i - _i / 10 * 10)); 113 | bytes1 b1 = bytes1(temp); 114 | bstr[k] = b1; 115 | _i /= 10; 116 | } 117 | return string(bstr); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require("@nomiclabs/hardhat-waffle"); 2 | require('hardhat-dependency-compiler'); 3 | require('dotenv').config(); 4 | 5 | // Config 6 | let accounts = process.env.PRV_KEY ? { accounts: [process.env.PRV_KEY] } : {}; 7 | 8 | module.exports = { 9 | networks: { 10 | hardhat: { 11 | hardfork: 'london', 12 | }, 13 | ropsten: { 14 | url: process.env.ROPSTEN_JSON_RPC_URL, 15 | ...accounts, 16 | }, 17 | mainnet: { 18 | url: process.env.MAINNET_JSON_RPC_URL, 19 | ...accounts, 20 | }, 21 | }, 22 | 23 | solidity: { 24 | compilers: [ 25 | { 26 | version: "0.8.6", 27 | settings: { 28 | optimizer: { 29 | enabled: true, 30 | runs: 1000000, 31 | }, 32 | }, 33 | }, 34 | ], 35 | }, 36 | 37 | dependencyCompiler: { 38 | paths: [ 39 | 'euler-contracts/contracts/Euler.sol', 40 | 'euler-contracts/contracts/modules/DToken.sol', 41 | 'euler-contracts/contracts/modules/EToken.sol', 42 | 'euler-contracts/contracts/modules/Exec.sol', 43 | 'euler-contracts/contracts/modules/Governance.sol', 44 | 'euler-contracts/contracts/modules/Installer.sol', 45 | 'euler-contracts/contracts/modules/Liquidation.sol', 46 | 'euler-contracts/contracts/modules/Markets.sol', 47 | 'euler-contracts/contracts/modules/RiskManager.sol', 48 | 'euler-contracts/contracts/modules/Swap.sol', 49 | 'euler-contracts/contracts/modules/interest-rate-models/IRMDefault.sol', 50 | 'euler-contracts/contracts/modules/interest-rate-models/IRMClassMajor.sol', 51 | 'euler-contracts/contracts/modules/interest-rate-models/IRMClassMidCap.sol', 52 | 'euler-contracts/contracts/modules/interest-rate-models/IRMClassStable.sol', 53 | 'euler-contracts/contracts/modules/interest-rate-models/IRMClassMega.sol', 54 | 'euler-contracts/contracts/modules/interest-rate-models/test/IRMZero.sol', 55 | 'euler-contracts/contracts/modules/interest-rate-models/test/IRMFixed.sol', 56 | 'euler-contracts/contracts/modules/interest-rate-models/test/IRMLinear.sol', 57 | 'euler-contracts/contracts/adaptors/FlashLoan.sol', 58 | 'euler-contracts/contracts/test/FlashLoanAdaptorTest2.sol', 59 | 'euler-contracts/contracts/test/FlashLoanAdaptorTest.sol', 60 | 'euler-contracts/contracts/test/FlashLoanNativeTest.sol', 61 | 'euler-contracts/contracts/test/InvariantChecker.sol', 62 | 'euler-contracts/contracts/test/JunkETokenUpgrade.sol', 63 | 'euler-contracts/contracts/test/JunkMarketsUpgrade.sol', 64 | 'euler-contracts/contracts/test/MockUniswapV3Factory.sol', 65 | 'euler-contracts/contracts/test/MockUniswapV3Pool.sol', 66 | 'euler-contracts/contracts/test/MockEACAggregatorProxy.sol', 67 | 'euler-contracts/contracts/test/SimpleUniswapPeriphery.sol', 68 | 'euler-contracts/contracts/test/TestERC20.sol', 69 | 'euler-contracts/contracts/test/TestModule.sol', 70 | 'euler-contracts/contracts/views/EulerGeneralView.sol', 71 | 'euler-contracts/contracts/mining/EulDistributor.sol', 72 | 'euler-contracts/contracts/mining/EulStakes.sol', 73 | ], 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "euler-liquidation-bot", 3 | "version": "1.0.0", 4 | "description": "Bot performing liquidations on the Euler platform", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "@eulerxyz/euler-sdk": "^0.2.10", 11 | "@flashbots/ethers-provider-bundle": "^0.5.0", 12 | "@nomiclabs/hardhat-ethers": "^2.0.2", 13 | "@nomiclabs/hardhat-waffle": "^2.0.1", 14 | "@uniswap/v3-periphery": "^1.1.1", 15 | "@uniswap/v3-sdk": "^3.8.2", 16 | "axios": "^0.27.2", 17 | "chai": "^4.3.4", 18 | "discord-webhook-node": "^1.1.8", 19 | "ethers": "^5.4.1", 20 | "immer": "^9.0.5" 21 | }, 22 | "devDependencies": { 23 | "dotenv": "^10.0.0", 24 | "ethereum-waffle": "^3.3.0", 25 | "euler-contracts": "github:euler-xyz/euler-contracts", 26 | "hardhat": "^2.7.0", 27 | "hardhat-dependency-compiler": "^1.1.2", 28 | "solidity-coverage": "^0.7.20" 29 | }, 30 | "scripts": { 31 | "start": "node scripts/mon.js", 32 | "start:ropsten": "NETWORK=ropsten node scripts/mon.js", 33 | "test": "npx hardhat test" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/euler-xyz/euler-liquidation-bot.git" 38 | }, 39 | "author": "Euler", 40 | "license": "ISC", 41 | "bugs": { 42 | "url": "https://github.com/euler-xyz/euler-liquidation-bot/issues" 43 | }, 44 | "homepage": "https://github.com/euler-xyz/euler-liquidation-bot#readme" 45 | } 46 | -------------------------------------------------------------------------------- /scripts/EulerToolClient.js: -------------------------------------------------------------------------------- 1 | const initialReconnectTimeout = 500; 2 | const reconnectTimeCeiling = 8000; 3 | 4 | class EulerToolClient { 5 | constructor(opts) { 6 | this.opts = opts; 7 | 8 | if (!this.opts.version) throw(`must provide version to EulerToolClient`); 9 | 10 | this.nextId = 1; 11 | this.cbs = {}; 12 | this.timeoutHandles = {}; 13 | this.subs = {}; 14 | this.pendingMessagesToSend = []; 15 | this.reconnectTimeout = initialReconnectTimeout; 16 | 17 | this.heartBeatInterval = setInterval(() => { 18 | if (this.ws === undefined || this.ws.readyState !== 1) return; 19 | this.send('ping', {}, () => {}); 20 | }, (this.opts.pingFreqMilliseconds || 55000)); 21 | } 22 | 23 | connect() { 24 | if (this.ws) { this.ws.close(); } 25 | this.ws = new this.opts.WebSocket(this.opts.endpoint); 26 | 27 | this.ws.onopen = () => { 28 | this.reconnectTimeout = initialReconnectTimeout; 29 | 30 | this.send("hello", { version: this.opts.version, }, (err, helloResponse) => { 31 | if (err) { 32 | console.error("Connection error: ", err); 33 | return; 34 | } 35 | 36 | if (this.opts.onConnect) this.opts.onConnect(helloResponse); 37 | }); 38 | 39 | for (let msg of this.pendingMessagesToSend) { 40 | this.ws.send(msg); 41 | } 42 | 43 | this.pendingMessagesToSend = []; 44 | 45 | for (let subId of Object.keys(this.subs)) { 46 | this.send('sub', this.subs[subId], this.cbs[subId], subId); 47 | } 48 | }; 49 | 50 | this.ws.onmessage = (msgStr) => { 51 | let msg = JSON.parse(msgStr.data); 52 | 53 | let cb = this.cbs[msg.id]; 54 | if (!cb) return; // probably already unsubscribed 55 | 56 | if (msg.fin) this.clearId(msg.id); 57 | 58 | cb(null, msg); 59 | }; 60 | 61 | this.ws.onclose = () => { 62 | if (this.shuttingDown) return; 63 | this.ws = undefined; 64 | 65 | if (this.timeoutWatcher) { 66 | clearTimeout(this.timeoutWatcher); 67 | } 68 | this.timeoutWatcher = setTimeout(() => this.connect(), this.reconnectTimeout); 69 | 70 | this.reconnectTimeout *= 2; 71 | if (this.reconnectTimeout > reconnectTimeCeiling) this.reconnectTimeout = reconnectTimeCeiling; 72 | 73 | if (this.opts.onDisconnect) this.opts.onDisconnect(this); 74 | }; 75 | 76 | this.ws.onerror = (e) => { 77 | let ws = this.ws; 78 | delete this.ws; 79 | ws.close(); 80 | }; 81 | } 82 | 83 | 84 | send(cmd, body, cb, idOverride, timeout) { 85 | let id = idOverride || this.nextId++; 86 | 87 | let msg = JSON.stringify({ id: parseInt(id), cmd, ...body, }); 88 | 89 | if (cb) { 90 | this.cbs[id] = cb; 91 | if (timeout) { 92 | this.timeoutHandles[id] = setTimeout(() => { 93 | this.clearId(id); 94 | cb(`timeout after ${timeout}ms`, null); 95 | }, timeout); 96 | } 97 | } 98 | 99 | if (this.ws === undefined || this.ws.readyState !== 1) { 100 | if (cmd !== 'sub' && cmd !== 'unsub') this.pendingMessagesToSend.push(msg); 101 | } else { 102 | this.ws.send(msg); 103 | } 104 | 105 | return id; 106 | } 107 | 108 | async sendAsync(cmd, body, timeout) { 109 | if (!timeout) timeout = 5000; 110 | 111 | let response = await new Promise((resolve, reject) => { 112 | let subId; subId = this.send(cmd, body, (err, result) => { 113 | if (cmd === "sub" && subId !== undefined) this.unsubscribe(subId); 114 | if (err) reject(err); 115 | else resolve(result); 116 | }, undefined, timeout); 117 | }); 118 | 119 | return response; 120 | } 121 | 122 | 123 | sub(sub, cb) { 124 | let id = this.send('sub', sub, cb); 125 | this.subs[id] = sub; 126 | return id; 127 | } 128 | 129 | unsubscribe(id) { 130 | let msg = JSON.stringify({ id: parseInt(id), cmd: 'unsub', }); 131 | 132 | if (this.ws !== undefined && this.ws.readyState === 1) { 133 | this.ws.send(msg); 134 | } 135 | 136 | this.clearId(id); 137 | } 138 | 139 | 140 | clearId(id) { 141 | delete this.cbs[id]; 142 | if (this.timeoutHandles[id]) { 143 | clearTimeout(this.timeoutHandles[id]); 144 | delete this.timeoutHandles[id]; 145 | } 146 | delete this.subs[id]; 147 | } 148 | 149 | 150 | shutdown() { 151 | this.shuttingDown = true; 152 | if (this.ws) this.ws.close(); 153 | this.ws = undefined; 154 | if (this.heartBeatInterval) clearInterval(this.heartBeatInterval); 155 | this.heartBeatInterval = undefined; 156 | } 157 | } 158 | 159 | module.exports = EulerToolClient; 160 | -------------------------------------------------------------------------------- /scripts/discordBot.js: -------------------------------------------------------------------------------- 1 | const { Webhook } = require('discord-webhook-node'); 2 | let hook; 3 | const hookUrl = process.env.DISCORD_WEBHOOK; 4 | 5 | if (hookUrl) { 6 | hook = new Webhook(hookUrl) 7 | hook.setUsername('Euler Liquidation BOT'); 8 | } 9 | 10 | module.exports = (alert) => { 11 | if (hook) return hook.send(alert); 12 | } -------------------------------------------------------------------------------- /scripts/mon.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { main } = require('./monLib'); 4 | 5 | main(); 6 | -------------------------------------------------------------------------------- /scripts/monLib.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const {enablePatches, applyPatches} = require('immer'); 3 | const ethers = require('ethers'); 4 | const { Euler } = require('@eulerxyz/euler-sdk'); 5 | 6 | const strategies = require('./strategies'); 7 | const EulerToolClient = require('./EulerToolClient.js'); 8 | const { cartesian, c1e18, txOpts } = require('./utils'); 9 | const Reporter = require('./reporter'); 10 | 11 | const NETWORK = process.env.NETWORK || 'mainnet'; 12 | const botConfig = require('../bot.config')[NETWORK]; 13 | 14 | enablePatches(); 15 | 16 | let subsData = {}; 17 | let showLogs; 18 | let euler; 19 | let reporter = { log: () => {} }; 20 | 21 | let deferredAccounts = {}; 22 | let bestStrategy; 23 | 24 | async function main() { 25 | const provider = new ethers.providers.JsonRpcProvider(botConfig.jsonRpcUrl) 26 | const wallet = new ethers.Wallet(process.env.PRV_KEY, provider) 27 | 28 | config(new Euler(wallet, botConfig.chainId)); 29 | 30 | reporter = new Reporter(botConfig.reporter); 31 | 32 | let designatedAccount = process.env.LIQUIDATE_ACCOUNT 33 | if (designatedAccount) { 34 | console.log(`ATTEMPTING LIQUIDATION OF DESIGNATED ACCOUNT ${designatedAccount}`) 35 | await liquidateDesignatedAccount(designatedAccount); 36 | process.exit(0); 37 | } 38 | doConnect(); 39 | } 40 | 41 | async function config(eul, logs = true) { 42 | showLogs = logs; 43 | euler = eul; 44 | } 45 | 46 | function setData(newData) { 47 | subsData = newData; 48 | } 49 | 50 | function log(...args) { 51 | if (showLogs) console.log(...args) 52 | } 53 | 54 | function doConnect() { 55 | let ec; ec = new EulerToolClient({ 56 | version: 'liqmon 1.0', 57 | endpoint: botConfig.eulerscan.ws, 58 | WebSocket, 59 | onConnect: () => { 60 | log("CONNECTED"); 61 | }, 62 | onDisconnect: () => { 63 | log("ORDERBOOK DISCONNECT"); 64 | subsData = {}; 65 | }, 66 | }); 67 | 68 | ec.sub({ 69 | query: { 70 | topic: "accounts", 71 | by: "healthScore", 72 | healthMax: botConfig.eulerscan.healthMax || 1000000, 73 | limit: botConfig.eulerscan.queryLimit || 500 74 | }, 75 | }, (err, patch) => { 76 | // console.log('patch: ', JSON.stringify(patch, null, 2)); 77 | if (err) { 78 | console.log(`ERROR from client: ${err}`); 79 | return; 80 | } 81 | 82 | for (let p of patch.result) p.path = p.path.split('/').filter(e => e !== ''); 83 | 84 | setData({ accounts: applyPatches(subsData.accounts, patch.result) }); 85 | processAccounts(); 86 | }); 87 | 88 | ec.connect(); 89 | } 90 | 91 | 92 | let inFlight; 93 | 94 | async function processAccounts() { 95 | if (inFlight) return; 96 | inFlight = true; 97 | let processedAccount; 98 | try { 99 | for (let act of Object.values(subsData.accounts.accounts)) { 100 | if (typeof(act) !== 'object') continue; 101 | 102 | processedAccount = act; 103 | if (act.healthScore < 1000000) { 104 | if (deferredAccounts[act.account] && deferredAccounts[act.account].until > Date.now()) { 105 | // console.log(`Skipping deferred ${act.account}`); 106 | continue; 107 | } 108 | 109 | await doLiquidation(act); 110 | break; 111 | } 112 | } 113 | } catch (e) { 114 | console.log('e: ', e); 115 | reporter.log({ type: reporter.ERROR, account: processedAccount, error: e, strategy: bestStrategy && bestStrategy.describe() }) 116 | deferAccount(processedAccount.account, 5 * 60000); 117 | } finally { 118 | inFlight = false; 119 | bestStrategy = null; 120 | } 121 | } 122 | 123 | async function liquidateDesignatedAccount(violator) { 124 | let account = await getAccountLiquidity(violator); 125 | 126 | console.log(`Account ${violator} health = ${ethers.utils.formatEther(account.healthScore)}`); 127 | if (account.healthScore.gte(c1e18)) { 128 | console.log(` Account not in violation.`); 129 | return; 130 | } 131 | 132 | await doLiquidation(account); 133 | } 134 | 135 | async function doLiquidation(act) { 136 | let isProfitable = (opportunity, feeData) => { 137 | if (Number(botConfig.minYield) === 0) return true; 138 | 139 | let gasCost = feeData.maxFeePerGas.mul(opportunity.gas); 140 | return opportunity.yield.sub(gasCost).gte(ethers.utils.parseEther(botConfig.minYield)); 141 | } 142 | 143 | let { totalLiabilities, totalCollateral, maxCollateralValue } = await getAccountLiquidity(act.account); 144 | 145 | act = { 146 | ...act, 147 | totalLiabilities, 148 | totalCollateral, 149 | } 150 | 151 | if ( 152 | botConfig.skipInsufficientCollateral && 153 | maxCollateralValue.lt(ethers.utils.parseEther(botConfig.minYield)) 154 | ) { 155 | reporter.log({ type: reporter.SKIP_INSUFFICIENT_COLLATERAL, account: act, maxCollateralValue }) 156 | deferAccount(act.account, 20 * 60000); 157 | return; 158 | } 159 | 160 | let activeStrategies = [strategies.EOASwapAndRepay]; // TODO config 161 | let collaterals = act.markets.filter(m => m.liquidityStatus.collateralValue !== '0'); 162 | let underlyings = act.markets.filter(m => m.liquidityStatus.liabilityValue !== '0'); 163 | 164 | 165 | // TODO all settled? 166 | let opportunities = await Promise.all( 167 | cartesian(collaterals, underlyings, activeStrategies).map( 168 | async ([collateral, underlying, Strategy]) => { 169 | const strategy = new Strategy(act, collateral, underlying, euler, reporter); 170 | await strategy.findBest(); 171 | return strategy; 172 | } 173 | ) 174 | ); 175 | 176 | bestStrategy = opportunities.reduce((accu, o) => { 177 | return o.best && o.best.yield.gt(accu.best.yield) ? o : accu; 178 | }, { best: { yield: 0 }}); 179 | 180 | if (bestStrategy.best.yield === 0) { 181 | deferAccount(act.account, 5 * 60000) 182 | reporter.log({ type: reporter.NO_OPPORTUNITY_FOUND, account: act }) 183 | return false; 184 | } 185 | let { opts, feeData } = await txOpts(euler.getProvider()); 186 | // use unmodified fee estimate for profitability calculation 187 | if (!isProfitable(bestStrategy.best, feeData)) { 188 | deferAccount(act.account, 10 * 60000) 189 | reporter.log({ type: reporter.YIELD_TOO_LOW, account: act, yield: bestStrategy.best.yield, gas: bestStrategy.best.gas.mul(feeData.maxFeePerGas), required: botConfig.minYield }); 190 | return false; 191 | } 192 | let tx = await bestStrategy.exec(opts, isProfitable); 193 | 194 | let botEthBalance = await euler.getSigner().getBalance(); 195 | 196 | reporter.log({ type: reporter.LIQUIDATION, account: act, tx, strategy: bestStrategy.describe(), balanceLeft: botEthBalance }); 197 | return true; 198 | } 199 | 200 | async function getAccountLiquidity(account) { 201 | let detLiq = await euler.contracts.exec.callStatic.detailedLiquidity(account); 202 | 203 | let markets = []; 204 | 205 | let totalLiabilities = ethers.BigNumber.from(0); 206 | let totalAssets = ethers.BigNumber.from(0); 207 | let maxCollateralValue = ethers.BigNumber.from(0); 208 | 209 | for (let asset of detLiq) { 210 | totalLiabilities = totalLiabilities.add(asset.status.liabilityValue); 211 | totalAssets = totalAssets.add(asset.status.collateralValue); 212 | if (maxCollateralValue.lt(asset.status.collateralValue)) maxCollateralValue = ethers.BigNumber.from(asset.status.collateralValue); 213 | 214 | markets.push({ 215 | liquidityStatus: { 216 | liabilityValue: asset.status.liabilityValue.toString(), 217 | collateralValue: asset.status.collateralValue.toString(), 218 | }, 219 | underlying: asset.underlying.toLowerCase(), 220 | }); 221 | }; 222 | 223 | let healthScore = totalAssets.mul(c1e18).div(totalLiabilities); 224 | 225 | return { 226 | totalLiabilities, 227 | totalCollateral: totalAssets, 228 | maxCollateralValue, 229 | account, 230 | healthScore, 231 | markets, 232 | } 233 | } 234 | 235 | function deferAccount(account, time) { 236 | deferredAccounts[account] = { until: Date.now() + time }; 237 | } 238 | 239 | module.exports = { 240 | main, 241 | processAccounts, 242 | config, 243 | setData, 244 | } -------------------------------------------------------------------------------- /scripts/reporter.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const discord = require('./discordBot'); 3 | const ethers = require('ethers') 4 | 5 | module.exports = class { 6 | YIELD_TOO_LOW = 1; 7 | NO_OPPORTUNITY_FOUND = 2; 8 | LIQUIDATION = 3; 9 | ERROR = 4; 10 | SKIP_INSUFFICIENT_COLLATERAL = 5; 11 | 12 | constructor(config) { 13 | this.reportingDisabled = !config; 14 | this.nextReport = {}; 15 | if (config) { 16 | this.logPath = config.logPath; 17 | setInterval(() => this.report(), config.interval * 1000); 18 | } 19 | } 20 | 21 | async report() { 22 | const countEvent = (events, type) => events.filter(e => e.type === type).length; 23 | let skipped = 0 24 | let rep = Object.entries(this.nextReport).map(([account, events]) => { 25 | 26 | // account in violation after FTX BF decrease 27 | if (account.toLowerCase() === '0xfe32a37f15ee4a4b59715530e5817d1322b9df80') { 28 | return null 29 | } 30 | // console.log('events: ', events); 31 | const yieldTooLowCount = countEvent(events, this.YIELD_TOO_LOW); 32 | const totalCollateral = parseFloat(ethers.utils.formatEther(events[events.length - 1].account.totalCollateral)).toFixed(3); 33 | if (Number(totalCollateral) < 0.5) { 34 | skipped++; 35 | return null; 36 | } 37 | const totalLiabilities = parseFloat(ethers.utils.formatEther(events[events.length - 1].account.totalLiabilities)).toFixed(3); 38 | let latestYield = ''; 39 | if (yieldTooLowCount) { 40 | latestYield = events.filter(e => e.type === this.YIELD_TOO_LOW).pop().yield; 41 | } 42 | let msg = ''; 43 | msg = `${account} HS: ${events[events.length - 1].account.healthScore / 1000000} \n`; 44 | msg += `Total collateral ETH: ${totalCollateral}, Total liabilities ETH: ${totalLiabilities} \n` 45 | msg += `Yield: ${yieldTooLowCount}${yieldTooLowCount && ` (${parseFloat(ethers.utils.formatEther(latestYield)).toFixed(6)}) ` }`; 46 | msg += `No op: ${countEvent(events, this.NO_OPPORTUNITY_FOUND)} `; 47 | msg += `Error: ${countEvent(events, this.ERROR)} \n`; 48 | return msg; 49 | }).filter(Boolean) 50 | 51 | if (rep.length === 0 && skipped === 0) { 52 | await discord('Nothing to report'); 53 | } else { 54 | rep.unshift(`REPORT ${(new Date()).toISOString()}`); 55 | rep.push(`Skipped small accounts: ${skipped}`) 56 | let buff = [] 57 | const parts = []; 58 | rep.forEach(r => { 59 | if ([...buff, r].join('\n').length < 1800) { 60 | buff.push(r); 61 | } else { 62 | parts.push([...buff]); 63 | buff = [r]; 64 | } 65 | }) 66 | parts.push(buff); 67 | 68 | for (const p of parts) { 69 | try { 70 | await discord(`\`\`\`${p.join('\n')}\`\`\``); 71 | } catch (e) { 72 | console.log('Error sending to discord', e, p); 73 | } 74 | // console.log(`\`\`\`${p.join('\n')}\`\`\``); 75 | } 76 | } 77 | 78 | this.nextReport = {}; 79 | } 80 | 81 | log(event) { 82 | event = { 83 | ...event, 84 | time: (new Date()).toISOString(), 85 | } 86 | console.log(this.describeEvent(event)); 87 | 88 | if (this.reportingDisabled) return; 89 | 90 | if (!this.nextReport[event.account.account]) this.nextReport[event.account.account] = [] 91 | this.nextReport[event.account.account].push(event); 92 | 93 | if ([this.LIQUIDATION, this.ERROR].includes(event.type)) { 94 | discord('@here ' + (this.describeEvent(event) || "").substring(0, 1900)); 95 | } 96 | 97 | fs.appendFileSync(this.logPath, this.describeEvent(event) + '\n'); 98 | } 99 | 100 | describeEvent(event) { 101 | const hs = ethers.BigNumber.isBigNumber(event.account.healthScore) 102 | ? ethers.utils.formatUnits(event.account.healthScore.div(ethers.BigNumber.from(10).pow(12)), 6) 103 | : event.account.healthScore / 1000000; 104 | const msg = `${event.time} Account: ${event.account.account} HS: ${hs}`; 105 | 106 | switch (event.type) { 107 | case this.YIELD_TOO_LOW: 108 | return `${msg} Yield too low (${ethers.utils.formatEther(event.yield)} ETH, gas: ${ethers.utils.formatEther(event.gas)} ETH), required: ${event.required}`; 109 | case this.NO_OPPORTUNITY_FOUND: 110 | return `${msg} No liquidation opportunity found`; 111 | case this.ERROR: 112 | return `${msg} ERROR ${event.error} strategy: ${event.strategy}`; 113 | case this.SKIP_INSUFFICIENT_COLLATERAL: 114 | return `${msg} SKIPPED - INSUFFICIENT COLLATERAL value: ${ethers.utils.formatEther(event.maxCollateralValue)}`; 115 | case this.LIQUIDATION: 116 | return `${msg} LIQUIDATION COMPLETED ${event.tx.transactionHash || event.tx.transaction?.hash } balance left: ${ethers.utils.formatEther(event.balanceLeft)} ${event.strategy}`; 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /scripts/strategies/BotSwapAndRepay.js: -------------------------------------------------------------------------------- 1 | const { cartesian, filterOutRejected } = require("../utils"); 2 | const hre = require('hardhat') 3 | 4 | class BotSwapAndRepay { 5 | constructor(act, collateral, underlying, ctx) { 6 | this.ctx = ctx; 7 | this.bot = liquidationBotContract; 8 | this.act = act; 9 | this.collateral = collateral; 10 | this.underlying = underlying; 11 | this.best = { yield: 0}; 12 | this.name = 'BotSwapAndRepay' 13 | } 14 | 15 | async findBest() { 16 | const feeLevels = [500, 3000, 10000]; 17 | let paths; 18 | 19 | if (this.collateral.underlying.toLowerCase() === WETH.toLowerCase()) { 20 | paths = feeLevels.map(fee => { 21 | return this.encodePath([collateral.underlying, underlying.underlying], [fee]); 22 | }); 23 | } else { 24 | // TODO explosion! try auto router, sdk 25 | paths = cartesian(feeLevels, feeLevels).map(([feeIn, feeOut]) => { 26 | return this.encodePath([this.underlying.underlying, WETH, this.collateral.underlying], [feeIn, feeOut]); 27 | }); 28 | } 29 | // console.log('paths: ', paths); 30 | 31 | let tests = await Promise.allSettled( 32 | paths.map(async (swapPath) => { 33 | return { 34 | swapPath, 35 | yield: await this.testUniswapLiquidation(swapPath) 36 | }; 37 | }) 38 | ); 39 | 40 | // TODO retry failed or continue 41 | // console.log('tests: ', tests); 42 | 43 | tests = filterOutRejected(tests, (i, err) => { 44 | // console.log(`Failed uniswap test ${this.act}, ${this.collateral.symbol} / ${this.underlying.symbol}: ${paths[i]} ${err}`) 45 | }) 46 | 47 | 48 | const best = tests.reduce((accu, t) => { 49 | return t.yield.gt(accu.yield) ? t : accu; 50 | }, { swapPath: null, yield: 0 }); 51 | 52 | 53 | this.best = best.yield.gt(0) ? best : null; 54 | } 55 | 56 | async exec() { 57 | if (!this.best) throw 'No opportunity found yet!'; 58 | 59 | let tx = await this.bot.liquidate( 60 | this.uniswapLiquidationParams(this.best.swapPath) 61 | ); 62 | 63 | let res = await tx.wait(); 64 | return res; 65 | } 66 | 67 | logBest() { 68 | if (!this.best) { 69 | console.log('No opportunity found') 70 | } else { 71 | console.log(`BotSwapAndRepay c: ${this.collateral.symbol} u: ${this.underlying.symbol} yield: ${this.best.yield.toString()} path ${this.best.swapPath}`); 72 | } 73 | } 74 | 75 | // PRIVATE 76 | 77 | uniswapLiquidationParams(swapPath) { 78 | return { 79 | eulerAddr: this.eulerAddresses.euler, 80 | liquidationAddr: this.eulerAddresses.liquidation, 81 | execAddr: this.eulerAddresses.exec, 82 | marketsAddr: this.eulerAddresses.markets, 83 | swapAddr: this.eulerAddresses.swap, 84 | 85 | swapPath, 86 | 87 | violator: this.act.account, 88 | underlying: this.underlying.underlying, 89 | collateral: this.collateral.underlying, 90 | } 91 | } 92 | 93 | 94 | 95 | async testUniswapLiquidation(swapPath) { 96 | let res = await this.bot.callStatic.testLiquidation( 97 | this.uniswapLiquidationParams(swapPath) 98 | ); 99 | // console.log(`Uniswap test yield: ${res.toString()} ${this.act.account}, c: ${this.collateral.symbol}, u: ${this.underlying.symbol}, ${swapPath}'`); 100 | return res; 101 | } 102 | 103 | encodePath(path, fees) { 104 | const FEE_SIZE = 3 105 | 106 | if (path.length != fees.length + 1) { 107 | throw new Error('path/fee lengths do not match') 108 | } 109 | 110 | let encoded = '0x' 111 | for (let i = 0; i < fees.length; i++) { 112 | // 20 byte encoding of the address 113 | encoded += path[i].slice(2) 114 | // 3 byte encoding of the fee 115 | encoded += fees[i].toString(16).padStart(2 * FEE_SIZE, '0') 116 | } 117 | // encode the final token 118 | encoded += path[path.length - 1].slice(2) 119 | 120 | return encoded.toLowerCase() 121 | } 122 | } 123 | 124 | module.exports = BotSwapAndRepay; 125 | 126 | -------------------------------------------------------------------------------- /scripts/strategies/EOASwapAndRepay.js: -------------------------------------------------------------------------------- 1 | let ethers = require('ethers'); 2 | let axios = require('axios'); 3 | let { FlashbotsBundleProvider, FlashbotsTransactionResolution } = require('@flashbots/ethers-provider-bundle'); 4 | let { cartesian, filterOutRejected, c1e18, txOpts } = require('../utils'); 5 | let { utils } = require('@eulerxyz/euler-sdk'); 6 | 7 | let FLASHBOTS_RELAY_SIGNING_KEY = process.env.FLASHBOTS_RELAY_SIGNING_KEY; 8 | let ONEINCH_API_URL = process.env.ONEINCH_API_URL; 9 | let useFlashbots = process.env.USE_FLASHBOTS === 'true'; 10 | let flashbotsMaxBlocks = Number(process.env.FLASHBOTS_MAX_BLOCKS); 11 | let flashbotsDisableFallback = process.env.FLASHBOTS_DISABLE_FALLBACK === 'true'; 12 | 13 | let receiverSubAccountId = Number(process.env.RECEIVER_SUBACCOUNT_ID); 14 | 15 | let MAX_UINT = ethers.constants.MaxUint256; 16 | let formatUnits = ethers.utils.formatUnits; 17 | let parseUnits = ethers.utils.parseUnits; 18 | 19 | let SWAPHUB_MODE_EXACT_OUTPUT = 1; 20 | 21 | class EOASwapAndRepay { 22 | constructor(act, collateral, underlying, euler, reporter) { 23 | this.act = act; 24 | this.euler = euler; 25 | this.violator = act.account; 26 | this.liquidator = euler.getSigner().address; 27 | this.receiver = receiverSubAccountId ? utils.getSubAccount(this.liquidator, receiverSubAccountId) : this.liquidator 28 | this.collateralAddr = collateral.underlying.toLowerCase(); 29 | this.underlyingAddr = underlying.underlying.toLowerCase(); 30 | this.refAsset = euler.referenceAsset.toLowerCase(); 31 | this.best = null; 32 | this.name = 'EOASwapAndRepay'; 33 | this.isProtectedCollateral = false; 34 | this.reporter = reporter || console; 35 | } 36 | 37 | async findBest() { 38 | let paths; 39 | let feeLevels = [100, 500, 3000, 10000]; 40 | 41 | let protectedUnderlying 42 | try { 43 | protectedUnderlying = await this.euler.pToken(this.collateralAddr).underlying(); 44 | } catch {} 45 | 46 | if (protectedUnderlying) { 47 | let u2p = await this.euler.contracts.markets.underlyingToPToken(protectedUnderlying); 48 | if (this.collateralAddr.toLowerCase() === u2p.toLowerCase()) { 49 | this.isProtectedCollateral = true; 50 | this.unwrappedCollateralAddr = protectedUnderlying.toLowerCase(); 51 | let unwrappedEToken = await this.euler.contracts.markets.underlyingToEToken(protectedUnderlying); 52 | this.unwrappedCollateralEToken = this.euler.eToken(unwrappedEToken); 53 | 54 | let allowance = await this.euler.erc20(this.unwrappedCollateralAddr).allowance(this.liquidator, this.euler.addresses.euler); 55 | let { opts } = await txOpts(this.euler.getProvider()) 56 | if (allowance.eq(0)) { 57 | await (await this.euler.erc20(this.unwrappedCollateralAddr).approve( 58 | this.euler.addresses.euler, 59 | MAX_UINT, 60 | ({...opts, gasLimit: 300000}) 61 | )).wait(); 62 | } 63 | } 64 | } 65 | 66 | this.finalCollateralAddr = this.isProtectedCollateral ? this.unwrappedCollateralAddr : this.collateralAddr; 67 | 68 | this.collateralEToken = await this.euler.eTokenOf(this.collateralAddr); 69 | this.collateralToken = this.euler.erc20(this.collateralAddr); 70 | this.collateralDecimals = await this.euler.erc20(this.finalCollateralAddr).decimals(); 71 | this.underlyingEToken = await this.euler.eTokenOf(this.underlyingAddr); 72 | this.underlyingDecimals = await this.euler.erc20(this.underlyingAddr).decimals(); 73 | 74 | let liqOpp = await this.euler.contracts.liquidation.callStatic.checkLiquidation( 75 | this.liquidator, 76 | this.violator, 77 | this.underlyingAddr, 78 | this.collateralAddr, 79 | ); 80 | 81 | if (liqOpp.repay.eq(0)) return; 82 | 83 | if ([this.finalCollateralAddr, this.underlyingAddr].includes(this.refAsset)) { 84 | paths = feeLevels.map(fee => { 85 | return this.encodePath([this.underlyingAddr, this.finalCollateralAddr], [fee]); 86 | }); 87 | } else { 88 | // TODO explosion! try auto router, sdk 89 | // TODO don't do combination if collateral is the same as underlying - burn conversion item 90 | paths = cartesian(feeLevels, feeLevels).map(([feeIn, feeOut]) => { 91 | return this.encodePath([this.underlyingAddr, this.refAsset, this.finalCollateralAddr], [feeIn, feeOut]); 92 | }); 93 | } 94 | 95 | let repayFraction = 98; 96 | while (!this.best && repayFraction >= 49) { 97 | let repay = liqOpp.repay.mul(repayFraction).div(100); 98 | let unwrapAmount; 99 | if (this.isProtectedCollateral) { 100 | unwrapAmount = await this.getYieldByRepay(repay); 101 | } 102 | 103 | let oneInchQuote 104 | if (this.underlyingAddr !== this.finalCollateralAddr) { 105 | try { 106 | oneInchQuote = await this.getOneInchQuote(repay.div(ethers.BigNumber.from(10).pow(18 - this.underlyingDecimals))); 107 | } catch (e) { 108 | console.log('e: ', e); 109 | if (!( 110 | e.response && e.response.data && e.response.data && e.response.data.description === 'insufficient liquidity' 111 | || e.message && e.message.includes('zero estimated amount') 112 | )) { 113 | this.reporter.log({ 114 | type: this.reporter.ERROR, 115 | account: this.act, 116 | error: `Failed fetching 1inch quote`, 117 | strategy: this.describe(), 118 | }); 119 | } 120 | } 121 | } 122 | let tests = await Promise.allSettled( 123 | paths.map(async (path) => { 124 | let { yieldEth, gas } = await this.testLiquidation(path, repay, unwrapAmount, oneInchQuote) 125 | return { 126 | swapPath: path, 127 | repay, 128 | yield: yieldEth, 129 | unwrapAmount, 130 | oneInchQuote, 131 | gas, 132 | }; 133 | }) 134 | ); 135 | 136 | // TODO retry failed or continue 137 | tests = filterOutRejected(tests, (i, err) => { 138 | // console.log(`EOASwapAndRepay failed test ${this.violator}, c: ${this.collateralAddr} u: ${this.underlyingAddr} path: ${paths[i]} error: ${err}`) 139 | }) 140 | 141 | let best = tests.reduce((accu, t) => { 142 | return t.yield.gt(accu.yield) ? t : accu; 143 | }, { swapPath: null, yield: ethers.BigNumber.from(0) }); 144 | 145 | 146 | this.best = best.yield.gt(0) ? best : null; 147 | 148 | repayFraction = Math.floor(repayFraction / 2); 149 | } 150 | } 151 | 152 | async exec(opts, isProfitable) { 153 | if (!this.best) throw 'No opportunity found yet!'; 154 | 155 | let execRegularTx = async (opts) => { 156 | let batch = this.buildLiqBatch(this.best.swapPath, this.best.repay, this.best.unwrapAmount, this.best.oneInchQuote); 157 | 158 | return await ( 159 | await this.euler.contracts.exec.batchDispatch( 160 | this.euler.buildBatch(batch), 161 | [this.liquidator], 162 | opts 163 | ) 164 | ).wait(); 165 | } 166 | 167 | if (useFlashbots) { 168 | try { 169 | let provider = this.euler.getProvider(); 170 | let signer = this.euler.getSigner(); 171 | let flashbotsRelaySigningWallet = FLASHBOTS_RELAY_SIGNING_KEY 172 | ? new ethers.Wallet(FLASHBOTS_RELAY_SIGNING_KEY) 173 | : ethers.Wallet.createRandom(); 174 | 175 | let flashbotsProvider = await FlashbotsBundleProvider.create( 176 | provider, 177 | flashbotsRelaySigningWallet, 178 | ...(this.euler.chainId === 5 ? ['https://relay-goerli.flashbots.net/', 'goerli'] : []), 179 | ); 180 | 181 | let tx = await this.euler.contracts.exec.populateTransaction.batchDispatch( 182 | this.euler.buildBatch(this.buildLiqBatch(this.best.swapPath, this.best.repay, this.best.unwrapAmount, this.best.oneInchQuote)), 183 | [this.liquidator], 184 | opts, 185 | ); 186 | 187 | tx = { 188 | ...tx, 189 | type: 2, 190 | chainId: this.euler.chainId, 191 | nonce: await provider.getTransactionCount(signer.address), 192 | }; 193 | 194 | let blockNumber = await this.euler.getProvider().getBlockNumber(); 195 | 196 | let signedTransaction = await signer.signTransaction(tx); 197 | let simulation = await flashbotsProvider.simulate( 198 | [signedTransaction], 199 | blockNumber + 1, 200 | ); 201 | 202 | if (simulation.error) { 203 | throw new Error(simulation.error.message); 204 | } 205 | if (simulation.firstRevert) { 206 | throw new Error(`${simulation.firstRevert.error} ${simulation.firstRevert.revert}`); 207 | } 208 | 209 | let privateTx = { 210 | transaction: tx, 211 | signer, 212 | }; 213 | let fbOpts = flashbotsMaxBlocks > 0 214 | ? { maxBlockNumber: blockNumber + flashbotsMaxBlocks } 215 | : {}; 216 | let submission = await flashbotsProvider.sendPrivateTransaction( 217 | privateTx, 218 | fbOpts 219 | ); 220 | 221 | if (submission.error) { 222 | throw new Error(submission.error.message); 223 | } 224 | 225 | let txResolution = await submission.wait(); 226 | 227 | if (txResolution !== FlashbotsTransactionResolution.TransactionIncluded) { 228 | throw new Error('Transaction dropped'); 229 | } 230 | 231 | return submission; 232 | } catch (e) { 233 | console.log('e: ', e); 234 | 235 | if (!flashbotsDisableFallback) { 236 | this.reporter.log({ 237 | type: this.reporter.ERROR, 238 | account: this.act, 239 | error: `Flashbots error, falling back to regular tx. err: "${e}"`, 240 | strategy: this.describe(), 241 | }); 242 | // recalculate opportunity 243 | await this.findBest(); 244 | let { opts: newOpts, feeData } = await txOpts(this.euler.getProvider()); 245 | if (!isProfitable(this.best, feeData)) throw new Error('Fallback tx is no longer profitable'); 246 | 247 | return execRegularTx(newOpts); 248 | } else { 249 | throw e; 250 | } 251 | } 252 | } 253 | 254 | return execRegularTx(opts); 255 | } 256 | 257 | describe() { 258 | return this.best 259 | ? `EOASwapAndRepay c: ${this.collateralAddr}, u: ${this.underlyingAddr}, repay: ${this.best.repay.toString()} ` 260 | +`yield: ${ethers.utils.formatEther(this.best.yield)} ETH, path ${this.best.swapPath}` 261 | : 'EOASwapAndRepay: No opportunity found'; 262 | } 263 | 264 | // PRIVATE 265 | 266 | buildLiqBatch(swapPath, repay, unwrapAmount, oneInchQuote) { 267 | let conversionItems = []; 268 | 269 | let collateralEToken = this.isProtectedCollateral ? this.unwrappedCollateralEToken : this.collateralEToken; 270 | 271 | if (this.isProtectedCollateral) { 272 | conversionItems.push( 273 | { 274 | contract: this.collateralEToken, 275 | method: 'withdraw', 276 | args: [0, MAX_UINT], 277 | }, 278 | { 279 | contract: 'exec', 280 | method: 'pTokenUnWrap', 281 | args: [ 282 | this.unwrappedCollateralAddr, 283 | unwrapAmount 284 | ] 285 | }, 286 | { 287 | contract: this.unwrappedCollateralEToken, 288 | method: 'deposit', 289 | args: [0, MAX_UINT] 290 | }, 291 | ) 292 | } 293 | 294 | if (this.underlyingAddr === this.finalCollateralAddr) { 295 | // TODO test 296 | conversionItems.push( 297 | { 298 | contract: collateralEToken, 299 | method: 'burn', 300 | args: [ 301 | 0, 302 | MAX_UINT, 303 | ], 304 | } 305 | ); 306 | } else { 307 | if (oneInchQuote) { 308 | conversionItems.push( 309 | { 310 | contract: 'swapHub', 311 | method: 'swapAndRepay', 312 | args: [ 313 | 0, // sub-account in 314 | 0, // sub-account out 315 | this.euler.addresses.swapHandler1Inch, 316 | { 317 | underlyingIn: this.finalCollateralAddr, 318 | underlyingOut: this.underlyingAddr, 319 | mode: SWAPHUB_MODE_EXACT_OUTPUT, 320 | amountIn: MAX_UINT, // MAX SLIPPAGE ALLOWED! Assuming the bot doesn't hold any token balances before the liquidation 321 | amountOut: 0, // Ignored by swapAndRepay 322 | // Arbitrary 1000 wei to account for fee on transfer or rebasing tokens like stETH. 323 | // For tokens with less decimals than 15 decimals it will be ineffective. 324 | exactOutTolerance: 1000, 325 | payload: ethers.utils.defaultAbiCoder.encode( 326 | ["bytes", "bytes"], 327 | [oneInchQuote.payload, swapPath], 328 | ), 329 | }, 330 | 0, // target debt 331 | ] 332 | }, 333 | ) 334 | } else { 335 | conversionItems.push( 336 | { 337 | contract: 'swapHub', 338 | method: 'swapAndRepay', 339 | args: [ 340 | 0, 341 | 0, 342 | this.euler.addresses.swapHandlerUniswapV3, 343 | { 344 | underlyingIn: this.finalCollateralAddr, 345 | underlyingOut: this.underlyingAddr, 346 | amountIn: MAX_UINT, // MAX SLIPPAGE ALLOWED 347 | amountOut: 0, // ignored 348 | mode: SWAPHUB_MODE_EXACT_OUTPUT, 349 | exactOutTolerance: 1000, 350 | payload: swapPath, 351 | }, 352 | 0, 353 | ], 354 | }, 355 | ); 356 | } 357 | } 358 | 359 | return [ 360 | { 361 | contract: 'liquidation', 362 | method: 'liquidate', 363 | args: [ 364 | this.violator, 365 | this.underlyingAddr, 366 | this.collateralAddr, 367 | repay, 368 | 0, 369 | ], 370 | }, 371 | ...conversionItems, 372 | { 373 | contract: 'markets', 374 | method: 'exitMarket', 375 | args: [ 376 | 0, 377 | this.underlyingAddr, 378 | ], 379 | }, 380 | ...(this.liquidator !== this.receiver 381 | ? [{ 382 | contract: this.collateralEToken, 383 | method: 'transferFromMax', 384 | args: [this.liquidator, this.receiver], 385 | }] 386 | : [] 387 | ) 388 | ]; 389 | } 390 | 391 | async testLiquidation(swapPath, repay, unwrapAmount, oneInchQuote) { 392 | const targetCollateralEToken = this.isProtectedCollateral ? this.unwrappedCollateralEToken : this.collateralEToken; 393 | 394 | let batchItems = [ 395 | { 396 | contract: targetCollateralEToken, 397 | method: 'balanceOfUnderlying', 398 | args: [ 399 | this.receiver, 400 | ] 401 | }, 402 | ...this.buildLiqBatch(swapPath, repay, unwrapAmount, oneInchQuote), 403 | { 404 | contract: 'exec', 405 | method: 'getPriceFull', 406 | args: [ 407 | this.collateralAddr, 408 | ], 409 | }, 410 | { 411 | contract: targetCollateralEToken, 412 | method: 'balanceOfUnderlying', 413 | args: [ 414 | this.receiver, 415 | ], 416 | }, 417 | ]; 418 | let simulation, error, gas; 419 | ({ simulation, error, gas } = await this.euler.simulateBatch([this.liquidator], batchItems)); 420 | if (error) throw error.value; 421 | 422 | let balanceBefore = simulation[0].response[0]; 423 | let balanceAfter = simulation[simulation.length - 1].response[0]; 424 | 425 | if (balanceAfter.lte(balanceBefore)) throw `No yield ${repay} ${swapPath}`; 426 | let yieldCollateral = balanceAfter.sub(balanceBefore); 427 | 428 | let yieldEth = yieldCollateral 429 | .mul(ethers.BigNumber.from(10).pow(18 - this.collateralDecimals)) 430 | .mul(simulation[simulation.length - 2].response.currPrice).div(c1e18); 431 | 432 | return { yieldEth, gas }; 433 | } 434 | 435 | encodePath(path, fees) { 436 | let FEE_SIZE = 3; 437 | 438 | if (path.length != fees.length + 1) { 439 | throw new Error('path/fee lengths do not match'); 440 | } 441 | 442 | let encoded = '0x'; 443 | for (let i = 0; i < fees.length; i++) { 444 | // 20 byte encoding of the address 445 | encoded += path[i].slice(2); 446 | // 3 byte encoding of the fee 447 | encoded += fees[i].toString(16).padStart(2 * FEE_SIZE, '0'); 448 | } 449 | // encode the final token 450 | encoded += path[path.length - 1].slice(2); 451 | 452 | return encoded.toLowerCase(); 453 | } 454 | 455 | async getYieldByRepay(repay) { 456 | let batch = [ 457 | { 458 | contract: this.collateralEToken, 459 | method: 'balanceOfUnderlying', 460 | args: [ 461 | this.liquidator, 462 | ] 463 | }, 464 | { 465 | contract: 'liquidation', 466 | method: 'liquidate', 467 | args: [ 468 | this.violator, 469 | this.underlyingAddr, 470 | this.collateralAddr, 471 | repay, 472 | 0, 473 | ], 474 | }, 475 | { 476 | contract: this.collateralEToken, 477 | method: 'balanceOfUnderlying', 478 | args: [ 479 | this.liquidator, 480 | ] 481 | }, 482 | ]; 483 | 484 | let { simulation } = await this.euler.simulateBatch([this.liquidator], batch); 485 | 486 | let balanceBefore = simulation[0].response[0]; 487 | let balanceAfter = simulation[simulation.length - 1].response[0]; 488 | 489 | return balanceAfter.sub(balanceBefore); 490 | } 491 | 492 | async getOneInchQuote(targetAmountOut) { 493 | if (!ONEINCH_API_URL) return; 494 | 495 | let getQuote = async amount => { 496 | let searchParams = new URLSearchParams({ 497 | fromTokenAddress: this.finalCollateralAddr, 498 | toTokenAddress: this.underlyingAddr, 499 | amount: amount.toString(), 500 | disableEstimate: "true", 501 | destReceiver: this.euler.addresses.euler, 502 | fromAddress: this.euler.addresses.swapHandler1Inch, 503 | allowPartialFill: "false", 504 | slippage: "50", // max slippage 505 | }) 506 | let err 507 | for (let i = 0; i < 3; i++) { 508 | await new Promise(r => setTimeout(r, i * 100)); 509 | 510 | try { 511 | let { data } = await axios.get(`${ONEINCH_API_URL}?${searchParams.toString()}`); 512 | 513 | return data; 514 | } catch (e) { 515 | console.log(e); 516 | err = e; 517 | } 518 | } 519 | 520 | throw err; 521 | } 522 | 523 | let findEstimatedAmountIn = async () => { 524 | let fromDecimals = this.collateralDecimals; 525 | let toDecimals = this.underlyingDecimals; 526 | 527 | let unitQuote = await getQuote( 528 | ethers.utils.parseUnits("1", fromDecimals), 529 | ); 530 | 531 | let unitAmountTo = ethers.BigNumber.from(unitQuote.toTokenAmount); 532 | 533 | let fromAmount = targetAmountOut; 534 | // adjust scale to match token from 535 | if (fromDecimals > toDecimals) { 536 | fromAmount = fromAmount.mul( 537 | ethers.BigNumber.from("10").pow(fromDecimals - toDecimals), 538 | ); 539 | } else { 540 | fromAmount = fromAmount.div( 541 | ethers.BigNumber.from("10").pow(toDecimals - fromDecimals), 542 | ); 543 | } 544 | // divide by unit price 545 | return fromAmount 546 | .mul(ethers.utils.parseUnits("1", toDecimals)) 547 | .div(unitAmountTo); 548 | }; 549 | 550 | let find1InchRoute = async ( 551 | targetAmountTo, 552 | amountFrom, 553 | shouldContinue, 554 | ) => { 555 | let result; 556 | let percentageChange = 10000; // 100% no change 557 | let cnt = 0; 558 | do { 559 | amountFrom = amountFrom.mul(percentageChange).div(10000); 560 | result = await getQuote(amountFrom); 561 | let swapAmountTo = ethers.BigNumber.from(result.toTokenAmount); 562 | percentageChange = swapAmountTo.eq(targetAmountTo) 563 | ? 9990 // result equal target, push input down by 0.1% 564 | : swapAmountTo.gt(targetAmountTo) 565 | ? // result above target, adjust input down by the percentage difference of outputs - 0.1% 566 | swapAmountTo 567 | .sub(targetAmountTo) 568 | .mul(10000) 569 | .div(swapAmountTo) 570 | .add(10) 571 | .sub(10000) 572 | .abs() 573 | : // result below target, adjust input by the percentege difference of outputs + 0.1% 574 | targetAmountTo 575 | .sub(swapAmountTo) 576 | .mul(10000) 577 | .div(swapAmountTo) 578 | .add(10000) 579 | .add(10); 580 | 581 | if (cnt++ === 15) throw new Error("Failed fetching quote in 15 iterations"); 582 | } while (shouldContinue(result)); 583 | 584 | return { amountFrom, result }; 585 | }; 586 | 587 | // rough estimate by calculating execution price on a unit trade 588 | let estimatedAmountIn = await findEstimatedAmountIn(); 589 | if (estimatedAmountIn.eq(0)) throw new Error('zero estimated amount') 590 | 591 | let { amountFrom, result } = await find1InchRoute( 592 | targetAmountOut, 593 | estimatedAmountIn, 594 | // search until quote is 99.5 - 100% target 595 | result => 596 | targetAmountOut.lte(result.toTokenAmount) 597 | || ( 598 | ethers.BigNumber.from(result.toTokenAmount).mul(1000).div(targetAmountOut).lt(995) 599 | && ethers.BigNumber.from(result.toTokenAmount).gte(1000) // for dust amounts the 0.5% accuracy might not be possible 600 | ), 601 | ); 602 | 603 | return { 604 | amount: amountFrom, 605 | payload: result.tx.data, 606 | }; 607 | } 608 | } 609 | 610 | module.exports = EOASwapAndRepay; 611 | -------------------------------------------------------------------------------- /scripts/strategies/index.js: -------------------------------------------------------------------------------- 1 | // const BotSwapAndRepay = require('./BotSwapAndRepay'); 2 | const EOASwapAndRepay = require('./EOASwapAndRepay'); 3 | 4 | module.exports = { 5 | // BotSwapAndRepay, 6 | EOASwapAndRepay, 7 | }; 8 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | const { BigNumber, utils } = require('ethers'); 2 | 3 | const cartesian = (...a) => a.reduce((a, b) => a.flatMap(d => b.map(e => [d, e].flat()))); 4 | 5 | const filterOutRejected = (results, onErr) => results.map((r, i) => { 6 | if (r.status === 'rejected') { 7 | if (typeof onErr === 'function') onErr(i, r.reason); 8 | return null; 9 | } 10 | return r.value; 11 | }) 12 | .filter(Boolean); 13 | 14 | const c1e18 = BigNumber.from(10).pow(18); 15 | 16 | const txOpts = async (provider) => { 17 | let opts = {}; 18 | let feeData = await provider.getFeeData(); 19 | 20 | opts.maxFeePerGas = feeData.maxFeePerGas; 21 | opts.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; 22 | 23 | if (process.env.TX_FEE_MUL !== undefined) { 24 | let feeMul = parseFloat(process.env.TX_FEE_MUL); 25 | 26 | opts.maxFeePerGas = BigNumber.from(Math.floor(opts.maxFeePerGas.toNumber() * feeMul)); 27 | opts.maxPriorityFeePerGas = BigNumber.from(Math.floor(opts.maxPriorityFeePerGas.toNumber() * feeMul)); 28 | } 29 | 30 | if (process.env.TX_NONCE !== undefined) { 31 | opts.nonce = parseInt(process.env.TX_NONCE); 32 | } 33 | 34 | if (process.env.TX_GAS_LIMIT !== undefined) { 35 | opts.gasLimit = parseInt(process.env.TX_GAS_LIMIT); 36 | } 37 | 38 | return { opts, feeData }; 39 | }; 40 | 41 | module.exports = { 42 | cartesian, 43 | filterOutRejected, 44 | c1e18, 45 | txOpts, 46 | } -------------------------------------------------------------------------------- /test/contract.js: -------------------------------------------------------------------------------- 1 | const et = require('euler-contracts/test/lib/eTestLib.js').config(`${__dirname}/lib/eTestLib.config.js`); 2 | const { provisionUniswapPool, deposit, } = require('./lib/helpers'); 3 | 4 | et.testSet({ 5 | desc: "bot contract", 6 | fixture: "testing-real-uniswap-activated", 7 | 8 | preActions: ctx => [ 9 | // deployBot(ctx), 10 | 11 | { action: 'setIRM', underlying: 'WETH', irm: 'IRM_ZERO', }, 12 | { action: 'setIRM', underlying: 'TST', irm: 'IRM_ZERO', }, 13 | { action: 'setIRM', underlying: 'TST2', irm: 'IRM_ZERO', }, 14 | { action: 'setAssetConfig', tok: 'WETH', config: { borrowFactor: .4}, }, 15 | { action: 'setAssetConfig', tok: 'TST', config: { borrowFactor: .4}, }, 16 | { action: 'setAssetConfig', tok: 'TST2', config: { borrowFactor: .4}, }, 17 | 18 | // wallet is lender and liquidator 19 | ...deposit(ctx, 'TST'), 20 | ...deposit(ctx, 'WETH'), 21 | 22 | // wallet2 is borrower/violator 23 | ...deposit(ctx, 'TST2', ctx.wallet2), 24 | { from: ctx.wallet2, send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST2.address], }, 25 | 26 | // wallet 5 is the super whale 27 | ...provisionUniswapPool(ctx, 'TST/WETH', ctx.wallet5, et.eth(1000)), 28 | ...provisionUniswapPool(ctx, 'TST2/WETH', ctx.wallet5, et.eth(1000)), 29 | ...provisionUniswapPool(ctx, 'TST3/WETH', ctx.wallet5, et.eth(1000)), 30 | 31 | // initial prices 32 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST', dir: 'buy', amount: et.eth(10_000), priceLimit: 2.2, }, 33 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST2', dir: 'sell', amount: et.eth(10_000), priceLimit: 0.4 }, 34 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST3', dir: 'buy', amount: et.eth(10_000), priceLimit: 1.7 }, 35 | 36 | // wait for twap 37 | { action: 'checkpointTime', }, 38 | { action: 'jumpTimeAndMine', time: 3600 * 30 }, 39 | ], 40 | }) 41 | 42 | 43 | .test({ 44 | desc: "basic full liquidation", 45 | actions: ctx => [ 46 | { from: ctx.wallet2, send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, 47 | 48 | { callStatic: 'exec.liquidity', args: [ctx.wallet2.address], onResult: r => { 49 | et.equals(r.collateralValue / r.liabilityValue, 1.09, 0.01); 50 | }, }, 51 | 52 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST', dir: 'buy', amount: et.eth(10_000), priceLimit: 2.5 }, 53 | 54 | { action: 'checkpointTime', }, 55 | { action: 'jumpTimeAndMine', time: 3600 * 30 * 100 }, 56 | 57 | { callStatic: 'exec.liquidity', args: [ctx.wallet2.address], onResult: r => { 58 | et.equals(r.collateralValue / r.liabilityValue, 0.96, 0.001); 59 | }, }, 60 | 61 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.tokens.TST2.address], 62 | onResult: r => { 63 | et.equals(r.healthScore, 0.96, 0.001); 64 | ctx.stash.repay = r.repay; 65 | ctx.stash.yield = r.yield; 66 | }, 67 | }, 68 | 69 | { action: 'snapshot'}, 70 | 71 | // liquidate with the bot 72 | { send: 'liquidationBot.liquidate', args: [async () => ({ 73 | eulerAddr: ctx.contracts.euler.address, 74 | liquidationAddr: ctx.contracts.liquidation.address, 75 | execAddr: ctx.contracts.exec.address, 76 | swapAddr: ctx.contracts.swap.address, 77 | marketsAddr: ctx.contracts.markets.address, 78 | 79 | swapPath: await ctx.encodeUniswapPath(['TST2/WETH', 'TST/WETH'], 'TST2', 'TST', true), 80 | 81 | violator: ctx.wallet2.address, 82 | underlying: ctx.contracts.tokens.TST.address, 83 | collateral: ctx.contracts.tokens.TST2.address, 84 | })], }, 85 | 86 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.tokens.TST2.address], 87 | onResult: r => { 88 | et.equals(r.healthScore, 1.25, 0.001); 89 | }, 90 | }, 91 | 92 | { action: 'revert' }, 93 | 94 | // manual liquidation 95 | { action: 'sendBatch', deferLiquidityCheck: [ctx.wallet.address], batch: [ 96 | { send: 'liquidation.liquidate', args: [ 97 | ctx.wallet2.address, 98 | ctx.contracts.tokens.TST.address, 99 | ctx.contracts.tokens.TST2.address, 100 | () => ctx.stash.repay, 101 | 0 102 | ]}, 103 | { send: 'swap.swapAndRepayUni', args: [ 104 | async () => ({ 105 | subAccountIdIn: 0, 106 | subAccountIdOut: 0, 107 | amountOut: 0, 108 | amountInMaximum: et.MaxUint256, 109 | deadline: 0, 110 | path: await ctx.encodeUniswapPath(['TST2/WETH', 'TST/WETH'], 'TST2', 'TST', true), 111 | }), 112 | 0 113 | ]}, 114 | { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST.address] }, 115 | ]}, 116 | 117 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.tokens.TST2.address], 118 | onResult: r => { 119 | et.equals(r.healthScore, 1.25, 0.001); 120 | }, 121 | }, 122 | ] 123 | }) 124 | 125 | .run(); 126 | 127 | -------------------------------------------------------------------------------- /test/lib/botTestLib.js: -------------------------------------------------------------------------------- 1 | const et = require('euler-contracts/test/lib/eTestLib.js'); 2 | const { setData, processAccounts } = require('../../scripts/monLib'); 3 | 4 | const deploy = async (ctx) => { 5 | const liquidationBotFactory = await ethers.getContractFactory('LiquidationBot'); 6 | ctx.contracts.liquidationBot = await (await liquidationBotFactory.deploy()).deployed(); 7 | }; 8 | 9 | 10 | const runConnector = async (ctx) => { 11 | const eulerscanData = async accounts => { 12 | const res = await ctx.contracts.eulerGeneralView.callStatic.doQueryBatch(accounts.map(account => ({ 13 | eulerContract: ctx.contracts.euler.address, 14 | account, 15 | markets: [], 16 | }))); 17 | 18 | const collateralValue = m => m.eTokenBalanceUnderlying.mul(m.twap).div(et.units(1, m.decimals)).mul(m.config.collateralFactor).div(4e9); 19 | const liabilityValue = m => m.dTokenBalance.mul(m.twap).div(et.units(1, m.decimals)).mul(4e9).div(m.config.borrowFactor); 20 | return res.reduce((accu, r, i) => { 21 | const totalLiabilities = r.markets.reduce((accu, m) => liabilityValue(m).add(accu), ethers.BigNumber.from(0)); 22 | const healthScore = totalLiabilities.eq(0) 23 | ? '10000000' 24 | : r.markets.reduce((accu, m) => collateralValue(m).add(accu), 0) 25 | .mul(1e6) 26 | .div(totalLiabilities) 27 | .toString(); 28 | 29 | accu.accounts.accounts[String(i + 1)] = { 30 | account: accounts[i], 31 | healthScore, 32 | markets: r.markets.map(m => ({ 33 | liquidityStatus: { 34 | liabilityValue: liabilityValue(m).toString(), 35 | collateralValue: collateralValue(m).toString(), 36 | }, 37 | underlying: m.underlying, 38 | symbol: m.symbol 39 | })), 40 | }; 41 | 42 | return accu; 43 | }, { accounts: { accounts: {} }}); 44 | }; 45 | 46 | const data = await eulerscanData([ctx.wallet.address, ctx.wallet2.address]); 47 | 48 | setData(data); 49 | await processAccounts(); 50 | } 51 | 52 | module.exports = { 53 | deploy, 54 | runConnector, 55 | } -------------------------------------------------------------------------------- /test/lib/eTestLib.config.js: -------------------------------------------------------------------------------- 1 | const { deploy } = require('./botTestLib') 2 | 3 | module.exports = { 4 | hooks: { 5 | deploy, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /test/lib/helpers.js: -------------------------------------------------------------------------------- 1 | const et = require('euler-contracts/test/lib/eTestLib.js'); 2 | 3 | const provisionUniswapPool = (ctx, pool, wallet, amount, tickLower = -887220, tickUpper = 887220) => [ 4 | { from: wallet, send: `tokens.${pool.split('/')[0]}.mint`, args: [wallet.address, amount.mul(1_000_001)], }, 5 | { from: wallet, send: `tokens.${pool.split('/')[1]}.mint`, args: [wallet.address, amount.mul(1_000_001)], }, 6 | 7 | { from: wallet, send: `tokens.${pool.split('/')[0]}.approve`, args: [ctx.contracts.simpleUniswapPeriphery.address, et.MaxUint256], }, 8 | { from: wallet, send: `tokens.${pool.split('/')[1]}.approve`, args: [ctx.contracts.simpleUniswapPeriphery.address, et.MaxUint256], }, 9 | 10 | { from: wallet, send: 'simpleUniswapPeriphery.mint', args: [ctx.contracts.uniswapPools[pool].address, wallet.address, tickLower, tickUpper, amount], }, 11 | ]; 12 | 13 | const deposit = (ctx, token, wallet = ctx.wallet, subAccountId = 0, amount = 100, decimals = 18) => [ 14 | { from: wallet, send: `tokens.${token}.mint`, args: [wallet.address, et.units(amount, decimals)], }, 15 | { from: wallet, send: `tokens.${token}.approve`, args: [ctx.contracts.euler.address, et.MaxUint256,], }, 16 | { from: wallet, send: `eTokens.e${token}.deposit`, args: [subAccountId, et.MaxUint256,], }, 17 | ]; 18 | 19 | module.exports = { 20 | provisionUniswapPool, 21 | deposit, 22 | }; 23 | -------------------------------------------------------------------------------- /test/monitor.js: -------------------------------------------------------------------------------- 1 | const et = require('euler-contracts/test/lib/eTestLib.js').config(`${__dirname}/lib/eTestLib.config.js`); 2 | const { provisionUniswapPool, deposit, } = require('./lib/helpers'); 3 | const { runConnector } = require('./lib/botTestLib'); 4 | const { config } = require('../scripts/monLib') 5 | const { Euler } = require('@eulerxyz/euler-sdk'); 6 | const { ethers } = require('hardhat'); 7 | 8 | et.testSet({ 9 | desc: "eoa liquidation", 10 | fixture: "testing-real-uniswap-activated", 11 | 12 | preActions: ctx => [ 13 | // deployBot(ctx), 14 | 15 | { action: 'setIRM', underlying: 'WETH', irm: 'IRM_ZERO', }, 16 | { action: 'setIRM', underlying: 'TST', irm: 'IRM_ZERO', }, 17 | { action: 'setIRM', underlying: 'TST2', irm: 'IRM_ZERO', }, 18 | { action: 'setAssetConfig', tok: 'WETH', config: { borrowFactor: .4}, }, 19 | { action: 'setAssetConfig', tok: 'TST', config: { borrowFactor: .4}, }, 20 | { action: 'setAssetConfig', tok: 'TST2', config: { borrowFactor: .4}, }, 21 | 22 | // wallet is lender and liquidator 23 | ...deposit(ctx, 'TST'), 24 | ...deposit(ctx, 'WETH'), 25 | 26 | { from: ctx.wallet2, send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST2.address], }, 27 | 28 | // wallet 5 is the super whale 29 | ...provisionUniswapPool(ctx, 'TST/WETH', ctx.wallet5, et.eth(1000)), 30 | ...provisionUniswapPool(ctx, 'TST2/WETH', ctx.wallet5, et.eth(1000)), 31 | ...provisionUniswapPool(ctx, 'TST3/WETH', ctx.wallet5, et.eth(1000)), 32 | 33 | // initial prices 34 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST', dir: 'buy', amount: et.eth(10_000), priceLimit: 2.2, }, 35 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST2', dir: 'sell', amount: et.eth(10_000), priceLimit: 0.4 }, 36 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST3', dir: 'buy', amount: et.eth(10_000), priceLimit: 1.7 }, 37 | 38 | // activate pTST2 39 | { send: 'markets.activatePToken', args: [ctx.contracts.tokens.TST2.address], }, 40 | { action: 'cb', cb: async () => { 41 | ctx.contracts.pTokens = {}; 42 | let pTokenAddr = await ctx.contracts.markets.underlyingToPToken(ctx.contracts.tokens.TST2.address); 43 | ctx.contracts.pTokens['pTST2'] = await ethers.getContractAt('PToken', pTokenAddr); 44 | 45 | let epTokenAddr = await ctx.contracts.markets.underlyingToEToken(ctx.contracts.pTokens.pTST2.address); 46 | ctx.contracts.eTokens['epTST2'] = await ethers.getContractAt('EToken', epTokenAddr); 47 | }}, 48 | 49 | // wait for twap 50 | { action: 'checkpointTime', }, 51 | { action: 'jumpTimeAndMine', time: 3600 * 30 }, 52 | () => config( 53 | new Euler( 54 | ctx.wallet, 55 | network.config.chainId, 56 | { 57 | eul: {address: ethers.constants.AddressZero}, 58 | addresses: { 59 | ...ctx.addressManifest, 60 | eulStakes: ethers.constants.AddressZero, 61 | eulDistributor: ethers.constants.AddressZero, 62 | }, 63 | referenceAsset: ctx.contracts.tokens.WETH.address 64 | } 65 | ), 66 | false, 67 | ), 68 | ], 69 | }) 70 | 71 | 72 | .test({ 73 | desc: "basic full liquidation", 74 | actions: ctx => [ 75 | // wallet2 is borrower/violator 76 | ...deposit(ctx, 'TST2', ctx.wallet2), 77 | 78 | { from: ctx.wallet2, send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, 79 | 80 | { callStatic: 'exec.liquidity', args: [ctx.wallet2.address], onResult: r => { 81 | et.equals(r.collateralValue / r.liabilityValue, 1.09, 0.01); 82 | }, }, 83 | 84 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST', dir: 'buy', amount: et.eth(10_000), priceLimit: 2.5 }, 85 | 86 | { action: 'checkpointTime', }, 87 | { action: 'jumpTimeAndMine', time: 3600 * 30 * 100 }, 88 | 89 | { callStatic: 'exec.liquidity', args: [ctx.wallet2.address], onResult: r => { 90 | et.equals(r.collateralValue / r.liabilityValue, 0.96, 0.001); 91 | }, }, 92 | 93 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.tokens.TST2.address], 94 | onResult: r => { 95 | et.equals(r.healthScore, 0.96, 0.001); 96 | ctx.stash.repay = r.repay; 97 | ctx.stash.yield = r.yield; 98 | }, 99 | }, 100 | 101 | () => runConnector(ctx), 102 | 103 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.tokens.TST2.address], 104 | onResult: r => { 105 | et.equals(r.healthScore, 1.25, 0.01); 106 | }, 107 | }, 108 | ] 109 | }) 110 | 111 | 112 | .test({ 113 | desc: "liquidate protected collateral", 114 | actions: ctx => [ 115 | // wallet2 is borrower/violator 116 | { from: ctx.wallet2, send: 'tokens.TST2.mint', args: [ctx.wallet2.address, et.eth(100)], }, 117 | { from: ctx.wallet2, send: 'tokens.TST2.approve', args: [ctx.contracts.pTokens.pTST2.address, et.MaxUint256,], }, 118 | { from: ctx.wallet2, send: 'pTokens.pTST2.wrap', args: [et.eth(100)], }, 119 | { from: ctx.wallet2, send: 'eTokens.epTST2.deposit', args: [0, et.eth(100)], }, 120 | 121 | { from: ctx.wallet2, send: 'markets.enterMarket', args: [0, ctx.contracts.pTokens.pTST2.address], }, 122 | 123 | { from: ctx.wallet2, send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, 124 | 125 | { callStatic: 'exec.liquidity', args: [ctx.wallet2.address], onResult: r => { 126 | et.equals(r.collateralValue / r.liabilityValue, 1.09, 0.01); 127 | }, }, 128 | 129 | { from: ctx.wallet5, action: 'doUniswapSwap', tok: 'TST', dir: 'buy', amount: et.eth(10_000), priceLimit: 2.5 }, 130 | 131 | { action: 'checkpointTime', }, 132 | { action: 'jumpTimeAndMine', time: 3600 * 30 * 100 }, 133 | 134 | { callStatic: 'exec.liquidity', args: [ctx.wallet2.address], onResult: r => { 135 | et.equals(r.collateralValue / r.liabilityValue, 0.96, 0.001); 136 | }, }, 137 | 138 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.pTokens.pTST2.address], 139 | onResult: r => { 140 | et.equals(r.healthScore, 0.96, 0.001); 141 | ctx.stash.repay = r.repay; 142 | ctx.stash.yield = r.yield; 143 | }, 144 | }, 145 | 146 | () => runConnector(ctx), 147 | 148 | { callStatic: 'liquidation.checkLiquidation', args: [ctx.wallet.address, ctx.wallet2.address, ctx.contracts.tokens.TST.address, ctx.contracts.pTokens.pTST2.address], 149 | onResult: r => { 150 | et.equals(r.healthScore, 1.25, 0.01); 151 | }, 152 | }, 153 | ] 154 | }) 155 | 156 | .run(); 157 | 158 | --------------------------------------------------------------------------------