diff --git a/contracts/stabilizer/Salvation.vy b/contracts/stabilizer/Salvation.vy new file mode 100644 index 00000000..b68701b1 --- /dev/null +++ b/contracts/stabilizer/Salvation.vy @@ -0,0 +1,102 @@ +# @version 0.3.10 +""" +@title Salvation +@license MIT +@author Curve.Fi +@notice Contract used to buy out coins from PegKeeper +""" + +interface ERC20: + def approve(_to: address, _value: uint256): nonpayable + def transfer(_to: address, _value: uint256) -> bool: nonpayable + def transferFrom(_from: address, _to: address, _value: uint256) -> bool: nonpayable + def balanceOf(_owner: address) -> uint256: view + def decimals() -> uint256: view + +interface CurvePool: + def add_liquidity(_amounts: uint256[2], _min_mint_amount: uint256) -> uint256: nonpayable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[N_COINS], _receiver: address = msg.sender) -> uint256[N_COINS]: nonpayable + def remove_liquidity_imbalance(_amounts: uint256[2], _max_burn_amount: uint256, _receiver: address = msg.sender) -> uint256: nonpayable + def remove_liquidity_one_coin(_burn_amount: uint256, i: int128, _min_received: uint256, _receiver: address = msg.sender) -> uint256: nonpayable + def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address = msg.sender) -> uint256: nonpayable + def coins(i: uint256) -> ERC20: view + def balances(i_coin: uint256) -> uint256: view + def price_oracle() -> uint256: view + def get_p() -> uint256: view + def balanceOf(arg0: address) -> uint256: view + def totalSupply() -> uint256: view + +interface PegKeeper: + def update(_beneficiary: address = msg.sender) -> uint256: nonpayable + def debt() -> uint256: view + + +N_COINS: constant(uint256) = 2 +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 +EPS: constant(uint256) = 5 * 10 ** 14 # 0.05% + + +@internal +def _transfer_back(_coin: ERC20): + assert _coin.transfer(msg.sender, _coin.balanceOf(self), default_return_value=True) # safe transfer + + +@external +def buy_out(_pool: CurvePool, _pk: PegKeeper, _ransom: uint256, _max_total_supply: uint256, _max_price: uint256=10**18, + _use_all: bool=False, _safe: bool=True) -> uint256: + """ + @notice Buy out coin with crvUSD from Peg Keeper + @dev Need off-chain data for TotalSupply and Price + @param _pool Pool which is used for Peg Keeper + @param _pk Peg Keeper to buy out from + @param _ransom Amount of crvUSD to use to buy out + @param _max_total_supply Maximum totalSupply allowed for the pool to mitigate sandwich + @param _max_price Max coin price in crvUSD to allow to buy out + @param _use_all Use the whole ransom. Might be needed with small amount when fees affect peg + @param _safe Withdraw coins saving the price in the pool, so there are no extra losses from exchanges. + Otherwise all coins will be exchanged in the pool to crvUSD. + @return Amount of debt bought out + """ + max_price: uint256 = min(_max_price, _pool.price_oracle() * (10 ** 18 + EPS) / 10 ** 18) + initial_price: uint256 = _pool.get_p() + assert initial_price <= max_price + assert _pool.totalSupply() <= _max_total_supply + + initial_debt: uint256 = _pk.debt() + dec: uint256 = 10 ** (18 - _pool.coins(0).decimals()) + initial_amounts: uint256[2] = [_pool.balances(0) * dec, _pool.balances(1)] + + # Add crvUSD, so there is more crvUSD than TUSD and it is withdrawn + amount: uint256 = _ransom + if not _use_all: + amount = min(amount, 5 * initial_debt + initial_amounts[0] - initial_amounts[1]) + crvUSD: ERC20 = _pool.coins(1) + crvUSD.transferFrom(msg.sender, self, amount) + crvUSD.approve(_pool.address, max_value(uint256)) + lp: uint256 = _pool.add_liquidity([0, amount], 0) + + # PegKeeper takes back crvUSD + lp += _pk.update() + + if _safe: + # Withdraw crvUSD to stabilize + withdraw_amount: uint256 = _pool.balances(1) - initial_amounts[1] * _pool.balances(0) * dec / initial_amounts[0] + _pool.remove_liquidity_imbalance( + [0, withdraw_amount], + lp, + msg.sender, + ) + + # Remove everything else proportionally preserving the price + _pool.remove_liquidity(_pool.balanceOf(self), [0, 0], msg.sender) + + assert convert(abs(convert(_pool.get_p(), int256) - convert(initial_price, int256)), uint256) * 10 ** 18 / initial_price < EPS, "Price moved too far" + else: + # Withdraw everything in crvUSD, automatically exchanging all coins in the pool + _pool.remove_liquidity_one_coin(lp, 1, 0, msg.sender) + + # Just in case + for coin in [_pool.coins(0), crvUSD, ERC20(_pool.address)]: + self._transfer_back(coin) + + return initial_debt - _pk.debt() diff --git a/scripts/boa-salvation.py b/scripts/boa-salvation.py new file mode 100644 index 00000000..bb78ab28 --- /dev/null +++ b/scripts/boa-salvation.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +import boa +import json +import os +import sys +from getpass import getpass +from eth_account import account +from boa.network import NetworkEnv + + +RANSOM = 15 * 10 ** 6 * 10 ** 18 # ALTER: bank of crvUSD available +IDX = 3 # ALTER: coin to buy out (TUSD) + +NETWORK = f"http://localhost:8545" # ALTER: provider +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +POOLS = [ + "0x4dece678ceceb27446b35c672dc7d61f30bad69e", # USDC/crvUSD + "0x390f3595bca2df7d23783dfd126427cceb997bf4", # USDT/crvUSD + "0xca978a0528116dda3cba9acd3e68bc6191ca53d0", # USDP/crvUSD + "0x34d655069f4cac1547e4c8ca284ffff5ad4a8db0", # TUSD/crvUSD +] +PEG_KEEPERS = [ + "0xaA346781dDD7009caa644A4980f044C50cD2ae22", # USDC + "0xE7cd2b4EB1d98CD6a4A48B6071D46401Ac7DC5C8", # USDT + "0x6B765d07cf966c745B340AdCa67749fE75B5c345", # USDP + "0x1ef89Ed0eDd93D1EC09E4c07373f69C49f4dcCae", # TUSD +] +CRVUSD = "0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E" +SALVATION = ZERO_ADDRESS + +ETHERSCAN_API = os.environ["ETHERSCAN_TOKEN"] # ALTER: Etherscan API token +_contracts = {} + + +def _pool(idx): + if POOLS[idx] not in _contracts: + # _contracts[POOLS[idx]] = boa.load_partial("contracts/StableSwap.vy").at(POOLS[idx]) + _contracts[POOLS[idx]] = boa.from_etherscan(POOLS[idx], name="StableSwap", api_key=ETHERSCAN_API) + return _contracts[POOLS[idx]] + + +def _peg_keeper(idx): + if PEG_KEEPERS[idx] not in _contracts: + # _contracts[PEG_KEEPERS[idx]] = boa.load_partial("contracts/stabilizer/PegKeeper.vy").at(PEG_KEEPERS[idx]) + _contracts[PEG_KEEPERS[idx]] = boa.from_etherscan(PEG_KEEPERS[idx], name="PegKeeper", api_key=ETHERSCAN_API) + return _contracts[PEG_KEEPERS[idx]] + + +def _coins(pool): + coins = [pool] + for coin in [pool.coins(0), CRVUSD]: + if coin not in _contracts: + _contracts[coin] = boa.from_etherscan(pool.coins(0), name="coin", api_key=ETHERSCAN_API) + coins.append(_contracts[coin]) + return coins + + +def deploy(): + salvation = boa.load_partial("contracts/stabilizer/Salvation.vy") + if SALVATION != ZERO_ADDRESS: + return salvation.at(SALVATION) + return salvation.deploy() + + +def buy_out(idx=IDX, ransom=RANSOM, max_total_supply=None, max_price=None, salvation=None, use_all=False): + pool, pk = _pool(idx), _peg_keeper(idx) + if not max_total_supply: + max_total_supply = pool.totalSupply() * 1001 // 1000 + if not max_price: + max_price = pool.price_oracle() * 1001 // 1000 + if not salvation: + salvation = deploy() + + coins = _coins(pool) + initial_balances = [ + coin.balanceOf(boa.env.eoa) for coin in coins + ] + + bought_out = salvation.buy_out(pool, pk, ransom, max_total_supply, max_price, use_all) + print(f"Bought out: {bought_out / 10 ** 18:>11.2f} crvUSD") + print(f"Remaining: {pk.debt() / 10 ** 18:>11.2f} crvUSD") + + diffs = [] + for coin, initial_balance in zip(coins, initial_balances): + delimiter = 10 ** coin.decimals() + new_balance = coin.balanceOf(boa.env.eoa) + diff = new_balance - initial_balance + print(f"{coin.symbol()}: {initial_balance / delimiter:.2f} -> {new_balance / delimiter:.2f} " + f"({'+' if diff > 0 else ''}{diff / delimiter:.2f})") + diffs.append(diff) + print(f"Total: {diffs[1] / pool.price_oracle() + diffs[2] / 10 ** 18:.2f} crvUSD") + + +def simulate(idx=IDX, ransom=RANSOM): + print("Simulation") + salvation = deploy() + to = boa.env.eoa + if CRVUSD not in _contracts: + _contracts[CRVUSD] = boa.from_etherscan(CRVUSD, name="crvUSD", api_key=ETHERSCAN_API) + crvusd = _contracts[CRVUSD] + + for i in range(5): # ALTER: number of iterations + if _peg_keeper(idx).debt() < 10 ** 18: # Small amounts may fail due to fees + print("Peg Keeper is free") + break + + print(f"Iteration {i}") + with boa.env.prank("0xC9332fdCB1C491Dcc683bAe86Fe3cb70360738BC"): + balance = crvusd.balanceOf(to) + if balance < ransom: + crvusd.mint(to, ransom - balance) + + crvusd.approve(salvation, ransom) + + try: + buy_out(idx, ransom, salvation=salvation) + except Exception: + print("Could not buy out, will try the whole ransom") + buy_out(idx, ransom, salvation=salvation, use_all=True) + boa.env.time_travel(seconds=15 * 60) # PegKeeper:ACTION_DELAY + print() + + +def account_load(fname): + path = os.path.expanduser(os.path.join('~', '.brownie', 'accounts', fname + '.json')) + with open(path, 'r') as f: + pkey = account.decode_keyfile_json(json.load(f), getpass()) + return account.Account.from_key(pkey) + + +if __name__ == '__main__': + if '--fork' in sys.argv[1:]: + boa.env.fork(NETWORK) + + boa.env.eoa = '0xbabe61887f1de2713c6f97e567623453d3C79f67' + simulate() + else: + boa.set_env(NetworkEnv(NETWORK)) + boa.env.add_account(account_load('babe')) # ALTER: account to use + boa.env._fork_try_prefetch_state = False + buy_out()