├── .config └── example │ ├── args │ ├── config.json │ └── pk ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .solcover.js ├── .solhint.json ├── .soliumignore ├── .soliumrc.json ├── @types └── types.d.ts ├── ARCHITECTURE.wsd ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE.md ├── README.md ├── TESTNET.md ├── aws ├── 00.deploy_cluster.sh ├── 01.deploy_roles.sh ├── 02.deploy_secrets.sh ├── 03.deploy.sh ├── README.md ├── dashboard_encode.py ├── ecs-task-role-trust-policy.json ├── imgs │ └── vpc_config.png ├── index.html ├── liquidator-task.json ├── liquidator.sh └── task-execution-role.json ├── build.rs ├── contracts ├── .YvBasicFlashLiquidator.sol ├── ChainlinkAggregatorV3Mock.sol ├── ERC20Mock.sol ├── FlashLiquidator.sol ├── ICurveStableSwap.sol ├── IMulticall2.sol ├── ISourceMock.sol ├── IWstEth.sol ├── TypechainImporter.sol ├── UniswapTransferHelper.sol ├── WstethFlashLiquidator.sol └── balancer │ ├── IFlashLoan.sol │ └── IFlashLoanRecipient.sol ├── docs └── ARCHITECTURE │ ├── Yield v2 Liquidations Bot Architecture.png │ ├── borrowers update_vaults.png │ ├── liquidations buy_opportunities.png │ └── liquidations remove_or_bump.png ├── hardhat.config.ts ├── package.json ├── regression_tests └── flashLiquidator.ts ├── scripts ├── deploy.ts ├── router.ts └── test.ts ├── src ├── bin │ └── liquidator.rs ├── bindings │ ├── .gitignore │ └── mod.rs ├── borrowers.rs ├── cache.rs ├── constants.ts ├── escalator.rs ├── helpers.ts ├── keeper.rs ├── lib.rs ├── liquidations.rs └── swap_router.rs ├── test └── test_flashLiquidator.ts ├── tsconfig-publish.json ├── tsconfig.json └── yarn.lock /.config/example/args: -------------------------------------------------------------------------------- 1 | --chain-id 42 --min-ratio 110 -i 30000 --url https://eth-kovan.alchemyapi.io/v2/your-key --json-log --instance-name kovan -------------------------------------------------------------------------------- /.config/example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "Witch": "0xaB588f06BE4ba4bBd0E0b236AeCe01CC0d9FA9b3", 3 | "Flash": "0xc653a2b32e6c35e9f28be9a49ea31bad0ca5e678", 4 | "Multicall": "0x2cc8688c5f75e365aaeeb4ea8d6a480405a48d2a" 5 | } 6 | -------------------------------------------------------------------------------- /.config/example/pk: -------------------------------------------------------------------------------- 1 | 123456 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2020": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "airbnb-base" 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": 11, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | } 15 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sol linguist-language=Solidity 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 12.x 18 | registry-url: 'https://registry.npmjs.org' 19 | 20 | - id: yarn-cache 21 | run: echo "::set-output name=dir::$(yarn cache dir)" 22 | 23 | - uses: actions/cache@v1 24 | with: 25 | path: ${{ steps.yarn-cache.outputs.dir }} 26 | key: yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | yarn- 29 | - run: yarn 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | - run: yarn lint:ts 33 | 34 | test: 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@v2 39 | - uses: actions/setup-node@v2 40 | with: 41 | node-version: 12.x 42 | registry-url: 'https://registry.npmjs.org' 43 | 44 | - id: yarn-cache 45 | run: echo "::set-output name=dir::$(yarn cache dir)" 46 | 47 | - uses: actions/cache@v1 48 | with: 49 | path: ${{ steps.yarn-cache.outputs.dir }} 50 | key: yarn-${{ hashFiles('**/yarn.lock') }} 51 | restore-keys: | 52 | yarn- 53 | - run: yarn 54 | env: 55 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | - run: yarn build 57 | - run: yarn test 58 | 59 | migrations: 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v2 64 | - uses: actions/setup-node@v2 65 | with: 66 | node-version: 12.x 67 | registry-url: 'https://registry.npmjs.org' 68 | 69 | - id: yarn-cache 70 | run: echo "::set-output name=dir::$(yarn cache dir)" 71 | 72 | - uses: actions/cache@v1 73 | with: 74 | path: ${{ steps.yarn-cache.outputs.dir }} 75 | key: yarn-${{ hashFiles('**/yarn.lock') }} 76 | restore-keys: | 77 | yarn- 78 | - run: yarn 79 | env: 80 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 81 | - run: yarn test:deploy 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temp & IDE files 2 | .vscode/ 3 | *~ 4 | *.swp 5 | *.swo 6 | 7 | # Packaging 8 | *.tar.gz 9 | *.tgz 10 | package/ 11 | 12 | # Buidler files 13 | cache 14 | artifacts 15 | node_modules/ 16 | abis/ 17 | output/ 18 | typechain/ 19 | deployments/ 20 | 21 | # Secret files 22 | .alchemyKey 23 | .etherscanKey 24 | .infuraKey 25 | .mythxKey 26 | .secret 27 | build/ 28 | 29 | # Solidity-coverage 30 | coverage/ 31 | .coverage_contracts/ 32 | coverage.json 33 | 34 | # Yarn 35 | yarn-error.log 36 | 37 | # NPM 38 | package-lock.json 39 | 40 | # Crytic 41 | crytic-export/ 42 | 43 | # Typescript 44 | dist/ 45 | 46 | # environments-v2 git submodule 47 | environments-v2/ 48 | 49 | /target 50 | 51 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | skipFiles: [ 3 | 'Migrations.sol', 4 | 'chai', 5 | 'maker', 6 | 'mocks' 7 | ] 8 | }; -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "plugins": [], 4 | "rules": { 5 | "avoid-throw": "off", 6 | "avoid-suicide": "error", 7 | "avoid-sha3": "warn", 8 | "compiler-version": "off", 9 | "func-visibility": ["warn",{"ignoreConstructors":true}], 10 | "no-empty-blocks": "off", 11 | "not-rely-on-time": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.soliumignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yieldprotocol/yield-liquidator-v2/9a49d9a0e9398f6a6c07bad531e77d1001a1166f/.soliumignore -------------------------------------------------------------------------------- /.soliumrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solium:all", 3 | "plugins": [ 4 | "security" 5 | ], 6 | "rules": { 7 | "error-reason": "off", 8 | "indentation": [ 9 | "error", 10 | 4 11 | ], 12 | "lbrace": "off", 13 | "linebreak-style": [ 14 | "error", 15 | "unix" 16 | ], 17 | "max-len": [ 18 | "error", 19 | 119 20 | ], 21 | "no-constant": [ 22 | "error" 23 | ], 24 | "no-empty-blocks": "off", 25 | "quotes": [ 26 | "error", 27 | "double" 28 | ], 29 | "uppercase": "off", 30 | "visibility-first": "error", 31 | "security/enforce-explicit-visibility": [ 32 | "error" 33 | ], 34 | "security/no-block-members": [ 35 | "warning" 36 | ], 37 | "security/no-inline-assembly": [ 38 | "warning" 39 | ], 40 | "imports-on-top": "warning", 41 | "variable-declarations": "warning", 42 | "array-declarations": "warning", 43 | "operator-whitespace": "warning", 44 | "function-whitespace": "warning", 45 | "semicolon-whitespace": "warning", 46 | "comma-whitespace": "warning", 47 | "conditionals-whitespace": "warning", 48 | "arg-overflow": "off" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /@types/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module Chai { 2 | interface Assertion { 3 | bignumber: Assertion; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ARCHITECTURE.wsd: -------------------------------------------------------------------------------- 1 | @startuml Yield v2 Liquidations Bot Architecture 2 | entity keeper.rs 3 | entity liquidations.rs as liq 4 | entity borrowers.rs as borrowers 5 | 6 | database "Eth Node" as ethnode 7 | 8 | loop 9 | group wait for a new block 10 | keeper.rs -> ethnode: got new blocks? 11 | return no 12 | ...sleep... 13 | keeper.rs -> ethnode: got new blocks? 14 | return new block! 15 | end 16 | 17 | group on_block 18 | keeper.rs -> liq: remove_or_bump 19 | note right: bumps gas for all non-confirmed transactions 20 | keeper.rs -> borrowers: update_vaults 21 | note right: finds all new vaults created since last seen block 22 | keeper.rs -> liq: start_auctions 23 | note right: starts auction for all undercollaterized vaults 24 | keeper.rs -> liq: buy_opportunities 25 | note right: liquidates vaults that have either:\n\ 26 | - dropped below `min_ratio` collaterization level (default: 110)\n\ 27 | - all collateral released by the auction 28 | end 29 | end 30 | @enduml 31 | 32 | @startuml liquidations::remove_or_bump 33 | title liquidations::remove_or_bump 34 | start 35 | while (have pending 'liquidate' txs?) is (yes) 36 | :tx.max_fee_per_gas = gas_escalator.get_gas_price() 37 | tx.max_priority_fee_per_gas = gas_escalator.get_gas_price(); 38 | endwhile (no) 39 | 40 | while (have pending 'start auction' txs?) is (yes) 41 | :tx.max_fee_per_gas = gas_escalator.get_gas_price() 42 | tx.max_priority_fee_per_gas = gas_escalator.get_gas_price(); 43 | endwhile (no) 44 | 45 | end 46 | @enduml 47 | 48 | @startuml borrowers::update_vaults 49 | title borrowers::update_vaults 50 | start 51 | group Collect new vaults 52 | : get all 'Cauldron::VaultPoured' events since last block; 53 | while (have new VaultPoured event?) is (yes) 54 | : self.vaults[event.vault_id] = new Vault(); 55 | endwhile (no) 56 | end group 57 | group Update **all** vaults data 58 | while (all self.vaults are processed?) is (no) 59 | :update vault data: 60 | 61 | self.vault[vault_id] = get_vault_info(vault_id){ 62 | vault.level = Cauldron::level(vault_id) 63 | vault.is_collateralized = vault.level >= 0 64 | vault.under_auction = Witch::auction(vault_id).owner != 0 65 | }; 66 | endwhile (yes) 67 | end group 68 | end 69 | @enduml 70 | 71 | 72 | @startuml liquidations::start_auctions 73 | title liquidations::start_auctions 74 | skinparam ConditionEndStyle hline 75 | start 76 | group Check **all** vaults 77 | while (all self.vaults are processed?) is (no) 78 | if (vault has no pending liquidation?) then (yes) 79 | if (vault is undercollaterized?) then (yes) 80 | if (vault is **not** under auction) then (yes) 81 | : With::auction(vault_id); 82 | endif 83 | endif 84 | endif 85 | endwhile (yes) 86 | end group 87 | end 88 | 89 | @startuml liquidations::buy_opportunities 90 | title liquidations::buy_opportunities 91 | skinparam ConditionEndStyle hline 92 | start 93 | group Collect new auctions 94 | : get all 'Witch::Auctioned' events since last block; 95 | while (have new Auctioned event?) is (yes) 96 | : self.auctions[event.vault_id] = new Auction(); 97 | endwhile (no) 98 | end group 99 | group Process **all** auctions 100 | while (all self.auctions are processed?) is (no) 101 | if (auction has no pending bid from us?) then (yes) 102 | :update auction data: 103 | 104 | auction = self.get_auction(auction_id){ 105 | auction.under_auction = Witch::auctions(id).owner != 0 106 | auction.ratio_pct = Flash::collateral_to_debt_ratio(vault_id, ...) 107 | int256 level = cauldron.level(vaultId) 108 | uint128 accrued_debt = cauldron.debtToBase(seriesId, art) 109 | (, uint32 ratio_u32) = cauldron.spotOracles(baseId, ilkId) 110 | 111 | return (level * 1e18 / int256(int128(accrued_debt))) + int256(uint256(ratio_u32)) * 1e12 112 | auction.is_at_minimal_price = Flash::is_at_minimal_price(vault_id, ...) 113 | (, uint32 auction_start) = witch.auctions(vaultId) 114 | (, uint32 duration, , ) = witch.ilks(ilkId) 115 | uint256 elapsed = uint32(block.timestamp) - auction_start 116 | return elapsed >= duration 117 | ... 118 | }; 119 | 120 | if (auction is still open?) then (yes) 121 | if (auction.ratio_pct < 110% OR auction.is_at_minimal_price) then (yes) 122 | :Flash::init_flash(); 123 | endif 124 | endif 125 | endif 126 | endwhile (yes) 127 | end group 128 | end 129 | @enduml -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yield-liquidator" 3 | version = "0.1.0" 4 | authors = ["Georgios Konstantopoulos "] 5 | edition = "2018" 6 | default-run = "liquidator" 7 | 8 | [dependencies] 9 | anyhow = "1.0.32" 10 | ethers = { version = "0.5.2", features=["ws", "openssl"] } 11 | ethers-core = { version = "0.5.3" } 12 | exitcode = "1.1.2" 13 | futures-util = "0.3.19" 14 | hex = "0.4.3" 15 | serde_json = "1.0.57" 16 | serde_with = "1.10.0" 17 | tokio = { version = "1.11.0", features = ["full"] } 18 | async-process = "1.3.0" 19 | 20 | # CLI 21 | gumdrop = "0.8.0" 22 | # Logging 23 | tracing = "0.1.29" 24 | tracing-subscriber = {version="0.2.25", features =["default", "json"]} 25 | serde = "1.0.130" 26 | thiserror = "1.0.20" 27 | 28 | [build-dependencies] 29 | ethers = { version = "0.5.2", features = ["abigen"] } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### Image 1: rust builder 2 | FROM amazonlinux:latest AS builder 3 | ## Step 0: install dependencies 4 | RUN yum update -y 5 | # install OS deps 6 | RUN yum install gcc openssl-devel -y 7 | # install rust compiler 8 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 9 | 10 | ## Step 1: build 'liquidator' binary 11 | # build dependencies - they're not changed frequently 12 | WORKDIR /usr/src/ 13 | COPY Cargo.* ./ 14 | COPY Cargo.toml . 15 | RUN mkdir -p src/bin \ 16 | && echo "//" > src/lib.rs \ 17 | && echo "fn main() {}" > src/bin/liquidator.rs \ 18 | && ~/.cargo/bin/cargo build --release 19 | 20 | # copy sources and build 21 | COPY abis abis 22 | COPY build.rs . 23 | COPY src src 24 | 25 | RUN ~/.cargo/bin/cargo build --release 26 | 27 | ### Image 2: yarn builder 28 | FROM amazonlinux:latest AS yarnbuilder 29 | RUN yum update -y 30 | # add nodejs repo 31 | RUN curl -fsSL https://rpm.nodesource.com/setup_16.x | bash - 32 | # install OS deps 33 | RUN yum install nodejs git -y 34 | # install yarn 35 | RUN corepack enable 36 | 37 | WORKDIR /usr/src/ 38 | COPY package.json tsconfig.json yarn.lock hardhat.config.ts ./ 39 | RUN yarn 40 | 41 | COPY src src 42 | COPY contracts contracts 43 | COPY scripts scripts 44 | RUN mkdir abis && yarn build 45 | RUN npm run buildRouter 46 | 47 | ### Image 3: binaries 48 | FROM amazonlinux:latest AS liquidator 49 | COPY --from=builder /usr/src/target/release/liquidator /usr/bin 50 | COPY --from=yarnbuilder /usr/src/build/bin/router /usr/bin 51 | COPY aws/liquidator.sh /usr/bin 52 | 53 | CMD ["/usr/bin/liquidator.sh"] 54 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Business Source License 1.1 2 | 3 | License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. 4 | "Business Source License" is a trademark of MariaDB Corporation Ab. 5 | 6 | ----------------------------------------------------------------------------- 7 | 8 | Parameters 9 | 10 | Licensor: Yield Inc. 11 | 12 | Licensed Work: Yield Protocol v2 13 | The Licensed Work is (c) 2021 Yield Inc. 14 | 15 | Additional Use Grant: Any uses listed and defined at 16 | v2-vault-license-grants.yieldprotocol.eth 17 | 18 | Change Date: The earlier of 2023-06-01 or a date specified at 19 | v2-vault-license-date.yieldprotocol.eth 20 | 21 | Change License: GNU General Public License v2.0 or later 22 | 23 | ----------------------------------------------------------------------------- 24 | 25 | Terms 26 | 27 | The Licensor hereby grants you the right to copy, modify, create derivative 28 | works, redistribute, and make non-production use of the Licensed Work. The 29 | Licensor may make an Additional Use Grant, above, permitting limited 30 | production use. 31 | 32 | Effective on the Change Date, or the fourth anniversary of the first publicly 33 | available distribution of a specific version of the Licensed Work under this 34 | License, whichever comes first, the Licensor hereby grants you rights under 35 | the terms of the Change License, and the rights granted in the paragraph 36 | above terminate. 37 | 38 | If your use of the Licensed Work does not comply with the requirements 39 | currently in effect as described in this License, you must purchase a 40 | commercial license from the Licensor, its affiliated entities, or authorized 41 | resellers, or you must refrain from using the Licensed Work. 42 | 43 | All copies of the original and modified Licensed Work, and derivative works 44 | of the Licensed Work, are subject to this License. This License applies 45 | separately for each version of the Licensed Work and the Change Date may vary 46 | for each version of the Licensed Work released by Licensor. 47 | 48 | You must conspicuously display this License on each original or modified copy 49 | of the Licensed Work. If you receive the Licensed Work in original or 50 | modified form from a third party, the terms and conditions set forth in this 51 | License apply to your use of that work. 52 | 53 | Any use of the Licensed Work in violation of this License will automatically 54 | terminate your rights under this License for the current and all other 55 | versions of the Licensed Work. 56 | 57 | This License does not grant you any right in any trademark or logo of 58 | Licensor or its affiliates (provided that you may use a trademark or logo of 59 | Licensor as expressly required by this License). 60 | 61 | TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON 62 | AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, 63 | EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF 64 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND 65 | TITLE. 66 | 67 | MariaDB hereby grants you permission to use this License’s text to license 68 | your works, and to refer to it using the trademark "Business Source License", 69 | as long as you comply with the Covenants of Licensor below. 70 | 71 | ----------------------------------------------------------------------------- 72 | 73 | Covenants of Licensor 74 | 75 | In consideration of the right to use this License’s text and the "Business 76 | Source License" name and trademark, Licensor covenants to MariaDB, and to all 77 | other recipients of the licensed work to be provided by Licensor: 78 | 79 | 1. To specify as the Change License the GPL Version 2.0 or any later version, 80 | or a license that is compatible with GPL Version 2.0 or a later version, 81 | where "compatible" means that software provided under the Change License can 82 | be included in a program with software provided under GPL Version 2.0 or a 83 | later version. Licensor may specify additional Change Licenses without 84 | limitation. 85 | 86 | 2. To either: (a) specify an additional grant of rights to use that does not 87 | impose any additional restriction on the right granted in this License, as 88 | the Additional Use Grant; or (b) insert the text "None". 89 | 90 | 3. To specify a Change Date. 91 | 92 | 4. Not to modify this License in any other way. 93 | 94 | ----------------------------------------------------------------------------- 95 | 96 | Notice 97 | 98 | The Business Source License (this document, or the "License") is not an Open 99 | Source license. However, the Licensed Work will eventually be made available 100 | under an Open Source License, as stated in this License. 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yield Protocol Liquidator 2 | 3 | Liquidates undercollateralized fyDAI-ETH positions using Uniswap V2 as a capital source. 4 | 5 | This liquidator altruistically calls the `Witch.auction` function for any 6 | position that is underwater, trigerring an auction for that position. It then tries 7 | to participate in the auction by flashloaning funds from Uniswap, if there's enough 8 | profit to be made. 9 | 10 | ## CLI 11 | 12 | ``` 13 | Usage: ./yield-liquidator [OPTIONS] 14 | 15 | Optional arguments: 16 | -h, --help 17 | -c, --config CONFIG path to json file with the contract addresses 18 | -u, --url URL the Ethereum node endpoint (HTTP or WS) (default: http://localhost:8545) 19 | -C, --chain-id CHAIN-ID chain id (default: 1) 20 | -p, --private-key PRIVATE-KEY 21 | path to your private key 22 | -i, --interval INTERVAL polling interval (ms) (default: 1000) 23 | -f, --file FILE the file to be used for persistence (default: data.json) 24 | -m, --min-ratio MIN-RATIO the minimum ratio (collateral/debt) to trigger liquidation, percents (default: 110) 25 | -s, --start-block START-BLOCK 26 | the block to start watching from 27 | ``` 28 | 29 | Your contracts' `--config` file should be in the following format where: 30 | * `Witch` is the address of the Witch 31 | * `Flash` is the address of the PairFlash 32 | * `Multicall` is the address of the Multicall (https://github.com/makerdao/multicall) 33 | ``` 34 | { 35 | "Witch": "0xCA4c47Ed4E8f8DbD73ecEd82ac0d8999960Ed57b", 36 | "Flash": "0xB869908891b245E82C8EDb74af02f799b61deC97", 37 | "Multicall": "0xeefba1e63905ef1d7acba5a8513c70307c1ce441" 38 | } 39 | ``` 40 | 41 | `Flash` is a deployment of `PairFlash` contract (https://github.com/sblOWPCKCR/vault-v2/blob/liquidation/contracts/liquidator/Flash.sol). Easy way to compile/deploy it: 42 | ``` 43 | solc --abi --overwrite --optimize --optimize-runs 5000 --bin -o /tmp/ external/vault-v2/contracts/liquidator/Flash.sol && ETH_GAS=3000000 seth send --create /tmp/PairFlash.bin "PairFlash(address,address,address,address,address) " $OWNER 0xE592427A0AEce92De3Edee1F18E0157C05861564 0x1F98431c8aD98523631AE4a59f267346ea31F984 0xd0a1e359811322d97991e03f863a0c30c2cf029c $WITCH_ADDRESS 44 | ``` 45 | 46 | The `--private-key` _must not_ have a `0x` prefix. Set the `interval` to 15s for mainnet. 47 | 48 | ## Building and Running 49 | 50 | ``` 51 | # Build in release mode 52 | cargo build --release 53 | 54 | # Run it with 55 | ./target/release/yield-liquidator \ 56 | --config ./addrs.json \ 57 | --private-key ./private_key \ 58 | --url http://localhost:8545 \ 59 | --interval 7000 \ 60 | --file state.json \ 61 | ``` 62 | 63 | ## How it Works 64 | 65 | On each block: 66 | 1. Bumps the gas price of all of our pending transactions 67 | 2. Updates our dataset of borrowers debt health & liquidation auctions with the new block's data 68 | 3. Trigger the auction for any undercollateralized borrowers 69 | 4. Try participating in any auctions which are worth buying 70 | 71 | Take this liquidator for a spin by [running it in a test environment](TESTNET.md). 72 | -------------------------------------------------------------------------------- /TESTNET.md: -------------------------------------------------------------------------------- 1 | ## Testing 2 | 3 | In this guide, you will: 4 | 1. Deploy the yield contracts 5 | 2. Run the liquidator 6 | 3. See the liquidator trigger the liquidation 7 | 4. After some time, see the liquidator participate in the auction 8 | 9 | ### Deploy the contracts 10 | 11 | First we must clone the contracts and install the deps: 12 | 13 | ``` 14 | git clone git@github.com:sblOWPCKCR/vault-v2.git 15 | git checkout liquidation 16 | yarn 17 | ``` 18 | 19 | In one terminal, run hardhat node with mainnet fork: `yarn hardhat node --network hardhat` 20 | 21 | In another terminal, deploy the contracts: `yarn hardhat run --network localhost scripts/deploy.ts` 22 | It deploys Yield-V2 and prints out 3 important pieces of information: 23 | * block number at the time of deployment 24 | * owner's address and private key 25 | * a json snippet with addresses of the deployed contracts 26 | 27 | Store the private key in a file (/tmp/pk) without the '0x' prefix, store the json snippet in another file (config.json) 28 | 29 | ### Run the liquidator 30 | 31 | In a new terminal, navigate back to the `yield-liquidator` directory and run: 32 | ``` 33 | RUST_BACKTRACE=1 RUST_LOG="liquidator,yield_liquidator=debug" cargo run -- --chain_id 31337 -c config.json -p /tmp/pk -s BLOCK_NUMBER_AT_TIME_OF_DEPLOYMENT --min-ratio 50 34 | ``` 35 | 36 | 37 | ``` 38 | Sep 15 11:02:14.493 INFO yield_liquidator: Starting Yield-v2 Liquidator. 39 | Sep 15 11:02:14.497 INFO yield_liquidator: Profits will be sent to 0xf364fdfe5706c4c274851765c00716ebad06eb6a 40 | Sep 15 11:02:14.497 INFO yield_liquidator: Node: http://localhost:8545 41 | Sep 15 11:02:14.498 INFO yield_liquidator: Cauldron: 0xc7309e5cda6e25a50ea71c5d4f27c5538182ca65 42 | Sep 15 11:02:14.498 INFO yield_liquidator: Witch: 0x2dc74a1349670aa2dedf81daa5f8cfabc7d6e4e6 43 | Sep 15 11:02:14.498 INFO yield_liquidator: Multicall: Some(0xeefba1e63905ef1d7acba5a8513c70307c1ce441) 44 | Sep 15 11:02:14.498 INFO yield_liquidator: FlashLiquidator 0x955dcdb2d59f0b4bf1e97bb8187c6dfe02b3ab03 45 | Sep 15 11:02:14.498 INFO yield_liquidator: Persistent data will be stored at: "data.json" 46 | Sep 15 11:02:15.532 DEBUG eloop{block=13228001}:monitoring: yield_liquidator::borrowers: New vaults: 1 47 | Sep 15 11:02:15.577 DEBUG eloop{block=13228001}:monitoring: yield_liquidator::borrowers: new_vault=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] details=Vault { is_collateralized: true, under_auction: false, level: 500000000000000000, ink: [69, 84, 72, 0, 0, 0], art: [107, 137, 103, 254, 149, 15] } 48 | Sep 15 11:02:15.577 DEBUG eloop{block=13228001}: yield_liquidator::liquidations: checking for undercollateralized positions... 49 | Sep 15 11:02:15.577 DEBUG eloop{block=13228001}: yield_liquidator::liquidations: Checking vault vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] 50 | Sep 15 11:02:15.577 DEBUG eloop{block=13228001}: yield_liquidator::liquidations: Vault is collateralized vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] 51 | ``` 52 | 53 | The vault is fully collaterized, everything is happy 54 | 55 | ### Create a liquidation opportunity 56 | 57 | Let's drop the collateral price 58 | ``` 59 | seth send SPOT_SOURCE_ADDRESS "set(uint)" "1000000000000000000" 60 | ``` 61 | 62 | This drops the collateral price to 1 (denominated in debt). Our vault is set up with 150% collaterization rate, so it should be under water now: 63 | ``` 64 | Sep 15 11:05:33.812 DEBUG eloop{block=13228002}: yield_liquidator::liquidations: checking for undercollateralized positions... 65 | Sep 15 11:05:33.812 DEBUG eloop{block=13228002}: yield_liquidator::liquidations: Checking vault vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] 66 | Sep 15 11:05:33.812 INFO eloop{block=13228002}: yield_liquidator::liquidations: found undercollateralized vault. starting an auction vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] details=Vault { is_collateralized: false, under_auction: false, level: -500000000000000000, ink: [69, 84, 72, 0, 0, 0], art: [107, 137, 103, 254, 149, 15] } 67 | Sep 15 11:05:33.898 TRACE eloop{block=13228002}: yield_liquidator::liquidations: Submitted liquidation tx_hash=PendingTransaction { tx_hash: 0xb3f0aaeec92e7b10b30e5dcdea4d06428e9b2a83bd785becf487ed390206c710, confirmations: 1, state: PendingTxState { state: "InitialDelay" } } vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] 68 | Sep 15 11:05:34.911 TRACE eloop{block=13228003}: yield_liquidator::liquidations: confirmed tx_hash=0xb3f0aaeec92e7b10b30e5dcdea4d06428e9b2a83bd785becf487ed390206c710 gas_used=83518 user=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] status="success" tx_type="liquidations" 69 | Sep 15 11:05:34.913 DEBUG eloop{block=13228003}:monitoring: yield_liquidator::borrowers: New vaults: 0 70 | Sep 15 11:05:34.946 DEBUG eloop{block=13228003}: yield_liquidator::liquidations: checking for undercollateralized positions... 71 | Sep 15 11:05:34.947 DEBUG eloop{block=13228003}: yield_liquidator::liquidations: Checking vault vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] 72 | Sep 15 11:05:34.947 DEBUG eloop{block=13228003}: yield_liquidator::liquidations: found vault under auction, ignoring it vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] details=Vault { is_collateralized: false, under_auction: true, level: -500000000000000000, ink: [69, 84, 72, 0, 0, 0], art: [107, 137, 103, 254, 149, 15] } 73 | Sep 15 11:05:35.033 DEBUG eloop{block=13228003}: yield_liquidator::liquidations: Not time to buy yet vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] auction=Auction { started: 1631729706, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 100, is_at_minimal_price: false, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 } 74 | ``` 75 | 76 | `Not time to buy yet` part is important here - the bot doesn't buy the debt right away. It either waits until collateral/debt ratio drops under the minimum specified, or the auction reaches the point when all of collateral is released. 77 | In our exercise, we set the minimal collateral/debt ratio to 50% (`--min-ratio 50`) which is impractical, but good enough for demo purposes. 78 | 79 | ### Buying debt: auction releases all collateral 80 | 81 | Skip some time and see what happens. Skip 10h: 82 | ``` 83 | curl -H "Content-Type: application/json" -X POST --data '{"id":1337,"jsonrpc":"2.0","method":"evm_increaseTime","params":[36000]}' http://localhost:8545 84 | 85 | curl -H "Content-Type: application/json" -X POST --data '{"id":1337,"jsonrpc":"2.0","method":"evm_mine","params":[]}' http://localhost:8545 86 | 87 | ``` 88 | 89 | And the bot says: 90 | ``` 91 | Sep 15 11:21:03.042 DEBUG eloop{block=13228004}: yield_liquidator::liquidations: Is at minimal price, buying vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] auction=Auction { started: 1631729706, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 100, is_at_minimal_price: true, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 } ratio=100 ratio_threshold=50 92 | Sep 15 11:21:03.042 DEBUG eloop{block=13228004}: yield_liquidator::liquidations: new auction vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] auction=Auction { started: 1631729706, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 100, is_at_minimal_price: true, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 } 93 | 94 | 95 | Sep 15 11:21:07.587 TRACE eloop{block=13228004}:buying{vault_id=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] auction=Auction { started: 1631729706, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 100, is_at_minimal_price: true, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 }}: yield_liquidator::liquidations: Submitted buy order tx_hash=PendingTransaction { tx_hash: 0x1406645b2fa56e6c36d053394e32d68905614b84ccc812a03a61c85bcaa46910, confirmations: 1, state: PendingTxState { state: "InitialDelay" } } 96 | Sep 15 11:21:08.599 TRACE eloop{block=13228005}: yield_liquidator::liquidations: confirmed tx_hash=0x1406645b2fa56e6c36d053394e32d68905614b84ccc812a03a61c85bcaa46910 gas_used=365632 user=[189, 42, 16, 43, 22, 126, 95, 211, 104, 131, 167, 65] status="success" tx_type="auctions" 97 | ``` 98 | 99 | `Is at minimal price` followed by `Submitted buy order` 100 | 101 | ### Buying debt: debt ratio drops below threshold 102 | 103 | Let's re-deploy our vault and go back to 1:1 collateral:debt price: 104 | 105 | ``` 106 | ... 107 | Sep 15 12:19:23.927 DEBUG eloop{block=13228076}: yield_liquidator::liquidations: checking for undercollateralized positions... 108 | Sep 15 12:19:23.927 DEBUG eloop{block=13228076}: yield_liquidator::liquidations: Checking vault vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] 109 | Sep 15 12:19:23.927 DEBUG eloop{block=13228076}: yield_liquidator::liquidations: Vault is collateralized vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] 110 | ... 111 | Sep 15 12:20:52.890 DEBUG eloop{block=13228078}: yield_liquidator::liquidations: Not time to buy yet vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] auction=Auction { started: 1631770267, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 100, is_at_minimal_price: false, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 } 112 | ``` 113 | 114 | And drop the collateral price to 1/1.6 of debt: 115 | ``` 116 | seth send SPOT_SOURCE_ADDRESS "set(uint)" "1600000000000000000" 117 | ``` 118 | 119 | The bot says: 120 | ``` 121 | Sep 15 12:21:39.318 DEBUG eloop{block=13228079}: yield_liquidator::liquidations: Ratio threshold is reached, buying vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] auction=Auction { started: 1631770267, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 50, is_at_minimal_price: false, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 } ratio=50 ratio_threshold=50 122 | Sep 15 12:21:41.109 TRACE eloop{block=13228079}:buying{vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] auction=Auction { started: 1631770267, under_auction: true, debt: 1000000000000000000, collateral: 1000000000000000000, ratio_pct: 50, is_at_minimal_price: false, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 }}: yield_liquidator::liquidations: Submitted buy order tx_hash=PendingTransaction { tx_hash: 0xa64535a1ee15cfac46ba360461060b487d7f06ebe5f92164816650403f394b4e, confirmations: 1, state: PendingTxState { state: "InitialDelay" } } 123 | Sep 15 12:21:41.118 TRACE eloop{block=13228080}: yield_liquidator::liquidations: confirmed tx_hash=0xa64535a1ee15cfac46ba360461060b487d7f06ebe5f92164816650403f394b4e gas_used=364932 user=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] status="success" tx_type="auctions" 124 | Sep 15 12:21:41.120 DEBUG eloop{block=13228080}:monitoring: yield_liquidator::borrowers: New vaults: 1 125 | Sep 15 12:21:41.153 DEBUG eloop{block=13228080}:monitoring: yield_liquidator::borrowers: Data fetched vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] details=Vault { is_collateralized: true, under_auction: false, level: 248958333333333333, ink: [69, 84, 72, 0, 0, 0], art: [73, 143, 171, 153, 67, 168] } 126 | Sep 15 12:21:41.153 DEBUG eloop{block=13228080}: yield_liquidator::liquidations: checking for undercollateralized positions... 127 | Sep 15 12:21:41.153 DEBUG eloop{block=13228080}: yield_liquidator::liquidations: Checking vault vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] 128 | Sep 15 12:21:41.153 DEBUG eloop{block=13228080}: yield_liquidator::liquidations: Vault is collateralized vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] 129 | Sep 15 12:21:41.204 DEBUG eloop{block=13228080}: yield_liquidator::liquidations: Auction is no longer active vault_id=[110, 161, 232, 101, 55, 96, 244, 173, 106, 64, 29, 210] auction=Auction { started: 0, under_auction: false, debt: 0, collateral: 497916666666666667, ratio_pct: 0, is_at_minimal_price: true, debt_id: [68, 65, 73, 0, 0, 0], collateral_id: [69, 84, 72, 0, 0, 0], debt_address: 0x6b175474e89094c44da98b954eedeac495271d0f, collateral_address: 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 } 130 | ``` 131 | 132 | `Ratio threshold is reached` -> `Submitted buy order` -> `Auction is no longer active` -------------------------------------------------------------------------------- /aws/00.deploy_cluster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xeuo pipefail 4 | 5 | # defaulut configuration values 6 | export REGION=${REGION:-us-west-2} 7 | export AWSACCOUNT=${AWSACCOUNT:-116961472995} 8 | export INSTANCE=${INSTANCE:-kovan} 9 | 10 | aws logs create-log-group --log-group-name yield --region $REGION 11 | aws ecs create-cluster --cluster-name "yield_cluster" --region $REGION -------------------------------------------------------------------------------- /aws/01.deploy_roles.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xeuo pipefail 4 | 5 | # defaulut configuration values 6 | export REGION=${REGION:-us-west-2} 7 | export AWSACCOUNT=${AWSACCOUNT:-116961472995} 8 | export INSTANCE=${INSTANCE:-kovan} 9 | 10 | cat ecs-task-role-trust-policy.json | envsubst > /tmp/ecs-task-role-trust-policy.json 11 | aws iam create-role --region $REGION --role-name liquidator-task-role-$INSTANCE --assume-role-policy-document file:///tmp/ecs-task-role-trust-policy.json 12 | aws iam create-role --region $REGION --role-name liquidator-task-execution-role-$INSTANCE --assume-role-policy-document file:///tmp/ecs-task-role-trust-policy.json 13 | 14 | cat task-execution-role.json | envsubst > /tmp/task-execution-role.json 15 | aws iam put-role-policy --region $REGION --role-name liquidator-task-execution-role-$INSTANCE --policy-name liquidator-iam-policy-task-execution-role-$INSTANCE --policy-document file:///tmp/task-execution-role.json 16 | 17 | -------------------------------------------------------------------------------- /aws/02.deploy_secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xeuo pipefail 4 | 5 | # defaulut configuration values 6 | REGION=${REGION:-us-west-2} 7 | AWSACCOUNT=${AWSACCOUNT:-116961472995} 8 | INSTANCE=${INSTANCE:-kovan} 9 | 10 | echo "Creating secrets" 11 | V_CONFIG=$(cat ../.config/$INSTANCE/config.json) 12 | V_ARGS=$(cat ../.config/$INSTANCE/args) 13 | V_PK=$(cat ../.config/$INSTANCE/pk) 14 | 15 | aws secretsmanager create-secret --region $REGION \ 16 | --name $INSTANCE/config.json --secret-string "$V_CONFIG" 17 | 18 | aws secretsmanager create-secret --region $REGION \ 19 | --name $INSTANCE/args --secret-string "$V_ARGS" 20 | 21 | aws secretsmanager create-secret --region $REGION \ 22 | --name $INSTANCE/pk --secret-string "$V_PK" 23 | -------------------------------------------------------------------------------- /aws/03.deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xeuo pipefail 4 | 5 | # defaulut configuration values 6 | export REGION=${REGION:-us-west-2} 7 | export AWSACCOUNT=${AWSACCOUNT:-116961472995} 8 | export INSTANCE=${INSTANCE:-kovan} 9 | 10 | export REPO_NAME=yield-v2-liquidator 11 | 12 | echo "Building docker image" 13 | docker build .. -t $REPO_NAME:latest 14 | 15 | # create docker repo 16 | echo "Uploading docker image" 17 | aws ecr list-images --repository-name $REPO_NAME --region $REGION || aws ecr create-repository --repository-name $REPO_NAME --region $REGION 18 | ECR_REPO_URI=$AWSACCOUNT.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME 19 | 20 | REMOTE_IMAGE=$ECR_REPO_URI:latest 21 | docker tag $REPO_NAME:latest $REMOTE_IMAGE 22 | 23 | aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ECR_REPO_URI 24 | docker push $REMOTE_IMAGE 25 | 26 | echo "Creating Fargate task" 27 | export CONFIG_KEY=$(aws secretsmanager list-secrets | jq -r ".SecretList[] | select (.Name==\"$INSTANCE/config.json\") | .ARN") 28 | export ARGS_KEY=$(aws secretsmanager list-secrets | jq -r ".SecretList[] | select (.Name==\"$INSTANCE/args\") | .ARN") 29 | export PK_KEY=$(aws secretsmanager list-secrets | jq -r ".SecretList[] | select (.Name==\"$INSTANCE/pk\") | .ARN") 30 | cat liquidator-task.json | envsubst > /tmp/liquidator-task.json 31 | 32 | aws ecs register-task-definition --region $REGION --cli-input-json file:///tmp/liquidator-task.json 33 | 34 | # echo "Running task" 35 | # aws ecs run-task --region $REGION \ 36 | # --cluster "yield_cluster" \ 37 | # --launch-type FARGATE \ 38 | # --network-configuration "awsvpcConfiguration={subnets=[subnet-07eba54f8844e6159],securityGroups=[sg-0a4c1fa1da8808a7c],assignPublicIp=ENABLED}" 39 | # --task-definition liquidator-$INSTANCE:8 -------------------------------------------------------------------------------- /aws/README.md: -------------------------------------------------------------------------------- 1 | # AWS Deployment 2 | 3 | ## Basics 4 | You can set up multiple bots, each looking at its own `Witch`/`Flash` deployment, with its own arguments. 5 | Each instance's configuration is represented by a separate directory in .config. A sample configuration is provided. 6 | 7 | The bot is deployed as a task in AWS Fargate: it's a simple service that runs tasks packaged as docker containers. 8 | 9 | ## Setting things up 10 | `aws` directory contains 4 scripts that take you through deployment. Why 4 **separate** steps? Sometimes (rarely!) you need to make changes to things that are already deployed, and AWS APIs are not idempotent -> you need to manually re-run a subset of commands. 11 | 12 | Before starting, make sure to export these environment variables: 13 | 14 | * `$REGION` - AWS region you're deploying to 15 | * `$AWSACCOUNT` - your AWS account ID 16 | * `$INSTANCE` - instance name you're setting up. The deployment scripts will search for `.config/$INSTANCE` configuration directory 17 | 18 | `$INSTANCE` can only contain alphanumerics and underscore/dashes, so be careful here. 19 | 20 | ### Step 0: setting up AWS cluster 21 | ```bash 22 | cd aws 23 | ./00.deploy_cluster.sh 24 | ``` 25 | Create the ECS cluster that Fargate tasks will run on. This is truly a one-time step that doesn't need to be ever redone 26 | 27 | 28 | ### Step 0.1: create VPC endpoints 29 | There are 2 types of permissions that need to be granted that needs to align: AWS **permissions** and firewall rules. Even if you allow bot to access S3, it won't be able to physically do this unless a firewall rule is created. 30 | 31 | To create the firewall rules, we create endpoints in the VPC that hosts our task, one for each AWS service our bot needs access to. 32 | Go to [VPC page](https://us-west-2.console.aws.amazon.com/vpc/home?), "Endpoints", and create endpoints for S3, Secrets manager, ECR (Docker container storage and API), CloudWatch: 33 | ![VPC config](./imgs/vpc_config.png) 34 | 35 | 36 | ### Step 1: create security roles 37 | ```bash 38 | ./01.deploy_roles.sh 39 | ``` 40 | Create 2 security roles: one for **launching** the task (needs permissions to pull the docker container, access S3 for storage, etc) and one that the launched task will have (nothing) 41 | 42 | ### Step 2: deploy secrets 43 | ```bash 44 | ./02.deploy_secrets.sh 45 | ``` 46 | This is important step, it fully defines your bot configuration. You define `Witch`/`Flash` addresses here, your instance name, wallet address, etc. 47 | 48 | Takes `.config/$INSTANCE` files and uploads them as 3 separate secrets: `config.json`, `pk` (wallet private key), `args` (command line argument passed to the bot). 49 | 50 | ### Step 3: build and deploy 51 | ```bash 52 | ./03.deploy.sh 53 | ``` 54 | 55 | Build the Docker image (make sure you have the docker daemon up and running), create task definition (tells Fargate that you want to run a task with the image just built and pass secrets from step 2 to it) 56 | 57 | ### Step 4: manually launch the task 58 | This is hard (not impossible) to automate, so this is manual at this point. 59 | 60 | Go to `ECS` console (something like https://us-west-2.console.aws.amazon.com/ecs/home) -> `Task definitions` -> pick the task you just defined -> `Actions` -> `Run task`. Change only these: 61 | * Launch type: `Fargate` 62 | * Cluster VPC: pick the only one available 63 | * Subnets: pick the 1st one 64 | * Security groups: Edit -> Select existing -> pick `default` 65 | * `Run task`! 66 | 67 | The new task instance appears in the cluster. It takes about a minute to set up, then the task should become `RUNNING` 68 | 69 | ### Step 5: adding the new instance to CloudWatch dashboard 70 | Open the dashboard (https://us-west-2.console.aws.amazon.com/cloudwatch/home?region=us-west-2#dashboards:name=yield), edit each graph to add the counters from the new instance there. 71 | Don't forget to copy the new Source for each graph to the monitoring page. Use `dashboard_encode.py` for this: launch it, copy/paste the graph source to it. 72 | 73 | -------------------------------------------------------------------------------- /aws/dashboard_encode.py: -------------------------------------------------------------------------------- 1 | import urllib.parse as UP 2 | import json 3 | import sys 4 | 5 | def main(data): 6 | print("widgetDefinition=%s" % UP.quote(json.dumps(json.loads(data)))) 7 | 8 | if __name__ == "__main__": 9 | main(sys.stdin.read()) -------------------------------------------------------------------------------- /aws/ecs-task-role-trust-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "", 6 | "Effect": "Allow", 7 | "Principal": { 8 | "Service": "ecs-tasks.amazonaws.com" 9 | }, 10 | "Action": "sts:AssumeRole" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /aws/imgs/vpc_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yieldprotocol/yield-liquidator-v2/9a49d9a0e9398f6a6c07bad531e77d1001a1166f/aws/imgs/vpc_config.png -------------------------------------------------------------------------------- /aws/index.html: -------------------------------------------------------------------------------- 1 | 2 |

Active auctions

3 | 4 |

Vaults

5 | 6 |

New block discovery lag

7 | 8 | 9 | -------------------------------------------------------------------------------- /aws/liquidator-task.json: -------------------------------------------------------------------------------- 1 | { 2 | "family": "liquidator-$INSTANCE", 3 | "networkMode": "awsvpc", 4 | "executionRoleArn": "arn:aws:iam::$AWSACCOUNT:role/liquidator-task-execution-role-$INSTANCE", 5 | "taskRoleArn": "arn:aws:iam::$AWSACCOUNT:role/liquidator-task-role-$INSTANCE", 6 | "containerDefinitions": [ 7 | { 8 | "name": "liquidator-$INSTANCE", 9 | "image": "$AWSACCOUNT.dkr.ecr.$REGION.amazonaws.com/yield-v2-liquidator:latest", 10 | "essential": true, 11 | "secrets": [ 12 | { 13 | "name": "L_CONFIG", 14 | "valueFrom": "$CONFIG_KEY" 15 | }, 16 | { 17 | "name": "L_ARGS", 18 | "valueFrom": "$ARGS_KEY" 19 | }, 20 | { 21 | "name": "L_PK", 22 | "valueFrom": "$PK_KEY" 23 | } 24 | ], 25 | "logConfiguration": { 26 | "logDriver": "awslogs", 27 | "options": { 28 | "awslogs-group": "yield", 29 | "awslogs-region": "$REGION", 30 | "awslogs-stream-prefix": "yield-liquidator", 31 | "mode": "non-blocking" 32 | } 33 | } 34 | } 35 | ], 36 | "requiresCompatibilities": [ 37 | "FARGATE" 38 | ], 39 | "cpu": "256", 40 | "memory": "512" 41 | } -------------------------------------------------------------------------------- /aws/liquidator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | export RUST_BACKTRACE=1 6 | export RUST_LOG="liquidator,yield_liquidator=info" 7 | 8 | echo $L_CONFIG > /tmp/config.json 9 | echo $L_PK > /tmp/pk 10 | 11 | exec /usr/bin/liquidator $L_ARGS -c /tmp/config.json -p /tmp/pk -------------------------------------------------------------------------------- /aws/task-execution-role.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "ecr:GetAuthorizationToken", 8 | "ecr:BatchCheckLayerAvailability", 9 | "ecr:GetDownloadUrlForLayer", 10 | "ecr:BatchGetImage" 11 | ], 12 | "Resource": "*" 13 | }, 14 | { 15 | "Effect": "Allow", 16 | "Action": [ 17 | "secretsmanager:GetSecretValue", 18 | "kms:Decrypt" 19 | ], 20 | "Resource": [ 21 | "arn:aws:secretsmanager:$REGION:$AWSACCOUNT:secret:$INSTANCE/*" 22 | ] 23 | }, 24 | { 25 | "Effect": "Allow", 26 | "Action": [ 27 | "logs:CreateLogStream", 28 | "logs:PutLogEvents" 29 | ], 30 | "Resource": "*" 31 | }, 32 | { 33 | "Action": [ 34 | "s3:GetObject" 35 | ], 36 | "Effect": "Allow", 37 | "Resource": ["arn:aws:s3:::prod-$REGION-starport-layer-bucket/*"] 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use ethers::contract::Abigen; 2 | 3 | // TODO: Figure out how to write the rerun-if-changed script properly 4 | fn main() { 5 | // Only re-run the builder script if the contract changes 6 | println!("cargo:rerun-if-changed=./abis/*.json"); 7 | bindgen("Cauldron"); 8 | bindgen("Witch"); 9 | bindgen("FlashLiquidator"); 10 | bindgen("IMulticall2"); 11 | } 12 | 13 | #[allow(dead_code)] 14 | fn bindgen(fname: &str) { 15 | let bindings = Abigen::new(fname, format!("./abis/{}.json", fname)) 16 | .expect("could not instantiate Abigen") 17 | .generate() 18 | .expect("could not generate bindings"); 19 | 20 | bindings 21 | .write_to_file(format!("./src/bindings/{}.rs", fname.to_lowercase())) 22 | .expect("could not write bindings to file"); 23 | } 24 | -------------------------------------------------------------------------------- /contracts/.YvBasicFlashLiquidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | 6 | import "./FlashLiquidator.sol"; 7 | import "@yield-protocol/vault-v2/contracts/oracles/yearn/IYvToken.sol"; 8 | 9 | // @notice This is the Yield Flash liquidator contract for basic Yearn Vault tokens 10 | // @dev This should only be used with basic Yearn Vault Tokens such as yvDAI and yvUSDC 11 | // and should not be used with something like yvcrvstEth which would require additional 12 | // logic to unwrap 13 | contract YvBasicFlashLiquidator is FlashLiquidator { 14 | using UniswapTransferHelper for address; 15 | 16 | constructor( 17 | IWitch witch_, 18 | address factory_, 19 | ISwapRouter swapRouter_ 20 | ) FlashLiquidator( 21 | witch_, 22 | factory_, 23 | swapRouter_ 24 | ) {} 25 | 26 | // @param fee0 The fee from calling flash for token0 27 | // @param fee1 The fee from calling flash for token1 28 | // @param data The data needed in the callback passed as FlashCallbackData from `initFlash` 29 | // @notice implements the callback called from flash 30 | // @dev Unlike the other Yield FlashLiquidator contracts, this contains extra steps to 31 | // unwrap basic yvTokens (yvDai and yvUSDC) 32 | function uniswapV3FlashCallback( 33 | uint256 fee0, 34 | uint256 fee1, 35 | bytes calldata data 36 | ) external override { 37 | // we only borrow 1 token 38 | require(fee0 == 0 || fee1 == 0, "Two tokens were borrowed"); 39 | uint256 fee; 40 | unchecked { 41 | // Since one fee is always zero, this won't overflow 42 | fee = fee0 + fee1; 43 | } 44 | 45 | // decode, verify, and set debtToReturn 46 | FlashCallbackData memory decoded = abi.decode(data, (FlashCallbackData)); 47 | _verifyCallback(decoded.poolKey); 48 | uint256 debtToReturn = decoded.baseLoan + fee; 49 | 50 | // liquidate the vault 51 | decoded.base.safeApprove(decoded.baseJoin, decoded.baseLoan); 52 | witch.payAll(decoded.vaultId, 0); // collateral is paid in yvToken 53 | 54 | // redeem the yvToken for underlying 55 | address underlyingAddress = IYvToken(decoded.collateral).token(); 56 | require(underlyingAddress != address(0), "underlying not found"); 57 | uint256 underlyingRedeemed = IYvToken(decoded.collateral).withdraw(); // defaults to max if no params passed 58 | 59 | uint256 debtRecovered; 60 | if (decoded.base == underlyingAddress) { 61 | debtRecovered = underlyingRedeemed; 62 | } else { 63 | ISwapRouter swapRouter_ = swapRouter; 64 | decoded.collateral.safeApprove(address(swapRouter), underlyingRedeemed); 65 | debtRecovered = swapRouter_.exactInputSingle( 66 | ISwapRouter.ExactInputSingleParams({ 67 | tokenIn: underlyingAddress, 68 | tokenOut: decoded.base, 69 | fee: 500, // can't use the same fee as the flash loan 70 | // because of reentrancy protection 71 | recipient: address(this), 72 | deadline: block.timestamp + 180, 73 | amountIn: underlyingRedeemed, 74 | amountOutMinimum: debtToReturn, // bots will sandwich us and eat profits, we don't mind 75 | sqrtPriceLimitX96: 0 76 | }) 77 | ); 78 | } 79 | // if profitable pay profits to recipient 80 | if (debtRecovered > debtToReturn) { 81 | uint256 profit; 82 | unchecked { 83 | profit = debtRecovered - debtToReturn; 84 | } 85 | decoded.base.safeTransfer(decoded.recipient, profit); 86 | } 87 | // repay flash loan 88 | decoded.base.safeTransfer(msg.sender, debtToReturn); 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /contracts/ChainlinkAggregatorV3Mock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | pragma solidity 0.8.6; 3 | import "./ISourceMock.sol"; 4 | 5 | 6 | contract ChainlinkAggregatorV3Mock is ISourceMock { 7 | int public price; // Prices in Chainlink can be negative (!) 8 | uint public timestamp; 9 | uint8 public decimals = 18; // Decimals provided in the oracle prices 10 | 11 | function set(uint price_) external override {// We provide prices with 18 decimals, which will be scaled Chainlink's decimals 12 | price = int(price_); 13 | timestamp = block.timestamp; 14 | } 15 | 16 | function latestRoundData() public view returns (uint80, int256, uint256, uint256, uint80) { 17 | return (0, price, 0, timestamp, 0); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /contracts/ERC20Mock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | pragma solidity 0.8.6; 3 | import "@yield-protocol/utils-v2/contracts/token/ERC20Permit.sol"; 4 | 5 | 6 | contract ERC20Mock is ERC20Permit { 7 | 8 | constructor( 9 | string memory name, 10 | string memory symbol 11 | ) ERC20Permit(name, symbol, 18) { } 12 | 13 | /// @dev Give tokens to whoever asks for them. 14 | function mint(address to, uint256 amount) public virtual { 15 | _mint(to, amount); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /contracts/FlashLiquidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | import "@yield-protocol/vault-interfaces/src/ICauldron.sol"; 6 | import "@yield-protocol/vault-interfaces/src/IWitch.sol"; 7 | import "uniswapv3-oracle/contracts/uniswapv0.8/IUniswapV3Pool.sol"; 8 | import "uniswapv3-oracle/contracts/uniswapv0.8/PoolAddress.sol"; 9 | import "./UniswapTransferHelper.sol"; 10 | import "./balancer/IFlashLoan.sol"; 11 | 12 | 13 | // @notice This is the standard Flash Liquidator used with Yield liquidator bots for most collateral types 14 | contract FlashLiquidator is IFlashLoanRecipient { 15 | using UniswapTransferHelper for address; 16 | 17 | // DAI official token -- "otherToken" for UniV3Pool flash loan 18 | address public constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; 19 | // WETH official token -- alternate "otherToken" 20 | address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; 21 | 22 | 23 | ICauldron public immutable cauldron; // Yield Cauldron 24 | IWitch public immutable witch; // Yield Witch 25 | address public immutable swapRouter02; // UniswapV3 swapRouter 02 26 | IFlashLoan public immutable flashLoaner; // balancer flashloan 27 | 28 | bool public liquidating; // reentrance + rogue callback protection 29 | 30 | struct FlashCallbackData { 31 | bytes12 vaultId; 32 | address base; 33 | address collateral; 34 | address baseJoin; 35 | address recipient; 36 | bytes swapCalldata; 37 | } 38 | 39 | // @dev Parameter order matters 40 | constructor( 41 | IWitch witch_, 42 | address swapRouter_, 43 | IFlashLoan flashLoaner_ 44 | ) { 45 | witch = witch_; 46 | cauldron = witch_.cauldron(); 47 | swapRouter02 = swapRouter_; 48 | flashLoaner = flashLoaner_; 49 | } 50 | 51 | // @notice This is used by the bot to determine the current collateral to debt ratio 52 | // @dev Cauldron.level returns collateral * price(collateral, denominator=debt) - debt * ratio * accrual 53 | // but the bot needs collateral * price(collateral, denominator=debt)/debt * accrual 54 | // @param vaultId id of vault to check 55 | // @return adjusted collateralization level 56 | function collateralToDebtRatio(bytes12 vaultId) public 57 | returns (uint256) { 58 | DataTypes.Vault memory vault = cauldron.vaults(vaultId); 59 | DataTypes.Balances memory balances = cauldron.balances(vaultId); 60 | DataTypes.Series memory series = cauldron.series(vault.seriesId); 61 | 62 | if (balances.art == 0) { 63 | return 0; 64 | } 65 | (uint256 inkValue,) = cauldron.spotOracles(series.baseId, vault.ilkId).oracle.get(vault.ilkId, series.baseId, balances.ink); // ink * spot 66 | uint128 accrued_debt = cauldron.debtToBase(vault.seriesId, balances.art); 67 | return inkValue * 1e18 / accrued_debt; 68 | } 69 | 70 | // @notice flash loan callback, see IFlashLoanRecipient for details 71 | // @param tokens tokens loaned 72 | // @param amounts amounts of tokens loaned 73 | // @param feeAmounts flash loan fees 74 | function receiveFlashLoan( 75 | address[] memory tokens, 76 | uint256[] memory amounts, 77 | uint256[] memory feeAmounts, 78 | bytes memory userData) public virtual override { 79 | require(liquidating && msg.sender == address(flashLoaner), "baka"); 80 | require(tokens.length == 1, "1 token expected"); 81 | 82 | // decode and verify 83 | FlashCallbackData memory decoded = abi.decode(userData, (FlashCallbackData)); 84 | 85 | uint256 baseLoan = amounts[0]; 86 | // liquidate the vault 87 | decoded.base.safeApprove(decoded.baseJoin, baseLoan); 88 | uint256 collateralReceived = witch.payAll(decoded.vaultId, 0); 89 | 90 | // sell the collateral 91 | uint256 debtToReturn = baseLoan + feeAmounts[0]; 92 | decoded.collateral.safeApprove(address(swapRouter02), collateralReceived); 93 | (bool ok, bytes memory swapReturnBytes) = swapRouter02.call(decoded.swapCalldata); 94 | require(ok, "swap failed"); 95 | 96 | // router can't access collateral anymore 97 | decoded.collateral.safeApprove(address(swapRouter02), 0); 98 | 99 | // take all remaining collateral 100 | decoded.collateral.safeTransfer(decoded.recipient, IERC20(decoded.collateral).balanceOf(address(this))); 101 | 102 | // repay flash loan 103 | decoded.base.safeTransfer(msg.sender, debtToReturn); 104 | } 105 | 106 | // @notice Liquidates a vault with help from a flash loan 107 | // @param vaultId The vault to liquidate 108 | function liquidate(bytes12 vaultId, bytes calldata swapCalldata) external { 109 | require(!liquidating, "go away baka"); 110 | liquidating = true; 111 | 112 | (, uint32 start) = witch.auctions(vaultId); 113 | require(start > 0, "Vault not under auction"); 114 | DataTypes.Vault memory vault = cauldron.vaults(vaultId); 115 | DataTypes.Balances memory balances = cauldron.balances(vaultId); 116 | DataTypes.Series memory series = cauldron.series(vault.seriesId); 117 | address baseToken = cauldron.assets(series.baseId); 118 | uint128 baseLoan = cauldron.debtToBase(vault.seriesId, balances.art); 119 | address collateral = cauldron.assets(vault.ilkId); 120 | 121 | // data for the callback to know what to do 122 | FlashCallbackData memory args = FlashCallbackData({ 123 | vaultId: vaultId, 124 | base: baseToken, 125 | collateral: collateral, 126 | baseJoin: address(witch.ladle().joins(series.baseId)), 127 | swapCalldata: swapCalldata, 128 | recipient: msg.sender // We will get front-run by generalized front-runners, this is desired as it reduces our gas costs 129 | }); 130 | 131 | address[] memory tokens = new address[](1); 132 | tokens[0] = baseToken; 133 | uint256[] memory amounts = new uint256[](1); 134 | amounts[0] = baseLoan; 135 | // initiate flash loan, with the liquidation logic embedded in the flash loan callback 136 | flashLoaner.flashLoan(this, tokens, amounts, abi.encode(args)); 137 | 138 | liquidating = false; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /contracts/ICurveStableSwap.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | interface ICurveStableSwap { 6 | // @notice Perform an exchange between two coins 7 | // @dev Index values can be found via the `coins` public getter method 8 | // @dev see: https://etherscan.io/address/0xDC24316b9AE028F1497c275EB9192a3Ea0f67022#readContract 9 | // @param i Index value for the stEth to send -- 1 10 | // @param j Index value of the Eth to recieve -- 0 11 | // @param dx Amount of `i` (stEth) being exchanged 12 | // @param minDy Minimum amount of `j` (Eth) to receive 13 | // @return Actual amount of `j` (Eth) received 14 | function exchange( 15 | int128 i, 16 | int128 j, 17 | uint256 dx, 18 | uint256 minDy 19 | ) external payable returns (uint256); 20 | } 21 | -------------------------------------------------------------------------------- /contracts/IMulticall2.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | 4 | pragma solidity >=0.8.0; 5 | 6 | /// @title Multicall2 - Aggregate results from multiple read-only function calls 7 | /// @author Michael Elliot 8 | /// @author Joshua Levine 9 | /// @author Nick Johnson 10 | 11 | interface IMulticall2 { 12 | struct CallData { 13 | address target; 14 | bytes callData; 15 | } 16 | struct ResultData { 17 | bool success; 18 | bytes returnData; 19 | } 20 | function tryAggregate(bool requireSuccess, CallData[] memory calls) external returns (ResultData[] memory returnData); 21 | } -------------------------------------------------------------------------------- /contracts/ISourceMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: BUSL-1.1 2 | pragma solidity 0.8.6; 3 | 4 | interface ISourceMock { 5 | function set(uint) external; 6 | } 7 | -------------------------------------------------------------------------------- /contracts/IWstEth.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity >=0.6.0; 3 | import "@yield-protocol/utils-v2/contracts/token/IERC20.sol"; 4 | 5 | interface IWstEth is IERC20 { 6 | /** 7 | * @notice Exchanges wstEth to stEth 8 | * @param _wstEthAmount amount of wstEth to uwrap in exchange for stEth 9 | * @dev Requirements: 10 | * - `_wstEthAmount` must be non-zero 11 | * - msg.sender must have at least `_wstEthAmount` wstEth. 12 | * @return Amount of stEth user receives after unwrap 13 | */ 14 | function unwrap(uint256 _wstEthAmount) external returns (uint256); 15 | } 16 | -------------------------------------------------------------------------------- /contracts/TypechainImporter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-3.0-or-later 2 | pragma solidity ^0.8.1; 3 | 4 | import "@yield-protocol/vault-v2/contracts/Cauldron.sol"; 5 | import "@yield-protocol/vault-v2/contracts/Join.sol"; 6 | import "@yield-protocol/vault-v2/contracts/oracles/compound/CompoundMultiOracle.sol"; 7 | import "@yield-protocol/vault-v2/contracts/oracles/chainlink/ChainlinkMultiOracle.sol"; 8 | import "@yield-protocol/vault-v2/contracts/FYToken.sol"; 9 | import "@yield-protocol/vault-v2/contracts/Witch.sol"; 10 | import "@yield-protocol/utils-v2/contracts/interfaces/IWETH9.sol"; -------------------------------------------------------------------------------- /contracts/UniswapTransferHelper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | import '@yield-protocol/utils-v2/contracts/interfaces/IWETH9.sol'; 6 | 7 | // File @uniswap/v3-periphery/contracts/libraries/TransferHelper.sol@v1.1.1 8 | // 9 | library UniswapTransferHelper { 10 | // @notice Transfers tokens from msg.sender to a recipient 11 | // @dev Errors with ST if transfer fails 12 | // @param token The contract address of the token which will be transferred 13 | // @param to The recipient of the transfer 14 | // @param value The value of the transfer 15 | function safeTransfer( 16 | address token, 17 | address to, 18 | uint256 value 19 | ) internal { 20 | (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); 21 | require(success && (data.length == 0 || abi.decode(data, (bool))), 'ST'); 22 | } 23 | 24 | // @notice Approves the stipulated contract to spend the given allowance in the given token 25 | // @dev Errors with "SA" if transfer fails 26 | // @param token The contract address of the token to be approved 27 | // @param to The target of the approval 28 | // @param value The amount of the given token the target will be allowed to spend 29 | function safeApprove( 30 | address token, 31 | address to, 32 | uint256 value 33 | ) internal { 34 | (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); 35 | require(success && (data.length == 0 || abi.decode(data, (bool))), 'SA'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /contracts/WstethFlashLiquidator.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | import "./FlashLiquidator.sol"; 6 | import "./ICurveStableSwap.sol"; 7 | import "./IWstEth.sol"; 8 | 9 | // @notice This is the Yield Flash liquidator contract for Lido Wrapped Staked Ether (WSTETH) 10 | contract WstethFlashLiquidator is FlashLiquidator { 11 | using UniswapTransferHelper for address; 12 | using UniswapTransferHelper for IWstEth; 13 | 14 | // @notice "i" and "j" are the first two parameters (token to sell and token to receive respectively) 15 | // in the CurveStableSwap.exchange function. They represent that contract's internally stored 16 | // index of the token being swapped 17 | // @dev The STETH/ETH pool only supports two tokens: ETH index: 0, STETH index: 1 18 | // https://etherscan.io/address/0xDC24316b9AE028F1497c275EB9192a3Ea0f67022#readContract 19 | // This can be confirmed by calling the "coins" function on the CurveStableSwap contract 20 | // 0 -> 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE == ETH (the address Curve uses to represent ETH -- see github link below) 21 | // 1 -> 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 == STETH (deployed contract address of STETH) 22 | // https://github.com/curvefi/curve-contract/blob/b0bbf77f8f93c9c5f4e415bce9cd71f0cdee960e/contracts/pools/steth/StableSwapSTETH.vy#L143 23 | int128 public constant CURVE_EXCHANGE_PARAMETER_I = 1; // token to sell (STETH, index 1 on Curve contract) 24 | int128 public constant CURVE_EXCHANGE_PARAMETER_J = 0; // token to receive (ETH, index 0 on Curve contract) 25 | 26 | // @notice stEth and wstEth deployed contracts https://docs.lido.fi/deployed-contracts/ 27 | address public constant STETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; 28 | address public constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; 29 | 30 | // @notice Curve stEth/Eth pool https://curve.readthedocs.io/ref-addresses.html 31 | address public constant CURVE_SWAP = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022; 32 | 33 | constructor( 34 | IWitch witch_, 35 | address swapRouter_, 36 | IFlashLoan flashLoaner_ 37 | ) FlashLiquidator( 38 | witch_, 39 | swapRouter_, 40 | flashLoaner_ 41 | ) {} 42 | 43 | // @dev Required to receive ETH from Curve 44 | receive() external payable {} 45 | 46 | // @notice flash loan callback, see IFlashLoanRecipient for details 47 | // @param tokens tokens loaned 48 | // @param amounts amounts of tokens loaned 49 | // @param feeAmounts flash loan fees 50 | // @dev Unlike the other Yield FlashLiquidator contracts, this contains extra steps to 51 | // unwrap WSTETH and swap it for Eth on Curve before Uniswapping it for base 52 | function receiveFlashLoan( 53 | address[] memory tokens, 54 | uint256[] memory amounts, 55 | uint256[] memory feeAmounts, 56 | bytes memory userData) public override { 57 | 58 | require(liquidating && msg.sender == address(flashLoaner), "baka"); 59 | require(tokens.length == 1 , "1 token expected"); 60 | 61 | // decode, verify, and set debtToReturn 62 | FlashCallbackData memory decoded = abi.decode(userData, (FlashCallbackData)); 63 | 64 | uint256 baseLoan = amounts[0]; 65 | uint256 debtToReturn = baseLoan + feeAmounts[0]; 66 | 67 | // liquidate the vault 68 | decoded.base.safeApprove(decoded.baseJoin, baseLoan); 69 | uint256 collateralReceived = witch.payAll(decoded.vaultId, 0); 70 | 71 | // Sell collateral: 72 | 73 | // Step 1 - unwrap WSTETH => STETH 74 | uint256 unwrappedStEth = IWstEth(WSTETH).unwrap(collateralReceived); 75 | 76 | // Step 2 - swap STETH for Eth on Curve 77 | STETH.safeApprove(CURVE_SWAP, unwrappedStEth); 78 | uint256 ethReceived = ICurveStableSwap(CURVE_SWAP).exchange( 79 | CURVE_EXCHANGE_PARAMETER_I, // index 1 representing STETH 80 | CURVE_EXCHANGE_PARAMETER_J, // index 0 representing ETH 81 | unwrappedStEth, // amount to swap 82 | 0 // no slippage guard 83 | ); 84 | 85 | // Step 3 - wrap the Eth => Weth 86 | IWETH9(WETH).deposit{ value: ethReceived }(); 87 | 88 | // Step 4 - if necessary, swap Weth for base on UniSwap 89 | uint256 debtRecovered; 90 | if (decoded.base == WETH) { 91 | debtRecovered = ethReceived; 92 | } else { 93 | address swapRouter_ = swapRouter02; 94 | WETH.safeApprove(address(swapRouter_), ethReceived); 95 | (bool ok, bytes memory swapReturnBytes) = swapRouter02.call(decoded.swapCalldata); 96 | require(ok, "swap failed"); 97 | } 98 | 99 | // if profitable pay profits to recipient 100 | if (debtRecovered > debtToReturn) { 101 | uint256 profit; 102 | unchecked { 103 | profit = debtRecovered - debtToReturn; 104 | } 105 | decoded.base.safeTransfer(decoded.recipient, profit); 106 | } 107 | // repay flash loan 108 | decoded.base.safeTransfer(msg.sender, debtToReturn); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /contracts/balancer/IFlashLoan.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | import './IFlashLoanRecipient.sol'; 6 | 7 | interface IFlashLoan { 8 | function flashLoan( 9 | IFlashLoanRecipient recipient, 10 | address[] memory tokens, 11 | uint256[] memory amounts, 12 | bytes memory userData 13 | ) external; 14 | } 15 | -------------------------------------------------------------------------------- /contracts/balancer/IFlashLoanRecipient.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: GPL-2.0-or-later 2 | 3 | pragma solidity >=0.8.6; 4 | 5 | interface IFlashLoanRecipient { 6 | /** 7 | * @dev When `flashLoan` is called on the Vault, it invokes the `receiveFlashLoan` hook on the recipient. 8 | * 9 | * At the time of the call, the Vault will have transferred `amounts` for `tokens` to the recipient. Before this 10 | * call returns, the recipient must have transferred `amounts` plus `feeAmounts` for each token back to the 11 | * Vault, or else the entire flash loan will revert. 12 | * 13 | * `userData` is the same value passed in the `IVault.flashLoan` call. 14 | */ 15 | function receiveFlashLoan( 16 | address[] memory tokens, 17 | uint256[] memory amounts, 18 | uint256[] memory feeAmounts, 19 | bytes memory userData 20 | ) external; 21 | } 22 | -------------------------------------------------------------------------------- /docs/ARCHITECTURE/Yield v2 Liquidations Bot Architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yieldprotocol/yield-liquidator-v2/9a49d9a0e9398f6a6c07bad531e77d1001a1166f/docs/ARCHITECTURE/Yield v2 Liquidations Bot Architecture.png -------------------------------------------------------------------------------- /docs/ARCHITECTURE/borrowers update_vaults.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yieldprotocol/yield-liquidator-v2/9a49d9a0e9398f6a6c07bad531e77d1001a1166f/docs/ARCHITECTURE/borrowers update_vaults.png -------------------------------------------------------------------------------- /docs/ARCHITECTURE/liquidations buy_opportunities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yieldprotocol/yield-liquidator-v2/9a49d9a0e9398f6a6c07bad531e77d1001a1166f/docs/ARCHITECTURE/liquidations buy_opportunities.png -------------------------------------------------------------------------------- /docs/ARCHITECTURE/liquidations remove_or_bump.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yieldprotocol/yield-liquidator-v2/9a49d9a0e9398f6a6c07bad531e77d1001a1166f/docs/ARCHITECTURE/liquidations remove_or_bump.png -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | 4 | import '@nomiclabs/hardhat-waffle' 5 | import "@nomiclabs/hardhat-ethers"; 6 | import '@nomiclabs/hardhat-etherscan' 7 | 8 | import '@typechain/hardhat' 9 | 10 | import 'hardhat-abi-exporter' 11 | import 'hardhat-contract-sizer' 12 | import 'hardhat-gas-reporter' 13 | import 'solidity-coverage' 14 | import 'hardhat-deploy' 15 | 16 | import { task } from 'hardhat/config' 17 | 18 | // import 'hardhat-dapptools' 19 | 20 | 21 | task("regression-test", async function(_args, hre, _runSuper) { 22 | return hre.run("test", {testFiles: ["regression_tests/flashLiquidator.ts"]}) 23 | }) 24 | 25 | function nodeUrl(network: any) { 26 | let infuraKey 27 | try { 28 | infuraKey = fs.readFileSync(path.resolve(__dirname, '.infuraKey')).toString().trim() 29 | } catch(e) { 30 | infuraKey = '' 31 | } 32 | return `https://${network}.infura.io/v3/${infuraKey}` 33 | } 34 | 35 | let mnemonic = process.env.MNEMONIC 36 | if (!mnemonic) { 37 | try { 38 | mnemonic = fs.readFileSync(path.resolve(__dirname, '.secret')).toString().trim() 39 | } catch(e){} 40 | } 41 | const accounts = mnemonic ? { 42 | mnemonic, 43 | }: undefined 44 | 45 | let etherscanKey = process.env.ETHERSCANKEY 46 | if (!etherscanKey) { 47 | try { 48 | etherscanKey = fs.readFileSync(path.resolve(__dirname, '.etherscanKey')).toString().trim() 49 | } catch(e){} 50 | } 51 | 52 | module.exports = { 53 | solidity: { 54 | version: '0.8.6', 55 | settings: { 56 | optimizer: { 57 | enabled: true, 58 | runs: 1000, 59 | } 60 | } 61 | }, 62 | abiExporter: { 63 | path: './abis', 64 | clear: true, 65 | flat: true, 66 | // only: [':ERC20$'], 67 | spacing: 2 68 | }, 69 | typechain: { 70 | outDir: 'typechain', 71 | target: 'ethers-v5', 72 | }, 73 | contractSizer: { 74 | alphaSort: true, 75 | runOnCompile: false, 76 | disambiguatePaths: false, 77 | }, 78 | gasReporter: { 79 | enabled: true, 80 | }, 81 | defaultNetwork: 'hardhat', 82 | namedAccounts: { 83 | deployer: 0, 84 | owner: 1, 85 | other: 2, 86 | }, 87 | networks: { 88 | hardhat: { 89 | accounts, 90 | chainId: 31337, 91 | timeout: 600000, 92 | blockGasLimit: 300_000_000 93 | }, 94 | localhost: { 95 | chainId: 31337, 96 | timeout: 600000 97 | }, 98 | kovan: { 99 | accounts, 100 | gasPrice: 10000000000, 101 | timeout: 600000, 102 | url: nodeUrl('kovan') 103 | }, 104 | goerli: { 105 | accounts, 106 | url: nodeUrl('goerli'), 107 | }, 108 | rinkeby: { 109 | accounts, 110 | url: nodeUrl('rinkeby') 111 | }, 112 | ropsten: { 113 | accounts, 114 | url: nodeUrl('ropsten') 115 | }, 116 | mainnet: { 117 | accounts, 118 | gasPrice: 200000000000, 119 | timeout: 60000000, 120 | url: nodeUrl('mainnet') 121 | }, 122 | coverage: { 123 | url: 'http://127.0.0.1:8555', 124 | }, 125 | }, 126 | etherscan: { 127 | apiKey: etherscanKey 128 | }, 129 | } 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yield-protocol/yield-liquidator-v2", 3 | "description": "Yield Flash Liquidator", 4 | "version": "0.0.5-rc5", 5 | "engines": { 6 | "node": ">=12" 7 | }, 8 | "files": [ 9 | "contracts/*.sol" 10 | ], 11 | "main": "index.js", 12 | "author": "Yield Inc.", 13 | "scripts": { 14 | "build": "hardhat compile", 15 | "test": "hardhat test", 16 | "test:deploy": "hardhat deploy --tags DeployTest", 17 | "coverage": "hardhat coverage", 18 | "lint:sol": "solhint -f table contracts/*.sol", 19 | "lint:ts": "prettier ./scripts/*.ts --check", 20 | "lint:ts:fix": "prettier ./scripts/*.ts --write", 21 | "prepublishOnly": "npx tsdx build --tsconfig ./tsconfig-publish.json", 22 | "buildRouter": "tsc && pkg -o build/bin/router build/scripts/router.js" 23 | }, 24 | "devDependencies": { 25 | "@ethersproject/abi": "^5.0.0", 26 | "@ethersproject/bytes": "^5.0.0", 27 | "@nomiclabs/hardhat-ethers": "^2.0.1", 28 | "@nomiclabs/hardhat-etherscan": "^2.1.0", 29 | "@nomiclabs/hardhat-waffle": "^2.0.1", 30 | "@openzeppelin/contracts": "^4.1.0", 31 | "@sinonjs/fake-timers": "^9.1.0", 32 | "@truffle/hdwallet-provider": "^1.0.40", 33 | "@typechain/ethers-v5": "^8.0.5", 34 | "@typechain/hardhat": "^3.0.0", 35 | "@types/mocha": "^9.0.0", 36 | "@uniswap/sdk-core": "^3.0.1", 37 | "@uniswap/smart-order-router": "^2.5.15", 38 | "@yield-protocol/utils-v2": "2.4.6", 39 | "@yield-protocol/vault-interfaces": "2.4.1", 40 | "@yield-protocol/vault-v2": "0.16.1-rc1", 41 | "chai": "4.2.0", 42 | "dss-interfaces": "0.1.1", 43 | "erc3156": "^0.4.8", 44 | "ethereum-waffle": "^3.2.2", 45 | "ethers": "^5.1.3", 46 | "hardhat": "^2.6.0", 47 | "hardhat-abi-exporter": "^2.0.3", 48 | "hardhat-contract-sizer": "^2.0.3", 49 | "hardhat-deploy": "^0.9.14", 50 | "hardhat-gas-reporter": "^1.0.3", 51 | "mocha": "^7.1.0", 52 | "pkg": "^5.5.2", 53 | "prettier": "^2.0.5", 54 | "solhint": "^3.3.3", 55 | "solidity-coverage": "^0.7.14", 56 | "ts-command-line-args": "^2.2.1", 57 | "ts-node": "^8.10.2", 58 | "tslog": "^3.3.0", 59 | "typechain": "^6.0.4", 60 | "typescript": "^4.5.4", 61 | "uniswapv3-oracle": "^1.0.0", 62 | "yargs": "^17.0.1" 63 | }, 64 | "repository": { 65 | "url": "git+https://github.com/yieldprotocol/yield-liquidator-v2.git", 66 | "type": "git" 67 | }, 68 | "bugs": { 69 | "url": "https://github.com/yieldprotocol/yield-liquidator-v2/issues" 70 | }, 71 | "license": "GPL-3.0-or-later", 72 | "homepage": "https://github.com/yieldprotocol/yield-liquidator-v2#readme", 73 | "dependencies": { 74 | "@types/chai": "^4.2.22", 75 | "latest": "^0.2.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /regression_tests/flashLiquidator.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 2 | import { FlashLiquidator, FlashLiquidator__factory } from "../typechain"; 3 | 4 | import { expect } from "chai"; 5 | import { config, ethers, network, run } from "hardhat"; 6 | import { subtask } from "hardhat/config"; 7 | import { normalizeHardhatNetworkAccountsConfig } from "hardhat/internal/core/providers/util"; 8 | 9 | import { Logger } from "tslog"; 10 | 11 | import { Readable } from "stream"; 12 | import { createInterface } from "readline"; 13 | // import { readFile, mkdtemp, writeFile } from "fs/promises"; 14 | import { promises as fs } from 'fs'; 15 | import { tmpdir } from "os"; 16 | import { join } from "path"; 17 | import { promisify } from "util"; 18 | import { exec as exec_async } from "child_process"; 19 | import { HardhatNetworkAccountConfig } from "hardhat/types/config"; 20 | import { TransactionResponse } from "@ethersproject/abstract-provider"; 21 | 22 | const exec = promisify(exec_async); 23 | 24 | const logger: Logger = new Logger(); 25 | 26 | const g_witch = "0x53C3760670f6091E1eC76B4dd27f73ba4CAd5061" 27 | const g_uni_router_02 = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"; 28 | const g_flash_loaner = '0xBA12222222228d8Ba445958a75a0704d566BF2C8' 29 | 30 | async function fork(block_number: number) { 31 | const alchemy_key = (await fs.readFile(join(__dirname, "..", '.alchemyKey'))).toString().trim() 32 | 33 | await network.provider.request({ 34 | method: "hardhat_reset", 35 | params: [ 36 | { 37 | forking: { 38 | jsonRpcUrl: `https://eth-mainnet.alchemyapi.io/v2/${alchemy_key}`, 39 | blockNumber: block_number, 40 | }, 41 | }, 42 | ], 43 | }); 44 | } 45 | 46 | async function deploy_flash_liquidator(): Promise<[SignerWithAddress, FlashLiquidator]> { 47 | const [owner] = await ethers.getSigners() as SignerWithAddress[]; 48 | 49 | const flFactory = await ethers.getContractFactory("FlashLiquidator") as FlashLiquidator__factory; 50 | 51 | 52 | const liquidator = await flFactory.deploy(g_witch, g_uni_router_02, g_flash_loaner) as FlashLiquidator 53 | return [owner, liquidator]; 54 | } 55 | 56 | async function run_liquidator(tmp_root: string, liquidator: FlashLiquidator, 57 | base_to_debt_threshold: { [name: string]: string } = {}) { 58 | 59 | const config_path = join(tmp_root, "config.json") 60 | await fs.writeFile(config_path, JSON.stringify({ 61 | "Witch": g_witch, 62 | "Flash": liquidator.address, 63 | "Multicall2": "0x5ba1e12693dc8f9c48aad8770482f4739beed696", 64 | "BaseToDebtThreshold": base_to_debt_threshold, 65 | "SwapRouter02": g_uni_router_02 66 | }, undefined, 2)) 67 | 68 | logger.info("Liquidator deployed: ", liquidator.address) 69 | const accounts = normalizeHardhatNetworkAccountsConfig( 70 | config.networks[network.name].accounts as HardhatNetworkAccountConfig[] 71 | ); 72 | 73 | const private_key_path = join(tmp_root, "private_key") 74 | await fs.writeFile(private_key_path, accounts[0].privateKey.substring(2)) 75 | const cmd = `cargo run -- -c ${config_path} -u http://127.0.0.1:8545/ -C ${network.config.chainId} \ 76 | -p ${private_key_path} \ 77 | --gas-boost 10 \ 78 | --swap-router-binary build/bin/router \ 79 | --one-shot \ 80 | --json-log \ 81 | --file /dev/null` 82 | 83 | let stdout: string; 84 | let stderr: string 85 | try { 86 | const results = await exec(cmd, { 87 | encoding: "utf-8", env: { 88 | "RUST_BACKTRACE": "1", 89 | "RUST_LOG": "liquidator,yield_liquidator=debug", 90 | ...process.env 91 | }, 92 | maxBuffer: 1024 * 1024 * 10 93 | }) 94 | stdout = results.stdout 95 | stderr = results.stderr 96 | } catch (x) { 97 | stdout = (x as any).stdout; 98 | stderr = (x as any).stderr; 99 | } 100 | await fs.writeFile(join(tmp_root, "stdout"), stdout) 101 | await fs.writeFile(join(tmp_root, "stderr"), stderr) 102 | logger.info("tmp root", tmp_root) 103 | 104 | const rl = createInterface({ 105 | input: Readable.from(stdout), 106 | crlfDelay: Infinity 107 | }); 108 | 109 | const ret = new Array(); 110 | for await (const line of rl) { 111 | ret.push(JSON.parse(line)); 112 | } 113 | return ret; 114 | } 115 | 116 | describe("flash liquidator", function () { 117 | let tmp_root: string; 118 | 119 | this.beforeAll(async function () { 120 | return new Promise((resolve, fail) => { 121 | run("node", { silent: true }); 122 | 123 | // launch hardhat node so that external processes can access it 124 | subtask("node:server-ready", async function (args, _hre, runSuper) { 125 | try { 126 | await runSuper(args); 127 | logger.info("node launched"); 128 | resolve() 129 | } catch { 130 | fail(); 131 | } 132 | }) 133 | }) 134 | }) 135 | this.beforeEach(async function () { 136 | tmp_root = await fs.mkdtemp(join(tmpdir(), "flash_liquidator_test")) 137 | }) 138 | 139 | it("liquidates ENS vaults on Dec-14-2021 (block: 13804681)", async function () { 140 | this.timeout(1800e3); 141 | 142 | await fork(13804681); 143 | const [_owner, liquidator] = await deploy_flash_liquidator(); 144 | 145 | const starting_balance = await _owner.getBalance(); 146 | 147 | const liquidator_logs = await run_liquidator(tmp_root, liquidator); 148 | 149 | let bought = 0; 150 | 151 | for (const log_record of liquidator_logs) { 152 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted buy order") { 153 | bought++; 154 | } 155 | expect(log_record["level"]).to.not.equal("ERROR"); // no errors allowed 156 | } 157 | expect(bought).to.be.equal(5) 158 | 159 | const final_balance = await _owner.getBalance(); 160 | logger.warn("ETH used: ", starting_balance.sub(final_balance).div(1e12).toString(), "uETH") 161 | }); 162 | 163 | it("does not liquidate base==collateral vaults Dec-30-2021 (block: 13911677)", async function () { 164 | this.timeout(1800e3); 165 | 166 | await fork(13911677) 167 | const [_owner, liquidator] = await deploy_flash_liquidator(); 168 | 169 | const liquidator_logs = await run_liquidator(tmp_root, liquidator); 170 | 171 | const vault_not_to_be_auctioned = "00cbb039b7b8103611a9717f"; 172 | 173 | let new_vaults_message; 174 | 175 | for (const log_record of liquidator_logs) { 176 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted liquidation") { 177 | const vault_id = log_record["fields"]["vault_id"]; 178 | expect(vault_id).to.not.equal(`"${vault_not_to_be_auctioned}"`); 179 | } 180 | if (log_record["fields"]["message"] && log_record["fields"]["message"].startsWith("New vaults: ")) { 181 | new_vaults_message = log_record["fields"]["message"]; 182 | } 183 | } 184 | // to make sure the bot did something and did not just crash 185 | expect(new_vaults_message).to.be.equal("New vaults: 1086"); 186 | }); 187 | 188 | it("does not liquidate <1000 USDC vaults Jan-24-2022 (block: 14070324)", async function () { 189 | this.timeout(1800e3); 190 | 191 | await fork(14070324) 192 | const [_owner, liquidator] = await deploy_flash_liquidator(); 193 | 194 | const liquidator_logs = await run_liquidator(tmp_root, liquidator, { 195 | "303200000000": "1000000000" 196 | }); 197 | 198 | const vault_not_to_be_auctioned = "468ff2cb1b8bb57bf932ab3f"; 199 | 200 | let new_vaults_message; 201 | 202 | for (const log_record of liquidator_logs) { 203 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted buy order") { 204 | const vault_id = log_record["fields"]["vault_id"]; 205 | expect(vault_id).to.not.equal(`"${vault_not_to_be_auctioned}"`); 206 | } 207 | if (log_record["fields"]["message"] && log_record["fields"]["message"].startsWith("New vaults: ")) { 208 | new_vaults_message = log_record["fields"]["message"]; 209 | } 210 | } 211 | // to make sure the bot did something and did not just crash 212 | expect(new_vaults_message).to.be.equal("New vaults: 1397"); 213 | }); 214 | 215 | it("does not liquidate <1000 DAI vaults Jan-24-2022 (block: 14070324)", async function () { 216 | this.timeout(1800e3); 217 | 218 | await fork(14070324) 219 | const [_owner, liquidator] = await deploy_flash_liquidator(); 220 | 221 | const liquidator_logs = await run_liquidator(tmp_root, liquidator, { 222 | "303100000000": "1000000000000000000000" 223 | }); 224 | 225 | const vault_not_to_be_auctioned = "9f78a0b12bc8152573520d52"; 226 | 227 | let new_vaults_message; 228 | 229 | for (const log_record of liquidator_logs) { 230 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted buy order") { 231 | const vault_id = log_record["fields"]["vault_id"]; 232 | expect(vault_id).to.not.equal(`"${vault_not_to_be_auctioned}"`); 233 | } 234 | if (log_record["fields"]["message"] && log_record["fields"]["message"].startsWith("New vaults: ")) { 235 | new_vaults_message = log_record["fields"]["message"]; 236 | } 237 | } 238 | // to make sure the bot did something and did not just crash 239 | expect(new_vaults_message).to.be.equal("New vaults: 1397"); 240 | }); 241 | 242 | 243 | describe("90% collateral offer", function () { 244 | const test_vault_id = "3ddcb12f945cd58f4acf26c7"; 245 | const auction_started_in_block = 13900229; // 1640781211 ~= 04:33:31 246 | const liquidated_in_block = 13900498; // 1640784847 ~= 05:34:07 247 | 248 | const auction_start = 1640781211; 249 | const ilk_id = "0x303300000000"; 250 | const duration = 3600; 251 | const initial_offer = 666000; // .000000000000666000 really? 252 | 253 | it("triggers liquidation upon expiry", async function () { 254 | this.timeout(1800e3); 255 | 256 | // block timestamp: 1640784562 ~= 05:29:22; ~95% collateral is offered 257 | await fork(13900485); 258 | const [_owner, liquidator] = await deploy_flash_liquidator(); 259 | 260 | const liquidator_logs = await run_liquidator(tmp_root, liquidator); 261 | 262 | let vault_is_liquidated = false; 263 | for (const log_record of liquidator_logs) { 264 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted buy order") { 265 | const vault_id = log_record["fields"]["vault_id"]; 266 | if (vault_id == `"${test_vault_id}"`) { 267 | vault_is_liquidated = true; 268 | } 269 | } 270 | } 271 | expect(vault_is_liquidated).to.equal(true); 272 | }) 273 | 274 | it("does not trigger liquidation before expiry", async function () { 275 | this.timeout(1800e3); 276 | 277 | // block timestamp: 1640782880 ~= 05:01:20; ~50% collateral is offered 278 | await fork(13900364); 279 | const [_owner, liquidator] = await deploy_flash_liquidator(); 280 | 281 | const liquidator_logs = await run_liquidator(tmp_root, liquidator); 282 | 283 | let new_vaults_message; 284 | for (const log_record of liquidator_logs) { 285 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted buy order") { 286 | const vault_id = log_record["fields"]["vault_id"]; 287 | expect(vault_id).to.not.equal(`"${test_vault_id}"`); 288 | } 289 | if (log_record["fields"]["message"] && log_record["fields"]["message"].startsWith("New vaults: ")) { 290 | new_vaults_message = log_record["fields"]["message"]; 291 | } 292 | 293 | } 294 | // to make sure the bot did something and did not just crash 295 | expect(new_vaults_message).to.be.equal("New vaults: 1073"); 296 | }) 297 | }); 298 | 299 | it("liquidates multihop ENS vaults on Jan-20-2022 (block: 14045343)", async function () { 300 | this.timeout(1800e3); 301 | 302 | await fork(14045343); 303 | const [_owner, liquidator] = await deploy_flash_liquidator(); 304 | 305 | const liquidator_logs = await run_liquidator(tmp_root, liquidator); 306 | 307 | const vault_to_be_auctioned = "b50e0c2ce9adb248f755540b"; 308 | let vault_is_liquidated = false; 309 | for (const log_record of liquidator_logs) { 310 | if (log_record["level"] == "INFO" && log_record["fields"]["message"] == "Submitted buy order") { 311 | const vault_id = log_record["fields"]["vault_id"]; 312 | if (vault_id == `"${vault_to_be_auctioned}"`) { 313 | vault_is_liquidated = true; 314 | } 315 | } 316 | } 317 | expect(vault_is_liquidated).to.equal(true); 318 | }) 319 | }); 320 | -------------------------------------------------------------------------------- /scripts/deploy.ts: -------------------------------------------------------------------------------- 1 | import { ethers, waffle, network } from 'hardhat' 2 | 3 | import { FlashLiquidator } from '../typechain' 4 | 5 | /** 6 | * @dev This script deploys the FlashLiquidator 7 | */ 8 | ;(async () => { 9 | const recipient = '0x3b43618b2961D5fbDf269A72ACcb225Df70dCb48' 10 | const swapRouter = '0xE592427A0AEce92De3Edee1F18E0157C05861564' 11 | const weth = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' 12 | const dai = '0x6b175474e89094c44da98b954eedeac495271d0f' 13 | const witch = '0x53C3760670f6091E1eC76B4dd27f73ba4CAd5061' 14 | const steth = '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84' 15 | const wsteth = '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0' 16 | const curvestableswap = '0xDC24316b9AE028F1497c275EB9192a3Ea0f67022' 17 | const flashLoaner = '0xBA12222222228d8Ba445958a75a0704d566BF2C8' 18 | let [ownerAcc] = await ethers.getSigners() 19 | // If we are running in a mainnet fork, we give some Ether to the current account 20 | if (ownerAcc.address === '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') { 21 | await network.provider.send('hardhat_setBalance', [ 22 | ownerAcc.address, 23 | ethers.utils.parseEther('1000000').toHexString(), 24 | ]) 25 | } 26 | 27 | const args = [witch, swapRouter, flashLoaner] 28 | const flashLiquidatorFactory = await ethers.getContractFactory('FlashLiquidator', ownerAcc) 29 | const flashLiquidator = (await flashLiquidatorFactory.deploy(witch, swapRouter, flashLoaner)) as FlashLiquidator 30 | console.log(`FlashLiquidator deployed at ${flashLiquidator.address}`) 31 | console.log(`npx hardhat verify --network ${network.name} ${flashLiquidator.address} ${args.join(' ')}`) 32 | })() 33 | -------------------------------------------------------------------------------- /scripts/router.ts: -------------------------------------------------------------------------------- 1 | import { AlphaRouter } from '@uniswap/smart-order-router' 2 | 3 | import { CurrencyAmount, Token, TradeType, Percent } from '@uniswap/sdk-core' 4 | 5 | import { parse } from 'ts-command-line-args' 6 | import { Logger } from 'tslog' 7 | 8 | import { providers, BigNumber, Contract } from 'ethers' 9 | 10 | const logger: Logger = new Logger() 11 | 12 | interface Args { 13 | rpc_url: string 14 | chain_id: number 15 | v3_swap_router_address: string 16 | from_address: string 17 | token_in: string 18 | token_out: string 19 | amount_out: string 20 | duration: number 21 | slippage_pct: number 22 | silent: boolean 23 | } 24 | 25 | async function getDecimals(provider: providers.BaseProvider, address: string): Promise { 26 | const erc20_abi = [ 27 | { 28 | constant: true, 29 | inputs: [], 30 | name: 'decimals', 31 | outputs: [ 32 | { 33 | name: '', 34 | type: 'uint8', 35 | }, 36 | ], 37 | payable: false, 38 | stateMutability: 'view', 39 | type: 'function', 40 | }, 41 | ] 42 | const token = new Contract(address, erc20_abi, provider) 43 | return await token.callStatic.decimals() 44 | } 45 | 46 | async function main() { 47 | const args = parse({ 48 | rpc_url: { type: String }, 49 | chain_id: { type: Number, defaultValue: 1 }, 50 | // https://docs.uniswap.org/protocol/reference/deployments 51 | v3_swap_router_address: { type: String, defaultValue: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45' }, 52 | from_address: { type: String }, 53 | token_in: { type: String }, 54 | token_out: { type: String }, 55 | amount_out: { type: String }, 56 | duration: { type: Number, defaultValue: 300 }, 57 | slippage_pct: { type: Number, defaultValue: 3 }, 58 | silent: { type: Boolean, defaultValue: false }, 59 | }) 60 | 61 | if (args.silent) { 62 | logger.setSettings({ suppressStdOutput: true }) 63 | } 64 | 65 | const chain_id = args.chain_id == 31337 ? 1 : args.chain_id // pretend hardhat is mainnet 66 | const provider = new providers.JsonRpcProvider(args.rpc_url) 67 | const router = new AlphaRouter({ chainId: chain_id, provider }) 68 | 69 | // logger.info("token in decimals: ", ); 70 | // logger.info("token out decimals: ", await getDecimals(provider, args.token_out)); 71 | // return; 72 | 73 | const token_in = new Token(chain_id, args.token_in, await getDecimals(provider, args.token_in), '', '') 74 | 75 | const token_out = new Token(chain_id, args.token_out, await getDecimals(provider, args.token_out), '', '') 76 | 77 | const token_out_amount = CurrencyAmount.fromRawAmount(token_out, args.amount_out) 78 | 79 | logger.info('Router built; quoting...') 80 | const route = await router.route(token_out_amount, token_in, TradeType.EXACT_OUTPUT, { 81 | recipient: args.from_address, 82 | slippageTolerance: new Percent(args.slippage_pct, 100), 83 | deadline: (await provider.getBlock(await provider.getBlockNumber())).timestamp + args.duration, 84 | }) 85 | 86 | logger.info(`Quote Exact Out: ${route!.quote.toFixed(2)}`) 87 | logger.info(`Gas Adjusted Quote Out: ${route!.quoteGasAdjusted.toFixed(2)}`) 88 | logger.info(`Gas Used USD: ${route!.estimatedGasUsedUSD.toFixed(6)}`) 89 | 90 | const transaction = { 91 | data: route!.methodParameters!.calldata, 92 | to: args.v3_swap_router_address, 93 | value: BigNumber.from(route!.methodParameters!.value).toString(), 94 | from: args.from_address, 95 | gasPrice: BigNumber.from(route!.gasPriceWei).toString(), 96 | } 97 | if (args.silent) { 98 | console.log(JSON.stringify(transaction)) 99 | } else { 100 | logger.info('Swap parameters: ', JSON.stringify(transaction)) 101 | } 102 | } 103 | main() 104 | -------------------------------------------------------------------------------- /scripts/test.ts: -------------------------------------------------------------------------------- 1 | import { ethers, network } from 'hardhat' 2 | import * as hre from 'hardhat' 3 | 4 | import { WstethFlashLiquidator, Cauldron, Witch, IERC20 } from '../typechain/' 5 | 6 | /** 7 | * @dev This script tests the FlashLiquidator 8 | * @notice The vault id and FlashLiquidator address might not be valid, please check 9 | */ 10 | ;(async () => { 11 | // UPDATE THESE TWO MANUALLY: 12 | const flashLiquidatorAddress = '0xfbC22278A96299D91d41C453234d97b4F5Eb9B2d' 13 | const vaultId = '0x776160d44b7a09553c2732d3' 14 | 15 | const wethAddress = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' 16 | const cauldronAddress = '0xc88191F8cb8e6D4a668B047c1C8503432c3Ca867' 17 | const witchAddress = '0x53C3760670f6091E1eC76B4dd27f73ba4CAd5061' 18 | const timelockAddress = '0x3b870db67a45611CF4723d44487EAF398fAc51E3' 19 | const recipient = '0x3b43618b2961D5fbDf269A72ACcb225Df70dCb48' 20 | 21 | const ETH = ethers.utils.formatBytes32String('00').slice(0, 14) 22 | const WSTETH = ethers.utils.formatBytes32String('04').slice(0, 14) 23 | const STETH = ethers.utils.formatBytes32String('05').slice(0, 14) 24 | 25 | let [ownerAcc] = await ethers.getSigners() 26 | 27 | // Give some ether to the running account, since we are in a mainnet fork and would have nothing 28 | await network.provider.send('hardhat_setBalance', [ownerAcc.address, ethers.utils.parseEther('10').toHexString()]) 29 | 30 | // Give some ether to the timelock, we'll need it later 31 | await network.provider.send('hardhat_setBalance', [timelockAddress, ethers.utils.parseEther('10').toHexString()]) 32 | 33 | // Contract instantiation 34 | const WETH = ((await ethers.getContractAt('IWETH9', wethAddress, ownerAcc)) as unknown) as IERC20 35 | const cauldron = ((await ethers.getContractAt('Cauldron', cauldronAddress, ownerAcc)) as unknown) as Cauldron 36 | const witch = ((await ethers.getContractAt('Witch', witchAddress, ownerAcc)) as unknown) as Witch 37 | const flashLiquidator = ((await ethers.getContractAt( 38 | 'FlashLiquidator', 39 | flashLiquidatorAddress, 40 | ownerAcc 41 | )) as unknown) as WstethFlashLiquidator 42 | 43 | // At the time of writing, this vault is collateralized at 268%. Find more at https://yield-protocol-info.netlify.app/#/vaults 44 | console.log(`Vault to liquidate: ${vaultId}`) 45 | 46 | // Check collateralToDebtRatio, just to make sure it doesn't revert 47 | console.log(`Collateral to debt ratio: ${await flashLiquidator.callStatic.collateralToDebtRatio(vaultId)}`) 48 | 49 | // Raise the required collateralization to 300% 50 | await hre.network.provider.request({ 51 | method: 'hardhat_impersonateAccount', 52 | params: [timelockAddress], 53 | }) 54 | const timelockAcc = await ethers.getSigner(timelockAddress) 55 | const oracleAddress = (await cauldron.spotOracles(ETH, WSTETH)).oracle 56 | console.log(`Raising required collateralization to 3000%`) 57 | await cauldron.connect(timelockAcc).setSpotOracle(ETH, WSTETH, oracleAddress, 30000000) 58 | 59 | // Liquidate the vault 60 | console.log(`Auctioning ${vaultId}`) 61 | await witch.auction(vaultId) 62 | 63 | // Wait to get enough collateral to pay the flash loan plus the fees 64 | const { timestamp } = await ethers.provider.getBlock('latest') 65 | await ethers.provider.send('evm_mine', [timestamp + 3600]) 66 | 67 | console.log(`Liquidating ${vaultId}`) 68 | // await flashLiquidator.liquidate(vaultId) 69 | 70 | console.log(`Profit: ${await WETH.balanceOf(recipient)}`) 71 | })() 72 | -------------------------------------------------------------------------------- /src/bin/liquidator.rs: -------------------------------------------------------------------------------- 1 | use ethers::prelude::*; 2 | use yield_liquidator::{escalator::GeometricGasPrice, keeper::Keeper, bindings::BaseIdType, swap_router::SwapRouter}; 3 | 4 | use gumdrop::Options; 5 | use serde::Deserialize; 6 | use std::{convert::{TryFrom, TryInto}, path::PathBuf, sync::Arc, time::Duration, collections::HashMap}; 7 | use tracing::info; 8 | use tracing_subscriber::{filter::EnvFilter, fmt::Subscriber}; 9 | 10 | // CLI Options 11 | #[derive(Debug, Options, Clone)] 12 | struct Opts { 13 | help: bool, 14 | 15 | #[options(help = "path to json file with the contract addresses")] 16 | config: PathBuf, 17 | 18 | #[options( 19 | help = "the Ethereum node endpoint (HTTP or WS)", 20 | default = "http://localhost:8545" 21 | )] 22 | url: String, 23 | 24 | #[options(help = "chain id", default = "1")] 25 | chain_id: u64, 26 | 27 | #[options(help = "path to your private key")] 28 | private_key: PathBuf, 29 | 30 | #[options(help = "polling interval (ms)", default = "1000")] 31 | interval: u64, 32 | 33 | #[options(help = "Multicall batch size", default = "500")] 34 | multicall_batch_size: usize, 35 | 36 | #[options(help = "the file to be used for persistence", default = "data.json")] 37 | file: PathBuf, 38 | 39 | #[options(help = "the minimum ratio (collateral/debt) to trigger liquidation, percents", default = "110")] 40 | min_ratio: u16, 41 | 42 | #[options(help = "extra gas to use for transactions, percent of estimated gas", default = "10")] 43 | gas_boost: u16, 44 | 45 | #[options(help = "Don't bump gas until the transaction is this many seconds old", default = "90")] 46 | bump_gas_delay: u64, 47 | 48 | #[options(help = "Buy an auction as soon as this much collateral percentage is offered", default = "90")] 49 | target_collateral_offer: u16, 50 | 51 | #[options(help = "the block to start watching from")] 52 | start_block: Option, 53 | 54 | #[options(default="false", help="Use JSON as log format")] 55 | json_log: bool, 56 | 57 | #[options(default="false", help="Only run 1 iteration and exit")] 58 | one_shot: bool, 59 | 60 | #[options(help = "Path to the swap router binary")] 61 | swap_router_binary: String, 62 | 63 | #[options( 64 | help = "Instance name (used for logging)", 65 | default = "undefined" 66 | )] 67 | instance_name: String, 68 | 69 | } 70 | 71 | #[derive(Deserialize)] 72 | struct Config { 73 | #[serde(rename = "Witch")] 74 | witch: Address, 75 | #[serde(rename = "Flash")] 76 | flashloan: Address, 77 | #[serde(rename = "Multicall2")] 78 | multicall2: Address, 79 | #[serde(rename = "SwapRouter02")] 80 | swap_router_02: Address, 81 | #[serde(rename = "BaseToDebtThreshold")] 82 | base_to_debt_threshold: HashMap 83 | } 84 | 85 | fn init_logger(use_json: bool) { 86 | let sub_builder = 87 | Subscriber::builder() 88 | .with_env_filter(EnvFilter::from_default_env()); 89 | 90 | if use_json { 91 | sub_builder 92 | .json() 93 | .with_current_span(true) 94 | .with_span_list(true) 95 | .init(); 96 | } else { 97 | sub_builder 98 | .init(); 99 | } 100 | } 101 | 102 | async fn main_impl() -> anyhow::Result<()> { 103 | let opts = Opts::parse_args_default_or_exit(); 104 | 105 | init_logger(opts.json_log); 106 | 107 | 108 | if opts.url.starts_with("http") { 109 | let provider = Provider::::try_from(opts.url.clone())?; 110 | run(opts, provider).await?; 111 | } else { 112 | let ws = Ws::connect(opts.url.clone()).await?; 113 | let provider = Provider::new(ws); 114 | run(opts, provider).await?; 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | #[tokio::main] 121 | async fn main() { 122 | match main_impl().await { 123 | Ok(_) => { 124 | std::process::exit(exitcode::OK); 125 | } 126 | Err(e) => { 127 | eprintln!("Error: {}", e); 128 | std::process::exit(exitcode::DATAERR); 129 | } 130 | }; 131 | } 132 | 133 | async fn run(opts: Opts, provider: Provider

) -> anyhow::Result<()> { 134 | info!("Starting Yield-v2 Liquidator."); 135 | let provider = provider.interval(Duration::from_millis(opts.interval)); 136 | let private_key = std::fs::read_to_string(opts.private_key)?.trim().to_string(); 137 | let wallet: LocalWallet = private_key.parse()?; 138 | let wallet = wallet.with_chain_id(opts.chain_id); 139 | let address = wallet.address(); 140 | let client = SignerMiddleware::new(provider, wallet); 141 | let client = NonceManagerMiddleware::new(client, address); 142 | let client = Arc::new(client); 143 | info!("Profits will be sent to {:?}", address); 144 | 145 | info!(instance_name=opts.instance_name.as_str(), "Node: {}", opts.url); 146 | 147 | let cfg: Config = serde_json::from_reader(std::fs::File::open(opts.config)?)?; 148 | info!("Witch: {:?}", cfg.witch); 149 | info!("Multicall2: {:?}", cfg.multicall2); 150 | info!("FlashLiquidator {:?}", cfg.flashloan); 151 | info!("Persistent data will be stored at: {:?}", opts.file); 152 | 153 | let file = std::fs::OpenOptions::new() 154 | .read(true) 155 | .write(true) 156 | .create(true) 157 | .open(&opts.file) 158 | .unwrap(); 159 | let state = serde_json::from_reader(&file).unwrap_or_default(); 160 | 161 | let mut gas_escalator = GeometricGasPrice::new(); 162 | gas_escalator.coefficient = 1.12501; 163 | gas_escalator.every_secs = 5; // TODO: Make this be 90s 164 | gas_escalator.max_price = Some(U256::from(5000 * 1e9 as u64)); // 5k gwei 165 | 166 | let base_to_debt_threshold: HashMap = cfg.base_to_debt_threshold.iter() 167 | .map(|(k, v)| { 168 | (hex::decode(k).unwrap().try_into().unwrap(), v.parse::().unwrap()) 169 | }) 170 | .collect(); 171 | 172 | let instance_name = format!("{}.witch={:?}.flash={:?}", opts.instance_name, cfg.witch, cfg.flashloan); 173 | 174 | let swap_router = SwapRouter::new( 175 | opts.url, 176 | opts.chain_id, 177 | cfg.swap_router_02, 178 | cfg.flashloan, 179 | opts.swap_router_binary, 180 | instance_name.clone() 181 | ); 182 | 183 | let mut keeper = Keeper::new( 184 | client, 185 | cfg.witch, 186 | cfg.flashloan, 187 | cfg.multicall2, 188 | opts.multicall_batch_size, 189 | opts.min_ratio, 190 | opts.gas_boost, 191 | gas_escalator, 192 | opts.bump_gas_delay, 193 | opts.target_collateral_offer, 194 | base_to_debt_threshold, 195 | state, 196 | swap_router, 197 | instance_name 198 | ).await?; 199 | 200 | if opts.one_shot { 201 | keeper.one_shot().await?; 202 | info!("One shot done"); 203 | } else { 204 | keeper.run(opts.file, opts.start_block).await?; 205 | } 206 | 207 | Ok(()) 208 | } 209 | -------------------------------------------------------------------------------- /src/bindings/.gitignore: -------------------------------------------------------------------------------- 1 | *.rs 2 | 3 | -------------------------------------------------------------------------------- /src/bindings/mod.rs: -------------------------------------------------------------------------------- 1 | mod cauldron; 2 | pub use cauldron::*; 3 | 4 | mod witch; 5 | pub use witch::*; 6 | 7 | mod flashliquidator; 8 | pub use flashliquidator::*; 9 | 10 | mod imulticall2; 11 | pub use imulticall2::*; 12 | pub use imulticall2::ResultData as IMulticall2Result; 13 | pub use imulticall2::CallData as IMulticall2Call; 14 | 15 | pub type AssetIdType = [u8; 6]; 16 | pub type VaultIdType = [u8; 12]; 17 | pub type BaseIdType = [u8; 6]; 18 | pub type IlkIdType = [u8; 6]; 19 | pub type SeriesIdType = [u8; 6]; -------------------------------------------------------------------------------- /src/borrowers.rs: -------------------------------------------------------------------------------- 1 | //! Borrowers / Users 2 | //! 3 | //! This module is responsible for keeping track of the users that have open 4 | //! positions and observing their debt healthiness. 5 | use crate::{ 6 | bindings::Cauldron, bindings::IMulticall2, bindings::IMulticall2Call, bindings::IlkIdType, 7 | bindings::SeriesIdType, bindings::VaultIdType, bindings::Witch, Result, cache::ImmutableCache, 8 | }; 9 | 10 | use ethers::prelude::*; 11 | use futures_util::stream::{self, StreamExt}; 12 | use serde::{Deserialize, Serialize}; 13 | use std::{collections::HashMap, sync::Arc}; 14 | use tracing::{debug, debug_span, info, instrument, trace, warn}; 15 | 16 | pub type VaultMap = HashMap; 17 | 18 | #[derive(Clone)] 19 | pub struct Borrowers { 20 | /// The cauldron smart contract 21 | pub cauldron: Cauldron, 22 | pub liquidator: Witch, 23 | 24 | /// Mapping of the addresses that have taken loans from the system and might 25 | /// be susceptible to liquidations 26 | pub vaults: VaultMap, 27 | 28 | /// We use multicall to batch together calls and have reduced stress on 29 | /// our RPC endpoint 30 | multicall2: IMulticall2, 31 | multicall_batch_size: usize, 32 | 33 | instance_name: String, 34 | } 35 | 36 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] 37 | /// A vault's details 38 | pub struct Vault { 39 | pub vault_id: VaultIdType, 40 | 41 | pub is_initialized: bool, 42 | 43 | pub is_collateralized: bool, 44 | 45 | pub under_auction: bool, 46 | 47 | pub level: I256, 48 | 49 | pub debt: u128, 50 | 51 | pub ilk_id: IlkIdType, 52 | 53 | pub series_id: SeriesIdType, 54 | } 55 | 56 | impl Borrowers { 57 | /// Constructor 58 | pub async fn new( 59 | cauldron: Address, 60 | liquidator: Address, 61 | multicall2: Address, 62 | multicall_batch_size: usize, 63 | client: Arc, 64 | vaults: HashMap, 65 | instance_name: String, 66 | ) -> Self { 67 | let multicall2 = IMulticall2::new(multicall2, client.clone()); 68 | Borrowers { 69 | cauldron: Cauldron::new(cauldron, client.clone()), 70 | liquidator: Witch::new(liquidator, client), 71 | vaults, 72 | multicall2, 73 | multicall_batch_size, 74 | instance_name, 75 | } 76 | } 77 | 78 | /// Gets any new borrowers which may have joined the system since we last 79 | /// made this call and then proceeds to get the latest account details for 80 | /// each user 81 | #[instrument(skip(self, cache), fields(self.instance_name))] 82 | pub async fn update_vaults(&mut self, from_block: U64, to_block: U64, cache: &mut ImmutableCache) -> Result<(), M> { 83 | let span = debug_span!("monitoring"); 84 | let _enter = span.enter(); 85 | 86 | // get the new vaults 87 | // TODO: Improve this logic to be more optimized 88 | let new_vaults = self 89 | .cauldron 90 | .vault_poured_filter() 91 | .from_block(from_block) 92 | .to_block(to_block) 93 | .query() 94 | .await? 95 | .into_iter() 96 | .map(|x| x.vault_id) 97 | .collect::>(); 98 | 99 | if new_vaults.len() > 0 { 100 | debug!("New vaults: {}", new_vaults.len()); 101 | } else { 102 | trace!("New vaults: {}", new_vaults.len()); 103 | } 104 | 105 | let all_vaults = crate::merge(new_vaults, &self.vaults); 106 | info!( 107 | count = all_vaults.len(), 108 | instance_name = self.instance_name.as_str(), 109 | "Vaults collected" 110 | ); 111 | 112 | self.get_vault_info(&all_vaults, cache) 113 | .await 114 | .iter() 115 | .zip(all_vaults) 116 | .for_each(|(vault_info, vault_id)| match vault_info { 117 | Ok(details) => { 118 | if self.vaults.insert(vault_id, details.clone()).is_none() { 119 | debug!(new_vault = ?hex::encode(vault_id), details=?details); 120 | } 121 | } 122 | Err(x) => { 123 | warn!(vault_id=?vault_id, err=?x, "Failed to get vault details"); 124 | self.vaults.insert( 125 | vault_id, 126 | Vault { 127 | vault_id: vault_id, 128 | is_initialized: false, 129 | is_collateralized: false, 130 | debt: 0, 131 | level: I256::zero(), 132 | under_auction: false, 133 | series_id: [0, 0, 0, 0, 0, 0], 134 | ilk_id: [0, 0, 0, 0, 0, 0], 135 | }, 136 | ); 137 | } 138 | }); 139 | Ok(()) 140 | } 141 | 142 | /// Fetches vault info for a set of vaults 143 | /// 144 | /// It relies on Multicall2 and does 2 levels of batching: 145 | /// 1. For each vault_id, there are 3 calls made 146 | /// 2. Calls for different vaults are batched together (self.multicall_batch_size is the batch size) 147 | /// If multicall_batch_size == 10 and we query 42 vaults, we will issue 5 separate Multicall2 calls 148 | /// First 4 multicalls will have multicall_batch_size * 3 == 40 internal calls 149 | /// Last multicall will have 2 * 3 = 6 internal calls 150 | /// 151 | #[instrument(skip(self, vault_ids, cache), fields(self.instance_name))] 152 | pub async fn get_vault_info(&mut self, vault_ids: &[VaultIdType], cache: &mut ImmutableCache) -> Vec> { 153 | let mut ret: Vec<_> = stream::iter(vault_ids) 154 | // split to chunks 155 | .chunks(self.multicall_batch_size) 156 | // technicality: 'materialize' slices, so that the next step can be async 157 | .map(|x| x.iter().map(|a| **a).collect()) 158 | // for each chunk, make a multicall2 call 159 | .then(|ids_chunk: Vec| async { 160 | let calls = self.get_vault_info_chunk_generate_multicall_args(&ids_chunk); 161 | let chunk_response = self.multicall2.try_aggregate(false, calls).call().await; 162 | 163 | match self.get_vault_info_chunk_parse_response(ids_chunk, chunk_response) { 164 | Ok(response) => response, 165 | Err(x) => { 166 | // if the multicall itself failed, we panic and crash 167 | // This is most likely to happen if the multicall runs out of gas (batch is too big) 168 | // We can't ignore this error and can't fallback to fetching vaults one-by-one 169 | // because it will be easy to miss the we start using the fallbacks 170 | // So, we take the safe route of letting the operator adjust the batch size 171 | panic!("multicall2 failed: {:?}", x) 172 | } 173 | } 174 | }) 175 | // glue back chunk responses 176 | .flat_map(|x| stream::iter(x)) 177 | .collect() 178 | .await; 179 | 180 | // hack: for vaults that appear undercollaterized do another round of checks 181 | // if base == ilk, vaults are not liquidatable => we mark them as overcollaterized 182 | // 183 | for single_vault_maybe in &mut ret { 184 | if let Ok(single_vault) = single_vault_maybe { 185 | if !single_vault.is_collateralized { 186 | info!(vault_id=?hex::encode(single_vault.vault_id), "Potentially undercollaterized vault - checking if it's trivial"); 187 | match cache.is_vault_ignored(single_vault.series_id, single_vault.ilk_id, single_vault.debt) 188 | .await 189 | { 190 | Ok(true) => { 191 | info!(vault_id=?hex::encode(single_vault.vault_id), "should be ignored - marking as NOT undercollaterized"); 192 | single_vault.is_collateralized = true; 193 | } 194 | Ok(false) => { 195 | info!(vault_id=?hex::encode(single_vault.vault_id), "Is not ignorable"); 196 | } 197 | Err(x) => { 198 | warn!(vault_id=?hex::encode(single_vault.vault_id), "Failed to check if it's ignorable"); 199 | *single_vault_maybe = Err(x); 200 | } 201 | } 202 | } 203 | } 204 | } 205 | 206 | assert!(vault_ids.len() == ret.len()); 207 | return ret; 208 | } 209 | 210 | /// Given a set of vaultIds, generate a Multicall2 call to get vault info 211 | fn get_vault_info_chunk_generate_multicall_args( 212 | &self, 213 | ids_chunk: &Vec, 214 | ) -> Vec { 215 | return ids_chunk 216 | .iter() 217 | .flat_map(|vault_id| { 218 | trace!(vault_id=?vault_id, "Getting vault info"); 219 | let level_fn = self.cauldron.level(*vault_id); 220 | let balances_fn = self.cauldron.balances(*vault_id); 221 | let vault_data_fn = self.cauldron.vaults(*vault_id); 222 | let auction_id_fn = self.liquidator.auctions(*vault_id); 223 | 224 | return [ 225 | IMulticall2Call { 226 | target: self.cauldron.address(), 227 | call_data: level_fn.calldata().unwrap().to_vec(), 228 | }, 229 | IMulticall2Call { 230 | target: self.cauldron.address(), 231 | call_data: balances_fn.calldata().unwrap().to_vec(), 232 | }, 233 | IMulticall2Call { 234 | target: self.cauldron.address(), 235 | call_data: vault_data_fn.calldata().unwrap().to_vec(), 236 | }, 237 | IMulticall2Call { 238 | target: self.liquidator.address(), 239 | call_data: auction_id_fn.calldata().unwrap().to_vec(), 240 | }, 241 | ]; 242 | }) 243 | .collect(); 244 | } 245 | 246 | /// Counterpart of get_vault_info_chunk_generate_multicall_args: given multicall response, 247 | /// convert it to a set of vaults 248 | fn get_vault_info_chunk_parse_response( 249 | &self, 250 | ids_chunk: Vec, // TODO borrow 251 | maybe_response: Result)>, M>, // TODO borrow 252 | ) -> Result>, M> { 253 | return maybe_response.map(|response| { 254 | assert!( 255 | response.len() == ids_chunk.len() * 4, 256 | "Unexpected results len: {}; expected: {}", 257 | response.len(), 258 | ids_chunk.len() * 4 259 | ); 260 | let x: Vec> = response 261 | .chunks(4) 262 | .zip(ids_chunk) 263 | .map(|(single_vault_data, vault_id)| { 264 | return self.get_vault_info_generate_vault(single_vault_data, &vault_id); 265 | }) 266 | .collect(); 267 | return x; 268 | }); 269 | } 270 | 271 | /// Given individual responses from Multicall2, construct vault data 272 | fn get_vault_info_generate_vault( 273 | &self, 274 | single_vault_data: &[(bool, Vec)], 275 | vault_id: &VaultIdType, 276 | ) -> Result { 277 | assert!(single_vault_data.len() == 4); 278 | let (level_data_ok, level_data) = &single_vault_data[0]; 279 | let (balances_data_ok, balances_data) = &single_vault_data[1]; 280 | let (vault_data_ok, vault_data) = &single_vault_data[2]; 281 | let (auction_id_data_ok, auction_id_data) = &single_vault_data[3]; 282 | if !level_data_ok || !balances_data_ok || !vault_data_ok || !auction_id_data_ok { 283 | warn!(vault_id=?hex::encode(vault_id), vault_data=?single_vault_data, "Failed to get vault data"); 284 | return Err(ContractError::ConstructorError {}); 285 | } 286 | use ethers::abi::Detokenize; 287 | let level_int = I256::from_tokens( 288 | self.cauldron 289 | .level(*vault_id) 290 | .function 291 | .decode_output(&level_data) 292 | .unwrap(), 293 | )?; 294 | let balances = <(u128, u128) as Detokenize>::from_tokens( 295 | self.cauldron 296 | .balances(*vault_id) 297 | .function 298 | .decode_output(&balances_data) 299 | .unwrap(), 300 | )?; 301 | let vault_data = <(Address, SeriesIdType, IlkIdType) as Detokenize>::from_tokens( 302 | self.cauldron 303 | .vaults(*vault_id) 304 | .function 305 | .decode_output(&vault_data) 306 | .unwrap(), 307 | )?; 308 | let auction_id = <(Address, u32) as Detokenize>::from_tokens( 309 | self.liquidator 310 | .auctions(*vault_id) 311 | .function 312 | .decode_output(&auction_id_data) 313 | .unwrap(), 314 | )?; 315 | 316 | let is_collateralized: bool = !level_int.is_negative(); 317 | trace!(vault_id=?hex::encode(vault_id), "Got vault info"); 318 | return Ok(Vault { 319 | vault_id: *vault_id, 320 | is_initialized: true, 321 | is_collateralized: is_collateralized, 322 | level: level_int, 323 | debt: balances.0, 324 | under_auction: auction_id.0 != Address::zero(), 325 | series_id: vault_data.1, 326 | ilk_id: vault_data.2, 327 | }); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | //! Immutable data cache 2 | //! 3 | use crate::{ 4 | bindings::{Cauldron}, bindings::{BaseIdType, IlkIdType, AssetIdType}, 5 | bindings::SeriesIdType, Result, 6 | }; 7 | 8 | use ethers::prelude::*; 9 | use std::{collections::HashMap, sync::Arc}; 10 | use tracing::{debug, instrument, warn}; 11 | 12 | 13 | #[derive(Clone)] 14 | pub struct ImmutableCache { 15 | /// The cauldron smart contract 16 | pub cauldron: Cauldron, 17 | 18 | pub series_to_base: HashMap, 19 | 20 | pub asset_id_to_address: HashMap, 21 | 22 | pub base_to_debt_threshold: HashMap, 23 | 24 | instance_name: String, 25 | } 26 | 27 | impl ImmutableCache { 28 | /// Constructor 29 | pub async fn new( 30 | client: Arc, 31 | cauldron: Address, 32 | series_to_base: HashMap, 33 | base_to_debt_threshold: HashMap, 34 | instance_name: String, 35 | ) -> Self { 36 | ImmutableCache { 37 | cauldron: Cauldron::new(cauldron, client.clone()), 38 | series_to_base, 39 | asset_id_to_address: HashMap::new(), 40 | base_to_debt_threshold, 41 | instance_name 42 | } 43 | } 44 | 45 | #[instrument(skip(self), fields(self.instance_name))] 46 | pub async fn get_or_fetch_base_id(&mut self, series_id: SeriesIdType) -> Result { 47 | 48 | if !self.series_to_base.contains_key(&series_id) { 49 | debug!(series_id=?hex::encode(series_id), "fetching series"); 50 | self.series_to_base.insert(series_id, self.cauldron.series(series_id).call().await?.1); 51 | } 52 | match self.series_to_base.get(&series_id) { 53 | Some(x) => Ok(*x), 54 | None => panic!("can't find data for series {:}", hex::encode(series_id)) 55 | } 56 | } 57 | 58 | pub async fn get_or_fetch_asset_address(&mut self, asset_id: AssetIdType) -> Result { 59 | 60 | if !self.asset_id_to_address.contains_key(&asset_id) { 61 | debug!(asset_id=?hex::encode(asset_id), "fetching asset"); 62 | self.asset_id_to_address.insert(asset_id, self.cauldron.assets(asset_id).call().await?); 63 | } 64 | match self.asset_id_to_address.get(&asset_id) { 65 | Some(x) => Ok(*x), 66 | None => panic!("can't find data for asset {:}", hex::encode(asset_id)) 67 | } 68 | } 69 | 70 | 71 | #[instrument(skip(self), fields(self.instance_name))] 72 | pub async fn is_vault_ignored(&mut self, series_id: SeriesIdType, ilk_id: IlkIdType, debt: u128) -> Result { 73 | let base_id = match self.get_or_fetch_base_id(series_id).await { 74 | Ok(x) => x, 75 | Err(x) => return Err(x) 76 | }; 77 | if base_id == ilk_id { 78 | debug!("vault is trivial"); 79 | return Ok(true); 80 | } 81 | match self.base_to_debt_threshold.get(&base_id) { 82 | Some(threshold) => Ok(debt < *threshold), 83 | None => { 84 | warn!(series_id=?hex::encode(series_id), base_id=?hex::encode(base_id), "missing debt threshold"); 85 | return Ok(false) 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers' 2 | 3 | export const CHI = ethers.utils.formatBytes32String('CHI').slice(0, 14) 4 | export const RATE = ethers.utils.formatBytes32String('RATE').slice(0, 14) 5 | export const DAI = ethers.utils.formatBytes32String('01').slice(0, 14) 6 | export const USDC = ethers.utils.formatBytes32String('02').slice(0, 14) 7 | export const ETH = ethers.utils.formatBytes32String('00').slice(0, 14) 8 | -------------------------------------------------------------------------------- /src/escalator.rs: -------------------------------------------------------------------------------- 1 | use ethers::types::U256; 2 | 3 | /// Geometrically increasing gas price. 4 | /// 5 | /// Start with `initial_price`, then increase it every 'every_secs' seconds by a fixed coefficient. 6 | /// Coefficient defaults to 1.125 (12.5%), the minimum increase for Parity to replace a transaction. 7 | /// Coefficient can be adjusted, and there is an optional upper limit. 8 | /// https://github.com/makerdao/pymaker/blob/master/pymaker/gas.py#L168 9 | #[derive(Clone, Debug)] 10 | pub struct GeometricGasPrice { 11 | pub every_secs: u64, 12 | pub coefficient: f64, 13 | pub max_price: Option, 14 | } 15 | 16 | impl GeometricGasPrice { 17 | pub fn new() -> Self { 18 | GeometricGasPrice { 19 | every_secs: 30, 20 | coefficient: 1.125, 21 | max_price: None, 22 | } 23 | } 24 | 25 | pub fn get_gas_price(&self, initial_price: U256, time_elapsed: u64) -> U256 { 26 | let mut result = initial_price.as_u64() as f64; 27 | 28 | if time_elapsed >= self.every_secs { 29 | let iters = time_elapsed / self.every_secs; 30 | for _ in 0..iters { 31 | result *= self.coefficient; 32 | } 33 | } 34 | 35 | let mut result = U256::from(result.ceil() as u64); 36 | if let Some(max_price) = self.max_price { 37 | result = std::cmp::min(result, max_price); 38 | } 39 | result 40 | } 41 | } 42 | 43 | #[cfg(test)] 44 | // https://github.com/makerdao/pymaker/blob/master/tests/test_gas.py#L165 45 | mod tests { 46 | use super::*; 47 | 48 | #[test] 49 | fn gas_price_increases_with_time() { 50 | let mut oracle = GeometricGasPrice::new(); 51 | oracle.every_secs = 10; 52 | let initial_price = U256::from(100); 53 | 54 | assert_eq!(oracle.get_gas_price(initial_price, 0), 100.into()); 55 | assert_eq!(oracle.get_gas_price(initial_price, 1), 100.into()); 56 | assert_eq!(oracle.get_gas_price(initial_price, 10), 113.into()); 57 | assert_eq!(oracle.get_gas_price(initial_price, 15), 113.into()); 58 | assert_eq!(oracle.get_gas_price(initial_price, 20), 127.into()); 59 | assert_eq!(oracle.get_gas_price(initial_price, 30), 143.into()); 60 | assert_eq!(oracle.get_gas_price(initial_price, 50), 181.into()); 61 | assert_eq!(oracle.get_gas_price(initial_price, 100), 325.into()); 62 | } 63 | 64 | #[test] 65 | fn gas_price_should_obey_max_value() { 66 | let mut oracle = GeometricGasPrice::new(); 67 | oracle.every_secs = 60; 68 | oracle.max_price = Some(2500.into()); 69 | let initial_price = U256::from(1000); 70 | 71 | assert_eq!(oracle.get_gas_price(initial_price, 0), 1000.into()); 72 | assert_eq!(oracle.get_gas_price(initial_price, 1), 1000.into()); 73 | assert_eq!(oracle.get_gas_price(initial_price, 59), 1000.into()); 74 | assert_eq!(oracle.get_gas_price(initial_price, 60), 1125.into()); 75 | assert_eq!(oracle.get_gas_price(initial_price, 119), 1125.into()); 76 | assert_eq!(oracle.get_gas_price(initial_price, 120), 1266.into()); 77 | assert_eq!(oracle.get_gas_price(initial_price, 1200), 2500.into()); 78 | assert_eq!(oracle.get_gas_price(initial_price, 3000), 2500.into()); 79 | assert_eq!(oracle.get_gas_price(initial_price, 1000000), 2500.into()); 80 | } 81 | 82 | #[test] 83 | fn behaves_with_realistic_values() { 84 | let mut oracle = GeometricGasPrice::new(); 85 | oracle.every_secs = 10; 86 | oracle.coefficient = 1.25; 87 | const GWEI: f64 = 1000000000.0; 88 | let initial_price = U256::from(100 * GWEI as u64); 89 | 90 | for seconds in &[0u64, 1, 10, 12, 30, 60] { 91 | println!( 92 | "gas price after {} seconds is {}", 93 | seconds, 94 | oracle.get_gas_price(initial_price, *seconds).as_u64() as f64 / GWEI 95 | ); 96 | } 97 | 98 | let normalized = |time| oracle.get_gas_price(initial_price, time).as_u64() as f64 / GWEI; 99 | 100 | assert_eq!(normalized(0), 100.0); 101 | assert_eq!(normalized(1), 100.0); 102 | assert_eq!(normalized(10), 125.0); 103 | assert_eq!(normalized(12), 125.0); 104 | assert_eq!(normalized(30), 195.3125); 105 | assert_eq!(normalized(60), 381.469726563); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Cauldron } from '../typechain/Cauldron' 2 | 3 | export async function getLastVaultId(cauldron: Cauldron): Promise { 4 | const logs = await cauldron.queryFilter(cauldron.filters.VaultBuilt(null, null, null, null)) 5 | const event = logs[logs.length - 1] 6 | return event.args.vaultId 7 | } -------------------------------------------------------------------------------- /src/keeper.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | bindings::{Witch, BaseIdType}, 3 | borrowers::{Borrowers, VaultMap}, 4 | cache::ImmutableCache, 5 | escalator::GeometricGasPrice, 6 | liquidations::{AuctionMap, Liquidator}, 7 | Result, swap_router::SwapRouter, 8 | }; 9 | 10 | use ethers::prelude::*; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_with::serde_as; 13 | use std::{ 14 | collections::HashMap, io::Write, path::PathBuf, sync::Arc, time::SystemTime, time::UNIX_EPOCH, 15 | }; 16 | use tokio::time::{sleep, Duration}; 17 | use tracing::{debug_span, info, instrument, trace}; 18 | 19 | #[serde_as] 20 | #[derive(Serialize, Deserialize, Default)] 21 | /// The state which is stored in our logs 22 | pub struct State { 23 | /// The auctions being monitored 24 | #[serde_as(as = "Vec<(_, _)>")] 25 | auctions: AuctionMap, 26 | /// The borrowers being monitored 27 | #[serde_as(as = "Vec<(_, _)>")] 28 | vaults: VaultMap, 29 | /// The last observed block 30 | last_block: u64, 31 | } 32 | 33 | /// The keeper monitors the chain for both liquidation opportunities and for 34 | /// participation in auctions using Uniswap as a liquidity source 35 | pub struct Keeper { 36 | client: Arc, 37 | last_block: U64, 38 | 39 | cache: ImmutableCache, 40 | borrowers: Borrowers, 41 | liquidator: Liquidator, 42 | instance_name: String, 43 | } 44 | 45 | impl Keeper { 46 | /// Instantiates the keeper. `state` should be passed if there is previous 47 | /// data which should be taken into account from a previous run 48 | pub async fn new( 49 | client: Arc, 50 | liquidations: Address, 51 | flashloan: Address, 52 | multicall2: Address, 53 | multicall_batch_size: usize, 54 | min_ratio: u16, 55 | gas_boost: u16, 56 | gas_escalator: GeometricGasPrice, 57 | bump_gas_delay: u64, 58 | target_collateral_offer: u16, 59 | base_to_debt_threshold: HashMap, 60 | state: Option, 61 | swap_router: SwapRouter, 62 | instance_name: String, 63 | ) -> Result, M> { 64 | let (vaults, auctions, last_block) = match state { 65 | Some(state) => (state.vaults, state.auctions, state.last_block.into()), 66 | None => (HashMap::new(), HashMap::new(), 0.into()), 67 | }; 68 | let witch = Witch::new(liquidations, client.clone()); 69 | let controller = witch.cauldron().call().await?; 70 | let borrowers = Borrowers::new( 71 | controller, 72 | liquidations, 73 | multicall2, 74 | multicall_batch_size, 75 | client.clone(), 76 | vaults, 77 | instance_name.clone(), 78 | ) 79 | .await; 80 | let liquidator = Liquidator::new( 81 | swap_router, 82 | controller, 83 | liquidations, 84 | flashloan, 85 | Some(multicall2), 86 | min_ratio, 87 | gas_boost, 88 | target_collateral_offer, 89 | client.clone(), 90 | auctions, 91 | gas_escalator, 92 | bump_gas_delay, 93 | instance_name.clone(), 94 | ) 95 | .await; 96 | 97 | let cache = ImmutableCache::new( 98 | client.clone(), 99 | controller, 100 | HashMap::new(), 101 | base_to_debt_threshold, 102 | instance_name.clone()) 103 | .await; 104 | 105 | Ok(Self { 106 | client, 107 | cache, 108 | borrowers, 109 | liquidator, 110 | last_block, 111 | instance_name: instance_name.clone(), 112 | }) 113 | } 114 | 115 | pub async fn run(&mut self, fname: PathBuf, start_block: Option) -> Result<(), M> { 116 | // Create the initial list of borrowers from the start_block, if provided 117 | if let Some(start_block) = start_block { 118 | self.last_block = start_block.into(); 119 | } 120 | 121 | let watcher = self.client.clone(); 122 | let mut filter_id = watcher 123 | .new_filter(FilterKind::NewBlocks) 124 | .await 125 | .map_err(ContractError::MiddlewareError)?; 126 | 127 | let mut err_count = 0; 128 | let mut file: Option = None; 129 | 130 | let mut maybe_last_block_number: Option = None; 131 | 132 | let span = debug_span!("run", instance_name = self.instance_name.as_str()); 133 | let _enter = span.enter(); 134 | loop { 135 | sleep(Duration::from_secs(30)).await; // don't spin 136 | match watcher 137 | .get_filter_changes::<_, ethers_core::types::H256>(filter_id) 138 | .await 139 | { 140 | Ok(_results) => { 141 | err_count = 0; 142 | let block_number = self 143 | .client 144 | .get_block_number() 145 | .await 146 | .map_err(ContractError::MiddlewareError)?; 147 | 148 | if let Some(last_block_number) = maybe_last_block_number { 149 | if last_block_number == block_number.as_u64() { 150 | trace!(last_block_number, "skipping previously seen block"); 151 | continue; 152 | } 153 | } 154 | 155 | maybe_last_block_number = Some(block_number.as_u64()); 156 | match self 157 | .client 158 | .get_block(block_number) 159 | .await 160 | .map_err(ContractError::MiddlewareError)? 161 | { 162 | Some(block) => { 163 | let block_timestamp = block.timestamp.as_u64() as i64; 164 | match SystemTime::now().duration_since(UNIX_EPOCH) { 165 | Ok(current_time) => { 166 | info!( 167 | block_number = block_number.as_u64(), 168 | timestamp = block_timestamp, 169 | delay_seconds = 170 | current_time.as_secs() as i64 - block_timestamp, 171 | instance_name = self.instance_name.as_str(), 172 | "New block" 173 | ); 174 | } 175 | Err(_) => { 176 | info!( 177 | block_number = block_number.as_u64(), 178 | timestamp = block_timestamp, 179 | instance_name = self.instance_name.as_str(), 180 | "New block" 181 | ); 182 | } 183 | } 184 | } 185 | None => { 186 | info!( 187 | block_number = block_number.as_u64(), 188 | instance_name = self.instance_name.as_str(), 189 | "New block" 190 | ); 191 | } 192 | } 193 | 194 | if block_number % 10 == 0.into() { 195 | // on each new block we open a new file handler to dump our state. 196 | // we should just have a database connection instead here... 197 | file = Some( 198 | std::fs::OpenOptions::new() 199 | .read(true) 200 | .write(true) 201 | .create(true) 202 | .open(&fname) 203 | .unwrap(), 204 | ); 205 | } 206 | 207 | // run the logic for this block 208 | self.on_block(block_number).await?; 209 | 210 | // update our last block 211 | self.last_block = block_number; 212 | 213 | // Log once every 10 blocks 214 | if let Some(file) = file.take() { 215 | self.log(file); 216 | } 217 | } 218 | Err(_x) => { 219 | err_count += 1; 220 | if err_count == 10 { 221 | return Err(ContractError::ProviderError(ProviderError::CustomError( 222 | String::from("can't query filter"), 223 | ))); 224 | } 225 | filter_id = watcher 226 | .new_filter(FilterKind::NewBlocks) 227 | .await 228 | .map_err(ContractError::MiddlewareError)?; 229 | } 230 | } 231 | } 232 | } 233 | 234 | #[instrument(skip(self), fields(self.instance_name))] 235 | pub async fn one_shot(&mut self) -> Result<(), M> { 236 | let block_number = self 237 | .client 238 | .get_block_number() 239 | .await 240 | .map_err(ContractError::MiddlewareError)?; 241 | return self.on_block(block_number).await; 242 | } 243 | 244 | /// Runs the liquidation business logic for the specified block 245 | #[instrument(skip(self), fields(self.instance_name))] 246 | async fn on_block(&mut self, block_number: U64) -> Result<(), M> { 247 | // Get the gas price - TODO: Replace with gas price oracle 248 | let gas_price = self 249 | .client 250 | .get_gas_price() 251 | .await 252 | .map_err(ContractError::MiddlewareError)?; 253 | 254 | // 1. Check if our transactions have been mined 255 | self.liquidator.remove_or_bump().await?; 256 | 257 | // 2. update our dataset with the new block's data 258 | self.borrowers 259 | .update_vaults(self.last_block, block_number, &mut self.cache) 260 | .await?; 261 | 262 | // 3. trigger the auction for any undercollateralized borrowers 263 | self.liquidator 264 | .start_auctions(self.borrowers.vaults.iter(), gas_price) 265 | .await?; 266 | 267 | // 4. try buying the ones which are worth buying 268 | self.liquidator 269 | .buy_opportunities(self.last_block, block_number, gas_price, &mut self.cache) 270 | .await?; 271 | Ok(()) 272 | } 273 | 274 | fn log(&self, w: W) { 275 | serde_json::to_writer( 276 | w, 277 | &State { 278 | auctions: self.liquidator.auctions.clone(), 279 | vaults: self.borrowers.vaults.clone(), 280 | last_block: self.last_block.as_u64(), 281 | }, 282 | ) 283 | .unwrap(); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod bindings; 2 | pub mod borrowers; 3 | pub mod cache; 4 | pub mod escalator; 5 | pub mod keeper; 6 | pub mod liquidations; 7 | pub mod swap_router; 8 | 9 | use ethers::prelude::*; 10 | use std::collections::HashMap; 11 | 12 | /// "ETH-A" collateral type in hex, right padded to 32 bytes 13 | pub const WETH: [u8; 32] = [ 14 | 0x45, 0x54, 0x48, 0x2d, 0x41, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 15 | 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 16 | ]; 17 | 18 | // merge & deduplicate the 2 data structs 19 | pub fn merge(a: Vec, b: &HashMap) -> Vec { 20 | let keys = b.keys().cloned().collect::>(); 21 | let mut all = [a, keys].concat(); 22 | all.sort_unstable(); 23 | all.dedup(); 24 | all 25 | } 26 | 27 | pub type Result = std::result::Result>; 28 | -------------------------------------------------------------------------------- /src/liquidations.rs: -------------------------------------------------------------------------------- 1 | //! Auctions Module 2 | //! 3 | //! This module is responsible for triggering and participating in a Auction's 4 | //! dutch auction 5 | use crate::{ 6 | bindings::{Cauldron, Witch, VaultIdType, FlashLiquidator, BaseIdType, IlkIdType}, 7 | borrowers::{Vault}, 8 | escalator::GeometricGasPrice, 9 | merge, Result, cache::ImmutableCache, swap_router::SwapRouter, 10 | }; 11 | 12 | use ethers_core::types::transaction::eip2718::TypedTransaction; 13 | 14 | use ethers::{ 15 | prelude::*, 16 | }; 17 | use serde::{Deserialize, Serialize}; 18 | use std::{collections::HashMap, fmt, ops::Mul, sync::Arc, time::{Instant, SystemTime}, convert::TryInto}; 19 | use tracing::{debug, debug_span, error, info, trace, warn, instrument}; 20 | 21 | pub type AuctionMap = HashMap; 22 | 23 | use std::ops::Div; 24 | 25 | 26 | #[derive(Clone)] 27 | pub struct Liquidator { 28 | cauldron: Cauldron, 29 | liquidator: Witch, 30 | flash_liquidator: FlashLiquidator, 31 | 32 | /// The currently active auctions 33 | pub auctions: AuctionMap, 34 | 35 | /// We use multicall to batch together calls and have reduced stress on 36 | /// our RPC endpoint 37 | multicall: Multicall, 38 | 39 | // uniswap swap router 40 | swap_router: SwapRouter, 41 | 42 | /// The minimum ratio (collateral/debt) to trigger liquidation 43 | min_ratio: u16, 44 | 45 | // extra gas to use for txs, as percent of estimated gas cost 46 | gas_boost: u16, 47 | 48 | // buy an auction when this percentage of collateral is released 49 | target_collateral_offer: u16, 50 | 51 | pending_liquidations: HashMap, 52 | pending_auctions: HashMap, 53 | gas_escalator: GeometricGasPrice, 54 | bump_gas_delay: u64, 55 | 56 | instance_name: String 57 | } 58 | 59 | /// Tx / Hash/ Submitted at time 60 | type PendingTransaction = (TypedTransaction, TxHash, Instant); 61 | 62 | /// An initiated auction 63 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] 64 | pub struct Auction { 65 | /// The start time of the auction 66 | started: u32, 67 | under_auction: bool, 68 | /// The debt which can be repaid 69 | debt: u128, 70 | 71 | base_id: BaseIdType, 72 | 73 | ilk_id: IlkIdType, 74 | 75 | ratio_pct: u16, 76 | collateral_offer_is_good_enough: bool, 77 | } 78 | 79 | #[derive(Clone, Debug, Serialize, Deserialize)] 80 | enum TxType { 81 | Auction, 82 | Liquidation, 83 | } 84 | 85 | impl fmt::Display for TxType { 86 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 87 | let string = match self { 88 | TxType::Auction => "auction", 89 | TxType::Liquidation => "liquidation", 90 | }; 91 | write!(f, "{}", string) 92 | } 93 | } 94 | 95 | impl Liquidator { 96 | /// Constructor 97 | pub async fn new( 98 | swap_router: SwapRouter, 99 | cauldron: Address, 100 | liquidator: Address, 101 | flashloan: Address, 102 | multicall: Option

, 103 | min_ratio: u16, 104 | gas_boost: u16, 105 | target_collateral_offer: u16, 106 | client: Arc, 107 | auctions: AuctionMap, 108 | gas_escalator: GeometricGasPrice, 109 | bump_gas_delay: u64, 110 | instance_name: String 111 | ) -> Self { 112 | let multicall = Multicall::new(client.clone(), multicall) 113 | .await 114 | .expect("could not initialize multicall"); 115 | 116 | Self { 117 | cauldron: Cauldron::new(cauldron, client.clone()), 118 | liquidator: Witch::new(liquidator, client.clone()), 119 | flash_liquidator: FlashLiquidator::new(flashloan, client.clone()), 120 | multicall, 121 | swap_router, 122 | min_ratio, 123 | gas_boost, 124 | target_collateral_offer, 125 | auctions, 126 | 127 | pending_liquidations: HashMap::new(), 128 | pending_auctions: HashMap::new(), 129 | gas_escalator, 130 | bump_gas_delay, 131 | instance_name 132 | } 133 | } 134 | 135 | /// Checks if any transactions which have been submitted are mined, removes 136 | /// them if they were successful, otherwise bumps their gas price 137 | #[instrument(skip(self), fields(self.instance_name))] 138 | pub async fn remove_or_bump(&mut self) -> Result<(), M> { 139 | let now = Instant::now(); 140 | 141 | let liquidator_client = self.liquidator.client(); 142 | // Check all the pending liquidations 143 | Liquidator::remove_or_bump_inner(now, liquidator_client, &self.gas_escalator, 144 | &mut self.pending_liquidations, "liquidations", 145 | self.instance_name.as_ref(), 146 | self.bump_gas_delay).await?; 147 | Liquidator::remove_or_bump_inner(now, liquidator_client, &self.gas_escalator, 148 | &mut self.pending_auctions, "auctions", 149 | self.instance_name.as_ref(), 150 | self.bump_gas_delay).await?; 151 | 152 | Ok(()) 153 | } 154 | 155 | async fn remove_or_bump_inner( 156 | now: Instant, 157 | client: &M, 158 | gas_escalator: &GeometricGasPrice, 159 | pending_txs: &mut HashMap, 160 | tx_type: &str, 161 | instance_name: &str, 162 | bump_gas_delay: u64 163 | ) -> Result<(), M> { 164 | for (addr, (pending_tx_wrapper, tx_hash, instant)) in pending_txs.clone().into_iter() { 165 | let pending_tx = match pending_tx_wrapper { 166 | TypedTransaction::Eip1559(x) => x, 167 | _ => panic!("Non-Eip1559 transactions are not supported yet") 168 | }; 169 | 170 | // get the receipt and check inclusion, or bump its gas price 171 | let receipt = client 172 | .get_transaction_receipt(tx_hash) 173 | .await 174 | .map_err(ContractError::MiddlewareError)?; 175 | if let Some(receipt) = receipt { 176 | pending_txs.remove(&addr); 177 | let status = if receipt.status == Some(1.into()) { 178 | "success" 179 | } else { 180 | "fail" 181 | }; 182 | info!(tx_hash = ?tx_hash, gas_used = %receipt.gas_used.unwrap_or_default(), user = ?addr, 183 | status = status, tx_type, instance_name, "confirmed"); 184 | } else { 185 | let time_since = now.duration_since(instant).as_secs(); 186 | if time_since > bump_gas_delay { 187 | info!(tx_hash = ?tx_hash, "Bumping gas"); 188 | // Get the new gas price based on how much time passed since the 189 | // tx was last broadcast 190 | let new_gas_price = gas_escalator.get_gas_price( 191 | pending_tx.max_fee_per_gas.expect("max_fee_per_gas price must be set"), 192 | now.duration_since(instant).as_secs(), 193 | ); 194 | 195 | let replacement_tx = pending_txs 196 | .get_mut(&addr) 197 | .expect("tx will always be found since we're iterating over the map"); 198 | 199 | // bump the gas price 200 | if let TypedTransaction::Eip1559(x) = &mut replacement_tx.0 { 201 | // it should be reversed: 202 | // - max_fee_per_gas has to be constant 203 | // - max_priority_fee_per_gas needs to be bumped 204 | x.max_fee_per_gas = Some(new_gas_price); 205 | x.max_priority_fee_per_gas = Some(U256::from(2000000000)); // 2 gwei 206 | } else { 207 | panic!("Non-Eip1559 transactions are not supported yet"); 208 | } 209 | 210 | // rebroadcast 211 | match client 212 | .send_transaction(replacement_tx.0.clone(), None) 213 | .await { 214 | Ok(tx) => { 215 | replacement_tx.1 = *tx; 216 | }, 217 | Err(x) => { 218 | error!(tx=?replacement_tx, err=?x, "Failed to replace transaction: dropping it"); 219 | pending_txs.remove(&addr); 220 | } 221 | } 222 | 223 | info!(tx_hash = ?tx_hash, new_gas_price = %new_gas_price, user = ?addr, 224 | tx_type, instance_name, "Bumping gas: done"); 225 | } else { 226 | info!(tx_hash = ?tx_hash, time_since, bump_gas_delay, instance_name, "Bumping gas: too early"); 227 | } 228 | } 229 | } 230 | 231 | Ok(()) 232 | } 233 | 234 | /// Sends a bid for any of the liquidation auctions. 235 | #[instrument(skip(self, from_block, to_block, cache), fields(self.instance_name))] 236 | pub async fn buy_opportunities( 237 | &mut self, 238 | from_block: U64, 239 | to_block: U64, 240 | gas_price: U256, 241 | cache: &mut ImmutableCache 242 | ) -> Result<(), M> { 243 | let all_auctions = { 244 | let liquidations = self 245 | .liquidator 246 | .auctioned_filter() 247 | .from_block(from_block) 248 | .to_block(to_block) 249 | .query() 250 | .await?; 251 | let new_liquidations = liquidations 252 | .iter() 253 | .map(|x| x.vault_id).collect::>(); 254 | merge(new_liquidations, &self.auctions) 255 | }; 256 | 257 | info!(count=all_auctions.len(), instance_name=self.instance_name.as_str(), "Liquidations collected"); 258 | for vault_id in all_auctions { 259 | self.auctions.insert(vault_id, true); 260 | 261 | trace!(vault_id=?hex::encode(vault_id), "Buying"); 262 | match self.buy(vault_id, Instant::now(), gas_price, cache).await { 263 | Ok(is_still_valid) => { 264 | if !is_still_valid { 265 | info!(vault_id=?hex::encode(vault_id), instance_name=self.instance_name.as_str(), "Removing no longer valid auction"); 266 | self.auctions.remove(&vault_id); 267 | } 268 | } 269 | Err(x) => { 270 | error!(vault_id=?hex::encode(vault_id), instance_name=self.instance_name.as_str(), 271 | error=?x, "Failed to buy"); 272 | } 273 | } 274 | } 275 | 276 | Ok(()) 277 | } 278 | 279 | /// Tries to buy the collateral associated with a user's liquidation auction 280 | /// via a flashloan funded by Uniswap. 281 | /// 282 | /// Returns 283 | /// - Result: auction is no longer valid, we need to forget about it 284 | /// - Result: auction is still valid 285 | #[instrument(skip(self, cache), fields(self.instance_name))] 286 | async fn buy(&mut self, vault_id: VaultIdType, now: Instant, gas_price: U256, 287 | cache: &mut ImmutableCache) -> Result { 288 | // only iterate over users that do not have active auctions 289 | if let Some(pending_tx) = self.pending_auctions.get(&vault_id) { 290 | trace!(tx_hash = ?pending_tx.1, vault_id=?vault_id, "bid not confirmed yet"); 291 | return Ok(true); 292 | } 293 | 294 | // Get the vault's info 295 | let auction = match self.get_auction(vault_id, cache).await { 296 | Ok(Some(x)) => x, 297 | Ok(None) => { 298 | // auction is not valid 299 | return Ok(false); 300 | } 301 | Err(x) => { 302 | warn!(vault_id=?hex::encode(vault_id), err=?x, "Failed to get auction"); 303 | return Ok(true); 304 | } 305 | }; 306 | 307 | if !auction.under_auction { 308 | debug!(vault_id=?hex::encode(vault_id), auction=?auction, "Auction is no longer active"); 309 | return Ok(false); 310 | } 311 | 312 | // Skip auctions which do not have any outstanding debt 313 | if auction.debt == 0 { 314 | debug!(vault_id=?hex::encode(vault_id), auction=?auction, "Has no debt - skipping"); 315 | return Ok(true); 316 | } 317 | 318 | let mut buy: bool = false; 319 | if auction.ratio_pct <= self.min_ratio { 320 | info!(vault_id=?hex::encode(vault_id), auction=?auction, 321 | ratio=auction.ratio_pct, ratio_threshold=self.min_ratio, 322 | instance_name=self.instance_name.as_str(), 323 | "Ratio threshold is reached, buying"); 324 | buy = true; 325 | } 326 | if auction.collateral_offer_is_good_enough { 327 | info!(vault_id=?hex::encode(vault_id), auction=?auction, 328 | ratio=auction.ratio_pct, ratio_threshold=self.min_ratio, 329 | instance_name=self.instance_name.as_str(), 330 | "Collateral offer is good enough, buying"); 331 | buy = true; 332 | } 333 | if !buy { 334 | debug!(vault_id=?hex::encode(vault_id), auction=?auction, "Not time to buy yet"); 335 | return Ok(true); 336 | } 337 | 338 | if self.auctions.insert(vault_id, true).is_none() { 339 | debug!(vault_id=?vault_id, auction=?auction, "new auction"); 340 | } 341 | let span = debug_span!("buying", vault_id=?vault_id, auction=?auction); 342 | let _enter = span.enter(); 343 | 344 | let maybe_calldata = self.swap_router.build_swap_exact_out( 345 | cache.get_or_fetch_asset_address(auction.ilk_id).await?, // in: collateral 346 | cache.get_or_fetch_asset_address(auction.base_id).await?, // out:debt 347 | U256::from(auction.debt) 348 | ).await; 349 | if let Err(x) = maybe_calldata { 350 | warn!(vault_id=?hex::encode(vault_id), err=?x, "failed to generate swap calldata - will try later"); 351 | return Ok(true); 352 | } 353 | let swap_calldata = maybe_calldata.unwrap().calldata; 354 | 355 | let raw_call = self.flash_liquidator.liquidate(vault_id, swap_calldata) 356 | // explicitly set 'from' field because we're about to call `estimate_gas` 357 | // If there's no `from` set, the estimated transaction is sent from 0x0 and reverts (tokens can't be transferred there) 358 | // 359 | // Also, it's safe to unwrap() client().default_sender(): if it's not set, we're in trouble anyways 360 | .from(self.flash_liquidator.client().default_sender().unwrap()); 361 | let gas_estimation = raw_call.estimate_gas().await?; 362 | let gas = gas_estimation.mul(U256::from(self.gas_boost + 100)).div(100); 363 | let call = raw_call 364 | .gas_price(gas_price) 365 | .gas(gas); 366 | 367 | let tx = call.tx.clone(); 368 | 369 | match call.send().await { 370 | Ok(hash) => { 371 | // record the tx 372 | info!(tx_hash = ?hash, 373 | vault_id = ?hex::encode(vault_id), 374 | instance_name=self.instance_name.as_str(), 375 | gas=?gas, 376 | "Submitted buy order"); 377 | self.pending_auctions 378 | .entry(vault_id) 379 | .or_insert((tx, *hash, now)); 380 | } 381 | Err(err) => { 382 | let err = err.to_string(); 383 | error!("Buy error: {}; data: {:?}", err, call.calldata()); 384 | } 385 | }; 386 | 387 | Ok(true) 388 | } 389 | 390 | /// Triggers liquidations for any vulnerable positions which were fetched from the 391 | /// controller 392 | #[instrument(skip(self, vaults), fields(self.instance_name))] 393 | pub async fn start_auctions( 394 | &mut self, 395 | vaults: impl Iterator, 396 | gas_price: U256, 397 | ) -> Result<(), M> { 398 | debug!("checking for undercollateralized positions..."); 399 | 400 | let now = Instant::now(); 401 | 402 | for (vault_id, vault) in vaults { 403 | if !vault.is_initialized { 404 | trace!(vault_id = ?hex::encode(vault_id), "Vault is not initialized yet, skipping"); 405 | continue; 406 | } 407 | // only iterate over vaults that do not have pending liquidations 408 | if let Some(pending_tx) = self.pending_liquidations.get(vault_id) { 409 | trace!(tx_hash = ?pending_tx.1, vault_id = ?hex::encode(vault_id), "liquidation not confirmed yet"); 410 | continue; 411 | } 412 | 413 | if !vault.is_collateralized { 414 | if vault.under_auction { 415 | debug!(vault_id = ?hex::encode(vault_id), details = ?vault, "found vault under auction, ignoring it"); 416 | continue; 417 | } 418 | info!( 419 | vault_id = ?hex::encode(vault_id), details = ?vault, gas_price=?gas_price, 420 | instance_name=self.instance_name.as_str(), 421 | "found an undercollateralized vault. starting an auction", 422 | ); 423 | 424 | // Send the tx and track it 425 | let call = self.liquidator.auction(*vault_id).gas_price(gas_price); 426 | let tx = call.tx.clone(); 427 | match call.send().await { 428 | Ok(tx_hash) => { 429 | info!(tx_hash = ?tx_hash, 430 | vault_id = ?hex::encode(vault_id), 431 | instance_name=self.instance_name.as_str(), "Submitted liquidation"); 432 | self.pending_liquidations 433 | .entry(*vault_id) 434 | .or_insert((tx, *tx_hash, now)); 435 | } 436 | Err(x) => { 437 | warn!( 438 | vault_id = ?hex::encode(vault_id), 439 | error=?x, 440 | calldata=?call.calldata(), 441 | "Can't start the auction"); 442 | } 443 | }; 444 | } else { 445 | debug!(vault_id=?hex::encode(vault_id), "Vault is collateralized/ignored"); 446 | } 447 | } 448 | Ok(()) 449 | } 450 | 451 | fn current_offer(&self, now: u64, auction_start: u64, duration: u64, initial_offer: u64) -> Result { 452 | if now < auction_start.into() { 453 | return Err(ContractError::ConstructorError{}); 454 | } 455 | let one = 10u64.pow(18); 456 | if initial_offer > one { 457 | error!(initial_offer, "initialOffer > 1"); 458 | return Err(ContractError::ConstructorError{}); 459 | } 460 | let initial_offer_pct = initial_offer / 10u64.pow(16); // 0-100 461 | 462 | let time_since_auction_start: u64 = now - auction_start; 463 | if time_since_auction_start >= duration { 464 | Ok(100) 465 | } else { 466 | // time_since_auction_start / duration * (1 - initial_offer) + initial_offer 467 | Ok((time_since_auction_start * (100 - initial_offer_pct) / duration + initial_offer_pct).try_into().unwrap()) 468 | } 469 | } 470 | 471 | async fn get_auction(&mut self, vault_id: VaultIdType, cache: &mut ImmutableCache) -> Result, M> { 472 | let (_, series_id, ilk_id) = self.cauldron.vaults(vault_id).call().await?; 473 | let balances_fn = self.cauldron.balances(vault_id); 474 | let auction_fn = self.liquidator.auctions(vault_id); 475 | 476 | trace!( 477 | vault_id=?hex::encode(vault_id), 478 | "Fetching auction details" 479 | ); 480 | 481 | let multicall = self 482 | .multicall 483 | .clear_calls() 484 | .add_call(balances_fn) 485 | .add_call(auction_fn) 486 | .add_call(self.liquidator.ilks(ilk_id)) 487 | .add_call(self.flash_liquidator.collateral_to_debt_ratio(vault_id)) 488 | ; 489 | 490 | let ((art, _), (auction_owner, auction_start), (duration, initial_offer), ratio_u256): 491 | ((u128, u128), (Address, u32), (u32, u64), U256) = multicall.call().await?; 492 | 493 | if cache.is_vault_ignored(series_id, ilk_id, art).await? { 494 | info!(vault_id=?hex::encode(vault_id), "vault is trivial or ignored - not auctioning"); 495 | return Ok(None); 496 | } 497 | let current_offer: u16 = 498 | match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { 499 | Ok(x) => self.current_offer(x.as_secs(), 500 | u64::from(auction_start), 501 | u64::from(duration), initial_offer) 502 | .unwrap_or(0), 503 | Err(x) => { 504 | error!("Failed to get system time: {}", x); 505 | 0u16 506 | } 507 | }; 508 | 509 | trace!( 510 | vault_id=?hex::encode(vault_id), 511 | debt=?art, 512 | ratio=?ratio_u256, 513 | current_offer=current_offer, 514 | "Fetched auction details" 515 | ); 516 | 517 | let ratio_pct_u256 = ratio_u256 / U256::exp10(16); 518 | let ratio_pct: u16 = { 519 | if ratio_pct_u256 > U256::from(u16::MAX) { 520 | error!(vault_id=?vault_id, ratio_pct_u256=?ratio_pct_u256, "Ratio is too big"); 521 | 0 522 | } else { 523 | (ratio_pct_u256.as_u64()) as u16 524 | } 525 | }; 526 | 527 | Ok(Some(Auction { 528 | under_auction: (auction_owner != Address::zero()), 529 | started: auction_start, 530 | debt: art, 531 | ratio_pct: ratio_pct, 532 | base_id: cache.get_or_fetch_base_id(series_id).await?, 533 | ilk_id: ilk_id, 534 | collateral_offer_is_good_enough: current_offer >= self.target_collateral_offer, 535 | })) 536 | 537 | } 538 | } 539 | -------------------------------------------------------------------------------- /src/swap_router.rs: -------------------------------------------------------------------------------- 1 | //! Immutable data cache 2 | //! 3 | 4 | use async_process::Command; 5 | use ethers::prelude::*; 6 | use thiserror::Error; 7 | use tracing::instrument; 8 | 9 | use serde::Deserialize; 10 | 11 | #[derive(Deserialize)] 12 | struct RouterResult { 13 | data: String, 14 | } 15 | 16 | #[derive(Clone)] 17 | pub struct SwapRouter { 18 | pub rpc_url: String, 19 | pub chain_id: u64, 20 | pub uni_router_02: Address, 21 | pub flash_liquidator: Address, 22 | pub router_binary_path: String, 23 | pub instance_name: String, 24 | } 25 | 26 | #[derive(Error, Debug)] 27 | pub enum SwapRouterError { 28 | #[error("router error")] 29 | RouterError(String), 30 | #[error("unknown error")] 31 | Unknown, 32 | } 33 | 34 | pub struct SwapCalldata { 35 | pub calldata: Vec, 36 | } 37 | 38 | impl SwapRouter { 39 | /// Constructor 40 | pub fn new( 41 | rpc_url: String, 42 | chain_id: u64, 43 | uni_router_02: Address, 44 | flash_liquidator: Address, 45 | router_binary_path: String, 46 | instance_name: String, 47 | ) -> Self { 48 | SwapRouter { 49 | rpc_url, 50 | chain_id, 51 | uni_router_02, 52 | flash_liquidator, 53 | router_binary_path, 54 | instance_name, 55 | } 56 | } 57 | 58 | #[instrument(skip(self), fields(self.instance_name))] 59 | pub async fn build_swap_exact_out( 60 | &self, 61 | token_in: Address, 62 | token_out: Address, 63 | amount_in: U256, 64 | ) -> std::result::Result { 65 | let out = Command::new(self.router_binary_path.as_str()) 66 | .arg(format!("--rpc_url={}", self.rpc_url)) 67 | .arg(format!("--chain_id={}", self.chain_id)) 68 | .arg(format!("--from_address={:?}", self.flash_liquidator)) 69 | .arg(format!("--token_in={:?}", token_in)) 70 | .arg(format!("--token_out={:?}", token_out)) 71 | .arg(format!("--amount_out={}", amount_in)) 72 | .arg(format!("--silent")) 73 | .output() 74 | .await 75 | .map_err(|io_error| { 76 | SwapRouterError::RouterError(format!( 77 | "Failed to call external router: {:}", 78 | io_error 79 | )) 80 | })?; 81 | if out.status.success() { 82 | return stdout_to_swap(&out.stdout); 83 | } else { 84 | return Err(SwapRouterError::RouterError(format!( 85 | "Failed to call external router; exit code: {:?}; stderr: {:?}; stdout: {:?}", 86 | out.status.code(), 87 | String::from_utf8(out.stderr), 88 | String::from_utf8(out.stdout), 89 | ))); 90 | } 91 | } 92 | /* 93 | uint256 debtRecovered = swapRouter.exactInputSingle( 94 | ISwapRouter.ExactInputSingleParams({ 95 | tokenIn: decoded.collateral, 96 | tokenOut: decoded.base, 97 | fee: 3000, // can't use the same fee as the flash loan 98 | // because of reentrancy protection 99 | recipient: address(this), 100 | deadline: block.timestamp + 180, 101 | amountIn: collateralReceived, 102 | amountOutMinimum: debtToReturn, // bots will sandwich us and eat profits, we don't mind 103 | sqrtPriceLimitX96: 0 104 | }) 105 | ); 106 | 107 | */ 108 | } 109 | 110 | fn stdout_to_swap(stdout: &[u8]) -> std::result::Result { 111 | let router_result: RouterResult = serde_json::from_slice(stdout).map_err(|e| { 112 | return SwapRouterError::RouterError(format!( 113 | "failed to deserialize json output: {:?}; output: {:?}", 114 | e, 115 | String::from_utf8(stdout.to_vec()) 116 | )); 117 | })?; 118 | let calldata = hex::decode(&router_result.data[2..]).map_err(|e| { 119 | SwapRouterError::RouterError(format!("failed to deserialize hex calldata: {:?}", e)) 120 | })?; 121 | return Ok(SwapCalldata { calldata }); 122 | } 123 | 124 | #[cfg(test)] 125 | mod tests { 126 | use super::*; 127 | use std::str::FromStr; 128 | 129 | #[tokio::test] 130 | async fn swap_weth_for_usdc() { 131 | let sr = SwapRouter::new( 132 | "http://127.0.0.1:8545/".to_string(), 133 | 1, 134 | Address::from_str("0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45").unwrap(), 135 | Address::zero(), 136 | "build/bin/router".to_string(), 137 | "".to_string(), 138 | ); 139 | let maybe_swap = sr 140 | .build_swap_exact_out( 141 | Address::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(), 142 | Address::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), 143 | U256::one(), //U256::from(10).pow(U256::from(18)) 144 | ) 145 | .await; 146 | // assert_eq!(maybe_swap.is_ok(), true); 147 | let swap = maybe_swap.unwrap(); 148 | assert_eq!(swap.calldata.len() > 4, true, "calldata should be at least 4 bytes long"); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /test/test_flashLiquidator.ts: -------------------------------------------------------------------------------- 1 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; 2 | import { id } from '@yield-protocol/utils-v2' 3 | 4 | import { expect } from "chai"; 5 | import { ethers } from "hardhat"; 6 | 7 | import { Logger } from "tslog"; 8 | 9 | 10 | import { Cauldron, CompoundMultiOracle, ChainlinkMultiOracle, ERC20Mock, FYToken, Join, 11 | SafeERC20Namer, ChainlinkAggregatorV3Mock, FlashLiquidator, Witch } from "../typechain"; 12 | import { BigNumber } from "@ethersproject/bignumber"; 13 | import { Interface } from '@ethersproject/abi'; 14 | 15 | 16 | const logger: Logger = new Logger(); 17 | 18 | const baseId = ethers.utils.formatBytes32String('02').slice(0, 14) 19 | const ilkId = ethers.utils.formatBytes32String('00').slice(0, 14) 20 | const vaultId = ethers.utils.randomBytes(12) 21 | const seriesId = ethers.utils.randomBytes(6) 22 | 23 | const WAD = BigNumber.from(10).pow(18); 24 | 25 | async function deploy(owner: SignerWithAddress, contract_name: string, ...args: any[]): Promise { 26 | const factory = await ethers.getContractFactory(contract_name, owner) 27 | return (await factory.deploy(...args)) as unknown as T; 28 | } 29 | 30 | describe("collateralToDebtRatio", function () { 31 | let cauldron: Cauldron; 32 | let owner: SignerWithAddress; 33 | let spotSource: ChainlinkAggregatorV3Mock; 34 | let flashLiquidator: FlashLiquidator; 35 | 36 | this.beforeEach(async function () { 37 | [owner] = await ethers.getSigners() 38 | 39 | const base = await deploy(owner, "ERC20Mock", "base", "BASE"); 40 | logger.info("base deployed"); 41 | 42 | const ilk = await deploy(owner, "ERC20Mock", "ilk", "ILK"); 43 | logger.info("ilk deployed"); 44 | 45 | const join = await deploy(owner, "Join", base.address); 46 | logger.info("join deployed"); 47 | 48 | const chiRateOracle = await deploy(owner, "CompoundMultiOracle"); 49 | const spotOracle = await deploy(owner, "ChainlinkMultiOracle"); 50 | await spotOracle.grantRole( 51 | id(spotOracle.interface as any, 'setSource(bytes6,address,bytes6,address,address)'), 52 | owner.address 53 | ) 54 | 55 | logger.info("oracles deployed"); 56 | 57 | spotSource = await deploy(owner, "ChainlinkAggregatorV3Mock"); 58 | await spotSource.set(WAD.div(2).toString()) // 0.5 base == 1 ilk 59 | await spotOracle.setSource(baseId, base.address, ilkId, ilk.address, spotSource.address); 60 | logger.info("spot source set"); 61 | 62 | const current_block = await ethers.provider.getBlock(await ethers.provider.getBlockNumber()); 63 | const fyTokenLibrary = await ethers.getContractFactory("FYToken", { 64 | signer: owner, 65 | libraries: { 66 | "SafeERC20Namer": (await deploy(owner, "SafeERC20Namer")).address 67 | } 68 | }); 69 | const fyToken = (await fyTokenLibrary.deploy( 70 | baseId, chiRateOracle.address, join.address, 71 | current_block.timestamp + 3600, "fytoken", "FYT")) as FYToken; 72 | 73 | logger.info("FYToken deployed"); 74 | 75 | const cauldronFactory = await ethers.getContractFactory('Cauldron', owner) 76 | cauldron = (await cauldronFactory.deploy()) as Cauldron 77 | 78 | await cauldron.grantRoles( 79 | [ 80 | id(cauldron.interface as any, 'build(address,bytes12,bytes6,bytes6)'), 81 | id(cauldron.interface as any, 'addAsset(bytes6,address)'), 82 | id(cauldron.interface as any, 'addSeries(bytes6,bytes6,address)'), 83 | id(cauldron.interface as any, 'addIlks(bytes6,bytes6[])'), 84 | id(cauldron.interface as any, 'setDebtLimits(bytes6,bytes6,uint96,uint24,uint8)'), 85 | id(cauldron.interface as any, 'setLendingOracle(bytes6,address)'), 86 | id(cauldron.interface as any, 'setSpotOracle(bytes6,bytes6,address,uint32)'), 87 | id(cauldron.interface as any, 'pour(bytes12,int128,int128)'), 88 | ], 89 | owner.address 90 | ) 91 | logger.info("Cauldron: created"); 92 | 93 | await cauldron.addAsset(baseId, base.address); 94 | await cauldron.addAsset(ilkId, ilk.address); 95 | logger.info("Cauldron: assets added"); 96 | 97 | await cauldron.setLendingOracle(baseId, chiRateOracle.address); 98 | await cauldron.setSpotOracle(baseId, ilkId, spotOracle.address, 200 * 1e4); // 200% collaterization ratio 99 | await cauldron.setDebtLimits(baseId, ilkId, WAD.toString(), 0, 18); 100 | logger.info("Cauldron: oracles set"); 101 | 102 | await cauldron.addSeries(seriesId, baseId, fyToken.address); 103 | logger.info("Cauldron: series added"); 104 | 105 | await cauldron.addIlks(seriesId, [ilkId]); 106 | logger.info("Cauldron: ilks added"); 107 | 108 | await cauldron.build(owner.address, vaultId, seriesId, ilkId) 109 | logger.info("Cauldron: built"); 110 | 111 | await cauldron.pour(vaultId, WAD.toString(), WAD.toString()) 112 | logger.info("Cauldron: poured"); 113 | 114 | const witch = await deploy(owner, "Witch", cauldron.address, ethers.constants.AddressZero); 115 | flashLiquidator = await deploy(owner, "FlashLiquidator", witch.address, ethers.constants.AddressZero, ethers.constants.AddressZero); 116 | logger.info("FlashLiquidator deployed"); 117 | }) 118 | 119 | it("is set to >100 for over-collaterized vaults", async function () { 120 | // oracle says: 0.5 base == 1 ilk 121 | // vault: 1 base deposited, 1 ilk borrowed => we're at 200% collaterization 122 | 123 | // collaterization rate is set at 200% => level() should be 0 124 | expect (await cauldron.callStatic.level(vaultId)).to.be.equal(0) 125 | 126 | // collateralToDebtRatio should be 200% 127 | expect (await flashLiquidator.callStatic.collateralToDebtRatio(vaultId)).to.be.equal(WAD.mul(2).toString()); 128 | }); 129 | 130 | it("is set to =100 for just-collaterized vaults", async function () { 131 | await spotSource.set(WAD.toString()) // 1 base == 1 ilk 132 | // vault: 1 base deposited, 1 ilk borrowed => we're at 100% collaterization 133 | 134 | // collaterization rate is set at 200% => level() should be at -1 135 | expect (await cauldron.callStatic.level(vaultId)).to.be.equal(WAD.mul(-1).toString()) 136 | 137 | // collateralToDebtRatio should be 100% 138 | expect (await flashLiquidator.callStatic.collateralToDebtRatio(vaultId)).to.be.equal(WAD.toString()); 139 | }); 140 | 141 | it("is set to <100 for under-collaterized vaults", async function () { 142 | await spotSource.set(WAD.mul(2).toString()) // 2 base == 1 ilk 143 | // vault: 1 base deposited, 1 ilk borrowed => we're at 50% collaterization 144 | 145 | // collaterization rate is set at 200% => level() should be at -1.5 146 | expect (await cauldron.callStatic.level(vaultId)).to.be.equal(WAD.mul(-3).div(2).toString()) 147 | 148 | // collateralToDebtRatio should be 50% 149 | expect (await flashLiquidator.callStatic.collateralToDebtRatio(vaultId)).to.be.equal(WAD.div(2).toString()); 150 | }); 151 | 152 | }); 153 | -------------------------------------------------------------------------------- /tsconfig-publish.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "es2015", 5 | "importHelpers": true, 6 | "declaration": true, 7 | "sourceMap": true, 8 | "rootDir": "./src", 9 | "baseUrl": "./", 10 | "paths": { 11 | "*": ["src/*", "node_modules/*"] 12 | } 13 | }, 14 | "include": [ 15 | "src/**/*.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "outDir": "build/" 9 | }, 10 | "include": [ 11 | "src/**/*.ts", 12 | "artifacts/**/*", 13 | "artifacts/**/*.json", 14 | "scripts/**/*", 15 | "tasks/**/*", 16 | "test/**/*", 17 | "typechain/**/*", 18 | "types/**/*", 19 | "hardhat.config.ts" 20 | ] 21 | } 22 | --------------------------------------------------------------------------------