diff --git a/.github/workflows/tbtc2.yaml b/.github/workflows/tbtc2.yaml new file mode 100644 index 00000000..74e897fa --- /dev/null +++ b/.github/workflows/tbtc2.yaml @@ -0,0 +1,59 @@ +name: tbtc2 + +on: + pull_request: + paths: + - 'tests/**/*.py' + - 'contracts/pools/tbtc2/**.vy' + push: + paths: + - 'tests/**/*.py' + - 'contracts/pools/tbtc2/**.vy' + + +env: + pool: 'tbtc2' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_OPTIONS: --max_old_space_size=4096 + + +jobs: + + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + target: [pools, zaps] + type: [unitary, integration] + + steps: + - uses: actions/checkout@v2 + + - name: Cache Compiler Installations + uses: actions/cache@v2 + with: + path: | + ~/.solcx + ~/.vvm + key: compiler-cache + + - name: Setup Node.js + uses: actions/setup-node@v1 + + - name: Install Ganache + run: npm install + + - name: Setup Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install Requirements + run: | + pip install wheel + pip install -r requirements.txt + + - name: Run Tests + run: brownie test tests/${{ matrix.target }} --pool ${{ env.pool }} --${{ matrix.type }} diff --git a/contracts/pools/README.md b/contracts/pools/README.md index addd6bce..8fc543d6 100644 --- a/contracts/pools/README.md +++ b/contracts/pools/README.md @@ -28,6 +28,7 @@ Each subdirectory holds contracts and other files specific to a single Curve poo * [`steth`](steth): [stETH pool](https://www.curve.fi/steth) * [`susd`](susd): [sUSD pool](https://www.curve.fi/susdv2) * [`tbtc`](tbtc): [tBTC metapool](https://www.curve.fi/tbtc) +* [`tbtc2`](tbtc2): [tBTC v2 metapool](https://www.curve.fi/tbtc2) * [`usdk`](usdk): [USDK metapool](https://www.curve.fi/usdk) * [`usdn`](usdn): [USDN metapool](https://www.curve.fi/usdn) * [`usdt`](usdt): [USDT pool](https://www.curve.fi/usdt), with lending on [Compound](https://compound.finance/) diff --git a/contracts/pools/tbtc2/DepositTBTC2.vy b/contracts/pools/tbtc2/DepositTBTC2.vy new file mode 100644 index 00000000..c4220f44 --- /dev/null +++ b/contracts/pools/tbtc2/DepositTBTC2.vy @@ -0,0 +1,366 @@ +# @version 0.2.7 +""" +@title "Zap" Depositer for Curve tBTCv2 pool +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2021 - all rights reserved +""" + +from vyper.interfaces import ERC20 + + +interface CurveMeta: + def add_liquidity(amounts: uint256[N_COINS], min_mint_amount: uint256) -> uint256: nonpayable + def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS]) -> uint256[N_COINS]: nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256) -> uint256: nonpayable + def remove_liquidity_imbalance(amounts: uint256[N_COINS], max_burn_amount: uint256) -> uint256: nonpayable + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def calc_token_amount(amounts: uint256[N_COINS], deposit: bool) -> uint256: view + def base_pool() -> address: view + def coins(i: uint256) -> address: view + +interface CurveBase: + def add_liquidity(amounts: uint256[BASE_N_COINS], min_mint_amount: uint256): nonpayable + def remove_liquidity(_amount: uint256, min_amounts: uint256[BASE_N_COINS]): nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256): nonpayable + def remove_liquidity_imbalance(amounts: uint256[BASE_N_COINS], max_burn_amount: uint256): nonpayable + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def calc_token_amount(amounts: uint256[BASE_N_COINS], deposit: bool) -> uint256: view + def coins(i: int128) -> address: view + def fee() -> uint256: view + + +N_COINS: constant(int128) = 2 +MAX_COIN: constant(int128) = N_COINS-1 +BASE_N_COINS: constant(int128) = 3 +N_ALL_COINS: constant(int128) = N_COINS + BASE_N_COINS - 1 + +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 +FEE_IMPRECISION: constant(uint256) = 100 * 10 ** 8 # % of the fee + + +pool: public(address) +token: public(address) +base_pool: public(address) + +coins: public(address[N_COINS]) +base_coins: public(address[BASE_N_COINS]) + + +@external +def __init__(_pool: address, _token: address): + """ + @notice Contract constructor + @param _pool Metapool address + @param _token Pool LP token address + """ + self.pool = _pool + self.token = _token + _base_pool: address = CurveMeta(_pool).base_pool() + self.base_pool = _base_pool + + for i in range(N_COINS): + coin: address = CurveMeta(_pool).coins(i) + self.coins[i] = coin + # approve coins for infinite transfers + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("approve(address,uint256)"), + convert(_pool, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + for i in range(BASE_N_COINS): + coin: address = CurveBase(_base_pool).coins(i) + self.base_coins[i] = coin + # approve underlying coins for infinite transfers + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("approve(address,uint256)"), + convert(_base_pool, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + +@external +def add_liquidity(amounts: uint256[N_ALL_COINS], min_mint_amount: uint256) -> uint256: + """ + @notice Wrap underlying coins and deposit them in the pool + @param amounts List of amounts of underlying coins to deposit + @param min_mint_amount Minimum amount of LP tokens to mint from the deposit + @return Amount of LP tokens received by depositing + """ + meta_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + base_amounts: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + deposit_base: bool = False + + # Transfer all coins in + for i in range(N_ALL_COINS): + amount: uint256 = amounts[i] + if amount == 0: + continue + coin: address = ZERO_ADDRESS + if i < MAX_COIN: + coin = self.coins[i] + meta_amounts[i] = amount + else: + x: int128 = i - MAX_COIN + coin = self.base_coins[x] + base_amounts[x] = amount + deposit_base = True + # "safeTransferFrom" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransferFrom" + + # Deposit to the base pool + if deposit_base: + CurveBase(self.base_pool).add_liquidity(base_amounts, 0) + meta_amounts[MAX_COIN] = ERC20(self.coins[MAX_COIN]).balanceOf(self) + + # Deposit to the meta pool + CurveMeta(self.pool).add_liquidity(meta_amounts, min_mint_amount) + + # Transfer meta token back + _lp_token: address = self.token + _lp_amount: uint256 = ERC20(_lp_token).balanceOf(self) + assert ERC20(_lp_token).transfer(msg.sender, _lp_amount) + + return _lp_amount + + +@external +def remove_liquidity(_amount: uint256, min_amounts: uint256[N_ALL_COINS]) -> uint256[N_ALL_COINS]: + """ + @notice Withdraw and unwrap coins from the pool + @dev Withdrawal amounts are based on current deposit ratios + @param _amount Quantity of LP tokens to burn in the withdrawal + @param min_amounts Minimum amounts of underlying coins to receive + @return List of amounts of underlying coins that were withdrawn + """ + _token: address = self.token + assert ERC20(_token).transferFrom(msg.sender, self, _amount) + + min_amounts_meta: uint256[N_COINS] = empty(uint256[N_COINS]) + min_amounts_base: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + amounts: uint256[N_ALL_COINS] = empty(uint256[N_ALL_COINS]) + + # Withdraw from meta + for i in range(MAX_COIN): + min_amounts_meta[i] = min_amounts[i] + CurveMeta(self.pool).remove_liquidity(_amount, min_amounts_meta) + + # Withdraw from base + _base_amount: uint256 = ERC20(self.coins[MAX_COIN]).balanceOf(self) + for i in range(BASE_N_COINS): + min_amounts_base[i] = min_amounts[MAX_COIN+i] + CurveBase(self.base_pool).remove_liquidity(_base_amount, min_amounts_base) + + # Transfer all coins out + for i in range(N_ALL_COINS): + coin: address = ZERO_ADDRESS + if i < MAX_COIN: + coin = self.coins[i] + else: + coin = self.base_coins[i - MAX_COIN] + amounts[i] = ERC20(coin).balanceOf(self) + # "safeTransfer" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(amounts[i], bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + return amounts + + +@external +def remove_liquidity_one_coin(_token_amount: uint256, i: int128, _min_amount: uint256) -> uint256: + """ + @notice Withdraw and unwrap a single coin from the pool + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @param _min_amount Minimum amount of underlying coin to receive + @return Amount of underlying coin received + """ + assert ERC20(self.token).transferFrom(msg.sender, self, _token_amount) + + coin: address = ZERO_ADDRESS + if i < MAX_COIN: + coin = self.coins[i] + # Withdraw a metapool coin + CurveMeta(self.pool).remove_liquidity_one_coin(_token_amount, i, _min_amount) + else: + coin = self.base_coins[i - MAX_COIN] + # Withdraw a base pool coin + CurveMeta(self.pool).remove_liquidity_one_coin(_token_amount, MAX_COIN, 0) + CurveBase(self.base_pool).remove_liquidity_one_coin( + ERC20(self.coins[MAX_COIN]).balanceOf(self), i-MAX_COIN, _min_amount + ) + + # Tranfer the coin out + coin_amount: uint256 = ERC20(coin).balanceOf(self) + # "safeTransfer" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(coin_amount, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + return coin_amount + + +@external +def remove_liquidity_imbalance(amounts: uint256[N_ALL_COINS], max_burn_amount: uint256) -> uint256: + """ + @notice Withdraw coins from the pool in an imbalanced amount + @param amounts List of amounts of underlying coins to withdraw + @param max_burn_amount Maximum amount of LP token to burn in the withdrawal. + This value cannot exceed the caller's LP token balance. + @return Actual amount of the LP token burned in the withdrawal + """ + _base_pool: address = self.base_pool + _meta_pool: address = self.pool + _base_coins: address[BASE_N_COINS] = self.base_coins + _meta_coins: address[N_COINS] = self.coins + _lp_token: address = self.token + + fee: uint256 = CurveBase(_base_pool).fee() * BASE_N_COINS / (4 * (BASE_N_COINS - 1)) + fee += fee * FEE_IMPRECISION / FEE_DENOMINATOR # Overcharge to account for imprecision + + # Transfer the LP token in + assert ERC20(_lp_token).transferFrom(msg.sender, self, max_burn_amount) + + withdraw_base: bool = False + amounts_base: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + amounts_meta: uint256[N_COINS] = empty(uint256[N_COINS]) + leftover_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + + # Prepare quantities + for i in range(MAX_COIN): + amounts_meta[i] = amounts[i] + + for i in range(BASE_N_COINS): + amount: uint256 = amounts[MAX_COIN + i] + if amount != 0: + amounts_base[i] = amount + withdraw_base = True + + if withdraw_base: + amounts_meta[MAX_COIN] = CurveBase(self.base_pool).calc_token_amount(amounts_base, False) + amounts_meta[MAX_COIN] += amounts_meta[MAX_COIN] * fee / FEE_DENOMINATOR + 1 + + # Remove liquidity and deposit leftovers back + CurveMeta(_meta_pool).remove_liquidity_imbalance(amounts_meta, max_burn_amount) + if withdraw_base: + CurveBase(_base_pool).remove_liquidity_imbalance(amounts_base, amounts_meta[MAX_COIN]) + leftover_amounts[MAX_COIN] = ERC20(_meta_coins[MAX_COIN]).balanceOf(self) + if leftover_amounts[MAX_COIN] > 0: + CurveMeta(_meta_pool).add_liquidity(leftover_amounts, 0) + + # Transfer all coins out + for i in range(N_ALL_COINS): + coin: address = ZERO_ADDRESS + amount: uint256 = 0 + if i < MAX_COIN: + coin = _meta_coins[i] + amount = amounts_meta[i] + else: + coin = _base_coins[i - MAX_COIN] + amount = amounts_base[i - MAX_COIN] + # "safeTransfer" which works for ERC20s which return bool or not + if amount > 0: + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + # Transfer the leftover LP token out + leftover: uint256 = ERC20(_lp_token).balanceOf(self) + if leftover > 0: + assert ERC20(_lp_token).transfer(msg.sender, leftover) + + return max_burn_amount - leftover + + +@view +@external +def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: + """ + @notice Calculate the amount received when withdrawing and unwrapping a single coin + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the underlying coin to withdraw + @return Amount of coin received + """ + if i < MAX_COIN: + return CurveMeta(self.pool).calc_withdraw_one_coin(_token_amount, i) + else: + _base_tokens: uint256 = CurveMeta(self.pool).calc_withdraw_one_coin(_token_amount, MAX_COIN) + return CurveBase(self.base_pool).calc_withdraw_one_coin(_base_tokens, i-MAX_COIN) + + +@view +@external +def calc_token_amount(amounts: uint256[N_ALL_COINS], is_deposit: bool) -> uint256: + """ + @notice Calculate addition or reduction in token supply from a deposit or withdrawal + @dev This calculation accounts for slippage, but not fees. + Needed to prevent front-running, not for precise calculations! + @param amounts Amount of each underlying coin being deposited + @param is_deposit set True for deposits, False for withdrawals + @return Expected amount of LP tokens received + """ + meta_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + base_amounts: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + + for i in range(MAX_COIN): + meta_amounts[i] = amounts[i] + + for i in range(BASE_N_COINS): + base_amounts[i] = amounts[i + MAX_COIN] + + _base_tokens: uint256 = CurveBase(self.base_pool).calc_token_amount(base_amounts, is_deposit) + meta_amounts[MAX_COIN] = _base_tokens + + return CurveMeta(self.pool).calc_token_amount(meta_amounts, is_deposit) diff --git a/contracts/pools/tbtc2/README.md b/contracts/pools/tbtc2/README.md new file mode 100644 index 00000000..df725c9d --- /dev/null +++ b/contracts/pools/tbtc2/README.md @@ -0,0 +1,34 @@ +# curve-contract/contracts/pools/tbtc2 + +[Curve tBTCv2 metapool](https://www.curve.fi/tbtc2), allowing swaps via the Curve [sBTC pool](../sbtc). + +## Contracts + +* [`DepositTBTC2`](DepositTBTC2.vy): Depositor contract, used to wrap underlying tokens prior to depositing them into the pool +* [`StableSwapTBTC2`](StableSwapTBTC2.vy): Curve stablecoin AMM contract + +## Deployments + +* [`CurveTokenV3`](../../tokens/CurveTokenV3.vy): [0xB6881e8b21a3cd6D23c4F90724E26e35BB8980bE](https://etherscan.io/address/0xB6881e8b21a3cd6D23c4F90724E26e35BB8980bE) +* [`DepositTBTC2`](DepositTBTC2.vy): [0x3264834ADA73a8b0B132ee52Fd5a367CF60E86C6](https://etherscan.io/address/0x3264834ADA73a8b0B132ee52Fd5a367CF60E86C6) +* [`LiquidityGaugeV3`](../../gauges/LiquidityGaugeV3.vy): [0xF816CFE922E03C2347664a4a61CAec409fcFF738](https://etherscan.io/address/0xF816CFE922E03C2347664a4a61CAec409fcFF738) +* [`StableSwapTBTC2`](StableSwapTBTC2.vy): [0x9e56512566236b8872b5798C8CB3a2b1a572A16C](https://etherscan.io/address/0x9e56512566236b8872b5798C8CB3a2b1a572A16C) + +## Stablecoins + +Curve tBTCv2 metapool utilizes the supports swaps between the following assets: + +## Direct swaps + +Direct swaps are possible between tBTCv2 and the Curve sBTC LP token. + +* `tBTCv2`: [0x18084fbA666a33d37592fA2633fD49a74DD93a88](https://etherscan.io/address/0x18084fbA666a33d37592fA2633fD49a74DD93a88) +* `sbtcCRV`: [0x075b1bb99792c9E1041bA13afEf80C91a1e70fB3](https://etherscan.io/address/0x075b1bb99792c9E1041bA13afEf80C91a1e70fB3) + +## Base Pool coins + +The sBTC LP token may be wrapped or unwrapped to provide swaps between tBTCv2 and the following coins: + +* `renBTC`: [0xeb4c2781e4eba804ce9a9803c67d0893436bb27d](https://etherscan.io/address/0xeb4c2781e4eba804ce9a9803c67d0893436bb27d) +* `wBTC`: [0x2260fac5e5542a773aa44fbcfedf7c193bc2c599](https://etherscan.io/address/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599) +* `sBTC`: [0xfe18be6b3bd88a2d2a7f928d00292e7a9963cfc6](https://etherscan.io/address/0xfe18be6b3bd88a2d2a7f928d00292e7a9963cfc6) diff --git a/contracts/pools/tbtc2/StableSwapTBTC2.vy b/contracts/pools/tbtc2/StableSwapTBTC2.vy new file mode 100644 index 00000000..4fca8988 --- /dev/null +++ b/contracts/pools/tbtc2/StableSwapTBTC2.vy @@ -0,0 +1,1078 @@ +# @version 0.2.7 +""" +@title Curve tBTCv2 Metapool +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2021 - all rights reserved +@dev Utilizes sBTC pool to allow swaps between tBTCv2 / rebBTC / wBTC / sBTC +""" + +from vyper.interfaces import ERC20 + +interface CurveToken: + def totalSupply() -> uint256: view + def mint(_to: address, _value: uint256) -> bool: nonpayable + def burnFrom(_to: address, _value: uint256) -> bool: nonpayable + +interface Curve: + def coins(i: int128) -> address: view + def get_virtual_price() -> uint256: view + def calc_token_amount(amounts: uint256[BASE_N_COINS], deposit: bool) -> uint256: view + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def fee() -> uint256: view + def get_dy(i: int128, j: int128, dx: uint256) -> uint256: view + def get_dy_underlying(i: int128, j: int128, dx: uint256) -> uint256: view + def exchange(i: int128, j: int128, dx: uint256, min_dy: uint256): nonpayable + def add_liquidity(amounts: uint256[BASE_N_COINS], min_mint_amount: uint256): nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256): nonpayable + + +# Events +event TokenExchange: + buyer: indexed(address) + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 + +event TokenExchangeUnderlying: + buyer: indexed(address) + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 + +event AddLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + invariant: uint256 + token_supply: uint256 + +event RemoveLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + token_supply: uint256 + +event RemoveLiquidityOne: + provider: indexed(address) + token_amount: uint256 + coin_amount: uint256 + token_supply: uint256 + +event RemoveLiquidityImbalance: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + invariant: uint256 + token_supply: uint256 + +event CommitNewAdmin: + deadline: indexed(uint256) + admin: indexed(address) + +event NewAdmin: + admin: indexed(address) + +event CommitNewFee: + deadline: indexed(uint256) + fee: uint256 + admin_fee: uint256 + +event NewFee: + fee: uint256 + admin_fee: uint256 + +event RampA: + old_A: uint256 + new_A: uint256 + initial_time: uint256 + future_time: uint256 + +event StopRampA: + A: uint256 + t: uint256 + + +N_COINS: constant(int128) = 2 +MAX_COIN: constant(int128) = N_COINS - 1 + +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 +PRECISION: constant(uint256) = 10 ** 18 # The precision to convert to +PRECISION_MUL: constant(uint256[N_COINS]) = [1, 1] +RATES: constant(uint256[N_COINS]) = [1000000000000000000, 1000000000000000000] +BASE_N_COINS: constant(int128) = 3 + +# An asset which may have a transfer fee (renBTC) +FEE_ASSET: constant(address) = 0xEB4C2781e4ebA804CE9a9803C67d0893436bB27D + +MAX_ADMIN_FEE: constant(uint256) = 10 * 10 ** 9 +MAX_FEE: constant(uint256) = 5 * 10 ** 9 +MAX_A: constant(uint256) = 10 ** 6 +MAX_A_CHANGE: constant(uint256) = 10 + +ADMIN_ACTIONS_DELAY: constant(uint256) = 3 * 86400 +MIN_RAMP_TIME: constant(uint256) = 86400 + +coins: public(address[N_COINS]) +balances: public(uint256[N_COINS]) +fee: public(uint256) # fee * 1e10 +admin_fee: public(uint256) # admin_fee * 1e10 + +owner: public(address) +token: public(CurveToken) + +# Token corresponding to the pool is always the last one +BASE_POOL_COINS: constant(int128) = 3 +BASE_CACHE_EXPIRES: constant(int128) = 10 * 60 # 10 min +base_pool: public(address) +base_virtual_price: public(uint256) +base_cache_updated: public(uint256) +base_coins: public(address[BASE_POOL_COINS]) + +A_PRECISION: constant(uint256) = 100 +initial_A: public(uint256) +future_A: public(uint256) +initial_A_time: public(uint256) +future_A_time: public(uint256) + +admin_actions_deadline: public(uint256) +transfer_ownership_deadline: public(uint256) +future_fee: public(uint256) +future_admin_fee: public(uint256) +future_owner: public(address) + +is_killed: bool +kill_deadline: uint256 +KILL_DEADLINE_DT: constant(uint256) = 2 * 30 * 86400 + + +@external +def __init__( + _owner: address, + _coins: address[N_COINS], + _pool_token: address, + _base_pool: address, + _A: uint256, + _fee: uint256, + _admin_fee: uint256 +): + """ + @notice Contract constructor + @param _owner Contract owner address + @param _coins Addresses of ERC20 conracts of coins + @param _pool_token Address of the token representing LP share + @param _base_pool Address of the base pool (which will have a virtual price) + @param _A Amplification coefficient multiplied by n * (n - 1) + @param _fee Fee to charge for exchanges + @param _admin_fee Admin fee + """ + for i in range(N_COINS): + assert _coins[i] != ZERO_ADDRESS + self.coins = _coins + self.initial_A = _A * A_PRECISION + self.future_A = _A * A_PRECISION + self.fee = _fee + self.admin_fee = _admin_fee + self.owner = _owner + self.kill_deadline = block.timestamp + KILL_DEADLINE_DT + self.token = CurveToken(_pool_token) + + self.base_pool = _base_pool + self.base_virtual_price = Curve(_base_pool).get_virtual_price() + self.base_cache_updated = block.timestamp + for i in range(BASE_POOL_COINS): + _base_coin: address = Curve(_base_pool).coins(i) + self.base_coins[i] = _base_coin + + # approve underlying coins for infinite transfers + _response: Bytes[32] = raw_call( + _base_coin, + concat( + method_id("approve(address,uint256)"), + convert(_base_pool, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + +@view +@internal +def _A() -> uint256: + """ + Handle ramping A up or down + """ + t1: uint256 = self.future_A_time + A1: uint256 = self.future_A + + if block.timestamp < t1: + A0: uint256 = self.initial_A + t0: uint256 = self.initial_A_time + # Expressions in uint256 cannot have negative numbers, thus "if" + if A1 > A0: + return A0 + (A1 - A0) * (block.timestamp - t0) / (t1 - t0) + else: + return A0 - (A0 - A1) * (block.timestamp - t0) / (t1 - t0) + + else: # when t1 == 0 or block.timestamp >= t1 + return A1 + + +@view +@external +def A() -> uint256: + return self._A() / A_PRECISION + + +@view +@external +def A_precise() -> uint256: + return self._A() + + +@view +@internal +def _xp(vp_rate: uint256) -> uint256[N_COINS]: + result: uint256[N_COINS] = RATES + result[MAX_COIN] = vp_rate # virtual price for the metacurrency + for i in range(N_COINS): + result[i] = result[i] * self.balances[i] / PRECISION + return result + + +@pure +@internal +def _xp_mem(vp_rate: uint256, _balances: uint256[N_COINS]) -> uint256[N_COINS]: + result: uint256[N_COINS] = RATES + result[MAX_COIN] = vp_rate # virtual price for the metacurrency + for i in range(N_COINS): + result[i] = result[i] * _balances[i] / PRECISION + return result + + +@internal +def _vp_rate() -> uint256: + if block.timestamp > self.base_cache_updated + BASE_CACHE_EXPIRES: + vprice: uint256 = Curve(self.base_pool).get_virtual_price() + self.base_virtual_price = vprice + self.base_cache_updated = block.timestamp + return vprice + else: + return self.base_virtual_price + + +@internal +@view +def _vp_rate_ro() -> uint256: + if block.timestamp > self.base_cache_updated + BASE_CACHE_EXPIRES: + return Curve(self.base_pool).get_virtual_price() + else: + return self.base_virtual_price + + +@pure +@internal +def get_D(xp: uint256[N_COINS], amp: uint256) -> uint256: + S: uint256 = 0 + Dprev: uint256 = 0 + for _x in xp: + S += _x + if S == 0: + return 0 + + D: uint256 = S + Ann: uint256 = amp * N_COINS + for _i in range(255): + D_P: uint256 = D + for _x in xp: + D_P = D_P * D / (_x * N_COINS) # If division by 0, this will be borked: only withdrawal will work. And that is good + Dprev = D + D = (Ann * S / A_PRECISION + D_P * N_COINS) * D / ((Ann - A_PRECISION) * D / A_PRECISION + (N_COINS + 1) * D_P) + # Equality with the precision of 1 + if D > Dprev: + if D - Dprev <= 1: + break + else: + if Dprev - D <= 1: + break + return D + + +@view +@internal +def get_D_mem(vp_rate: uint256, _balances: uint256[N_COINS], amp: uint256) -> uint256: + xp: uint256[N_COINS] = self._xp_mem(vp_rate, _balances) + return self.get_D(xp, amp) + + +@view +@external +def get_virtual_price() -> uint256: + """ + @notice The current virtual price of the pool LP token + @dev Useful for calculating profits + @return LP token virtual price normalized to 1e18 + """ + amp: uint256 = self._A() + vp_rate: uint256 = self._vp_rate_ro() + xp: uint256[N_COINS] = self._xp(vp_rate) + D: uint256 = self.get_D(xp, amp) + # D is in the units similar to DAI (e.g. converted to precision 1e18) + # When balanced, D = n * x_u - total virtual value of the portfolio + token_supply: uint256 = self.token.totalSupply() + return D * PRECISION / token_supply + + +@view +@external +def calc_token_amount(amounts: uint256[N_COINS], is_deposit: bool) -> uint256: + """ + @notice Calculate addition or reduction in token supply from a deposit or withdrawal + @dev This calculation accounts for slippage, but not fees. + Needed to prevent front-running, not for precise calculations! + @param amounts Amount of each coin being deposited + @param is_deposit set True for deposits, False for withdrawals + @return Expected amount of LP tokens received + """ + amp: uint256 = self._A() + vp_rate: uint256 = self._vp_rate_ro() + _balances: uint256[N_COINS] = self.balances + D0: uint256 = self.get_D_mem(vp_rate, _balances, amp) + for i in range(N_COINS): + if is_deposit: + _balances[i] += amounts[i] + else: + _balances[i] -= amounts[i] + D1: uint256 = self.get_D_mem(vp_rate, _balances, amp) + token_amount: uint256 = self.token.totalSupply() + diff: uint256 = 0 + if is_deposit: + diff = D1 - D0 + else: + diff = D0 - D1 + return diff * token_amount / D0 + + +@external +@nonreentrant('lock') +def add_liquidity(amounts: uint256[N_COINS], min_mint_amount: uint256) -> uint256: + """ + @notice Deposit coins into the pool + @param amounts List of amounts of coins to deposit + @param min_mint_amount Minimum amount of LP tokens to mint from the deposit + @return Amount of LP tokens received by depositing + """ + assert not self.is_killed # dev: is killed + + amp: uint256 = self._A() + vp_rate: uint256 = self._vp_rate() + token_supply: uint256 = self.token.totalSupply() + _fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + _admin_fee: uint256 = self.admin_fee + + # Initial invariant + D0: uint256 = 0 + old_balances: uint256[N_COINS] = self.balances + if token_supply > 0: + D0 = self.get_D_mem(vp_rate, old_balances, amp) + new_balances: uint256[N_COINS] = old_balances + + for i in range(N_COINS): + if token_supply == 0: + assert amounts[i] > 0 # dev: initial deposit requires all coins + # balances store amounts of c-tokens + new_balances[i] = old_balances[i] + amounts[i] + + # Invariant after change + D1: uint256 = self.get_D_mem(vp_rate, new_balances, amp) + assert D1 > D0 + + # We need to recalculate the invariant accounting for fees + # to calculate fair user's share + fees: uint256[N_COINS] = empty(uint256[N_COINS]) + D2: uint256 = D1 + if token_supply > 0: + # Only account for fees if we are not the first to deposit + for i in range(N_COINS): + ideal_balance: uint256 = D1 * old_balances[i] / D0 + difference: uint256 = 0 + if ideal_balance > new_balances[i]: + difference = ideal_balance - new_balances[i] + else: + difference = new_balances[i] - ideal_balance + fees[i] = _fee * difference / FEE_DENOMINATOR + self.balances[i] = new_balances[i] - (fees[i] * _admin_fee / FEE_DENOMINATOR) + new_balances[i] -= fees[i] + D2 = self.get_D_mem(vp_rate, new_balances, amp) + else: + self.balances = new_balances + + # Calculate, how much pool tokens to mint + mint_amount: uint256 = 0 + if token_supply == 0: + mint_amount = D1 # Take the dust if there was any + else: + mint_amount = token_supply * (D2 - D0) / D0 + + assert mint_amount >= min_mint_amount, "Slippage screwed you" + + # Take coins from the sender + for i in range(N_COINS): + if amounts[i] > 0: + assert ERC20(self.coins[i]).transferFrom(msg.sender, self, amounts[i]) # dev: failed transfer + + # Mint pool tokens + self.token.mint(msg.sender, mint_amount) + + log AddLiquidity(msg.sender, amounts, fees, D1, token_supply + mint_amount) + + return mint_amount + + +@view +@internal +def get_y(i: int128, j: int128, x: uint256, xp_: uint256[N_COINS]) -> uint256: + # x in the input is converted to the same price/precision + + assert i != j # dev: same coin + assert j >= 0 # dev: j below zero + assert j < N_COINS # dev: j above N_COINS + + # should be unreachable, but good for safety + assert i >= 0 + assert i < N_COINS + + amp: uint256 = self._A() + D: uint256 = self.get_D(xp_, amp) + + S_: uint256 = 0 + _x: uint256 = 0 + y_prev: uint256 = 0 + c: uint256 = D + Ann: uint256 = amp * N_COINS + + for _i in range(N_COINS): + if _i == i: + _x = x + elif _i != j: + _x = xp_[_i] + else: + continue + S_ += _x + c = c * D / (_x * N_COINS) + c = c * D * A_PRECISION / (Ann * N_COINS) + b: uint256 = S_ + D * A_PRECISION / Ann # - D + y: uint256 = D + for _i in range(255): + y_prev = y + y = (y*y + c) / (2 * y + b - D) + # Equality with the precision of 1 + if y > y_prev: + if y - y_prev <= 1: + break + else: + if y_prev - y <= 1: + break + return y + + +@view +@external +def get_dy(i: int128, j: int128, dx: uint256) -> uint256: + # dx and dy in c-units + rates: uint256[N_COINS] = RATES + rates[MAX_COIN] = self._vp_rate_ro() + xp: uint256[N_COINS] = self._xp(rates[MAX_COIN]) + + x: uint256 = xp[i] + (dx * rates[i] / PRECISION) + y: uint256 = self.get_y(i, j, x, xp) + dy: uint256 = xp[j] - y - 1 + _fee: uint256 = self.fee * dy / FEE_DENOMINATOR + return (dy - _fee) * PRECISION / rates[j] + + +@view +@external +def get_dy_underlying(i: int128, j: int128, dx: uint256) -> uint256: + # dx and dy in underlying units + vp_rate: uint256 = self._vp_rate_ro() + xp: uint256[N_COINS] = self._xp(vp_rate) + precisions: uint256[N_COINS] = PRECISION_MUL + _base_pool: address = self.base_pool + + # Use base_i or base_j if they are >= 0 + base_i: int128 = i - MAX_COIN + base_j: int128 = j - MAX_COIN + meta_i: int128 = MAX_COIN + meta_j: int128 = MAX_COIN + if base_i < 0: + meta_i = i + if base_j < 0: + meta_j = j + + x: uint256 = 0 + if base_i < 0: + x = xp[i] + dx * precisions[i] + else: + if base_j < 0: + # i is from BasePool + # At first, get the amount of pool tokens + base_inputs: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + base_inputs[base_i] = dx + # Token amount transformed to underlying "dollars" + x = Curve(_base_pool).calc_token_amount(base_inputs, True) * vp_rate / PRECISION + # Accounting for deposit/withdraw fees approximately + x -= x * Curve(_base_pool).fee() / (2 * FEE_DENOMINATOR) + # Adding number of pool tokens + x += xp[MAX_COIN] + else: + # If both are from the base pool + return Curve(_base_pool).get_dy(base_i, base_j, dx) + + # This pool is involved only when in-pool assets are used + y: uint256 = self.get_y(meta_i, meta_j, x, xp) + dy: uint256 = xp[meta_j] - y - 1 + dy = (dy - self.fee * dy / FEE_DENOMINATOR) + + # If output is going via the metapool + if base_j < 0: + dy /= precisions[meta_j] + else: + # j is from BasePool + # The fee is already accounted for + dy = Curve(_base_pool).calc_withdraw_one_coin(dy * PRECISION / vp_rate, base_j) + + return dy + + +@external +@nonreentrant('lock') +def exchange(i: int128, j: int128, dx: uint256, min_dy: uint256) -> uint256: + """ + @notice Perform an exchange between two coins + @dev Index values can be found via the `coins` public getter method + @param i Index value for the coin to send + @param j Index valie of the coin to recieve + @param dx Amount of `i` being exchanged + @param min_dy Minimum amount of `j` to receive + @return Actual amount of `j` received + """ + assert not self.is_killed # dev: is killed + rates: uint256[N_COINS] = RATES + rates[MAX_COIN] = self._vp_rate() + + old_balances: uint256[N_COINS] = self.balances + xp: uint256[N_COINS] = self._xp_mem(rates[MAX_COIN], old_balances) + + x: uint256 = xp[i] + dx * rates[i] / PRECISION + y: uint256 = self.get_y(i, j, x, xp) + + dy: uint256 = xp[j] - y - 1 # -1 just in case there were some rounding errors + dy_fee: uint256 = dy * self.fee / FEE_DENOMINATOR + + # Convert all to real units + dy = (dy - dy_fee) * PRECISION / rates[j] + assert dy >= min_dy, "Too few coins in result" + + dy_admin_fee: uint256 = dy_fee * self.admin_fee / FEE_DENOMINATOR + dy_admin_fee = dy_admin_fee * PRECISION / rates[j] + + # Change balances exactly in same way as we change actual ERC20 coin amounts + self.balances[i] = old_balances[i] + dx + # When rounding errors happen, we undercharge admin fee in favor of LP + self.balances[j] = old_balances[j] - dy - dy_admin_fee + + assert ERC20(self.coins[i]).transferFrom(msg.sender, self, dx) + assert ERC20(self.coins[j]).transfer(msg.sender, dy) + + log TokenExchange(msg.sender, i, dx, j, dy) + + return dy + + +@external +@nonreentrant('lock') +def exchange_underlying(i: int128, j: int128, dx: uint256, min_dy: uint256) -> uint256: + """ + @notice Perform an exchange between two underlying coins + @dev Index values can be found via the `underlying_coins` public getter method + @param i Index value for the underlying coin to send + @param j Index valie of the underlying coin to recieve + @param dx Amount of `i` being exchanged + @param min_dy Minimum amount of `j` to receive + @return Actual amount of `j` received + """ + assert not self.is_killed # dev: is killed + rates: uint256[N_COINS] = RATES + rates[MAX_COIN] = self._vp_rate() + _base_pool: address = self.base_pool + + # Use base_i or base_j if they are >= 0 + base_i: int128 = i - MAX_COIN + base_j: int128 = j - MAX_COIN + meta_i: int128 = MAX_COIN + meta_j: int128 = MAX_COIN + if base_i < 0: + meta_i = i + if base_j < 0: + meta_j = j + dy: uint256 = 0 + + # Addresses for input and output coins + input_coin: address = ZERO_ADDRESS + if base_i < 0: + input_coin = self.coins[i] + else: + input_coin = self.base_coins[base_i] + output_coin: address = ZERO_ADDRESS + if base_j < 0: + output_coin = self.coins[j] + else: + output_coin = self.base_coins[base_j] + + # Handle potential Tether fees + dx_w_fee: uint256 = dx + if input_coin == FEE_ASSET: + dx_w_fee = ERC20(FEE_ASSET).balanceOf(self) + # "safeTransferFrom" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + input_coin, + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(dx, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransferFrom" + # Handle potential Tether fees + if input_coin == FEE_ASSET: + dx_w_fee = ERC20(FEE_ASSET).balanceOf(self) - dx_w_fee + + if base_i < 0 or base_j < 0: + old_balances: uint256[N_COINS] = self.balances + xp: uint256[N_COINS] = self._xp_mem(rates[MAX_COIN], old_balances) + + x: uint256 = 0 + if base_i < 0: + x = xp[i] + dx_w_fee * rates[i] / PRECISION + else: + # i is from BasePool + # At first, get the amount of pool tokens + base_inputs: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + base_inputs[base_i] = dx_w_fee + coin_i: address = self.coins[MAX_COIN] + # Deposit and measure delta + x = ERC20(coin_i).balanceOf(self) + Curve(_base_pool).add_liquidity(base_inputs, 0) + # Need to convert pool token to "virtual" units using rates + # dx is also different now + dx_w_fee = ERC20(coin_i).balanceOf(self) - x + x = dx_w_fee * rates[MAX_COIN] / PRECISION + # Adding number of pool tokens + x += xp[MAX_COIN] + + y: uint256 = self.get_y(meta_i, meta_j, x, xp) + + # Either a real coin or token + dy = xp[meta_j] - y - 1 # -1 just in case there were some rounding errors + dy_fee: uint256 = dy * self.fee / FEE_DENOMINATOR + + # Convert all to real units + # Works for both pool coins and real coins + dy = (dy - dy_fee) * PRECISION / rates[meta_j] + + dy_admin_fee: uint256 = dy_fee * self.admin_fee / FEE_DENOMINATOR + dy_admin_fee = dy_admin_fee * PRECISION / rates[meta_j] + + # Change balances exactly in same way as we change actual ERC20 coin amounts + self.balances[meta_i] = old_balances[meta_i] + dx_w_fee + # When rounding errors happen, we undercharge admin fee in favor of LP + self.balances[meta_j] = old_balances[meta_j] - dy - dy_admin_fee + + # Withdraw from the base pool if needed + if base_j >= 0: + out_amount: uint256 = ERC20(output_coin).balanceOf(self) + Curve(_base_pool).remove_liquidity_one_coin(dy, base_j, 0) + dy = ERC20(output_coin).balanceOf(self) - out_amount + + assert dy >= min_dy, "Too few coins in result" + + else: + # If both are from the base pool + dy = ERC20(output_coin).balanceOf(self) + Curve(_base_pool).exchange(base_i, base_j, dx_w_fee, min_dy) + dy = ERC20(output_coin).balanceOf(self) - dy + + # "safeTransfer" which works for ERC20s which return bool or not + _response = raw_call( + output_coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(dy, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + log TokenExchangeUnderlying(msg.sender, i, dx, j, dy) + + return dy + + +@external +@nonreentrant('lock') +def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS]) -> uint256[N_COINS]: + """ + @notice Withdraw coins from the pool + @dev Withdrawal amounts are based on current deposit ratios + @param _amount Quantity of LP tokens to burn in the withdrawal + @param min_amounts Minimum amounts of underlying coins to receive + @return List of amounts of coins that were withdrawn + """ + total_supply: uint256 = self.token.totalSupply() + amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + fees: uint256[N_COINS] = empty(uint256[N_COINS]) # Fees are unused but we've got them historically in event + + for i in range(N_COINS): + value: uint256 = self.balances[i] * _amount / total_supply + assert value >= min_amounts[i], "Too few coins in result" + self.balances[i] -= value + amounts[i] = value + assert ERC20(self.coins[i]).transfer(msg.sender, value) + + self.token.burnFrom(msg.sender, _amount) # dev: insufficient funds + + log RemoveLiquidity(msg.sender, amounts, fees, total_supply - _amount) + + return amounts + + +@external +@nonreentrant('lock') +def remove_liquidity_imbalance(amounts: uint256[N_COINS], max_burn_amount: uint256) -> uint256: + """ + @notice Withdraw coins from the pool in an imbalanced amount + @param amounts List of amounts of underlying coins to withdraw + @param max_burn_amount Maximum amount of LP token to burn in the withdrawal + @return Actual amount of the LP token burned in the withdrawal + """ + assert not self.is_killed # dev: is killed + + amp: uint256 = self._A() + vp_rate: uint256 = self._vp_rate() + + token_supply: uint256 = self.token.totalSupply() + assert token_supply != 0 # dev: zero total supply + _fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + _admin_fee: uint256 = self.admin_fee + + old_balances: uint256[N_COINS] = self.balances + new_balances: uint256[N_COINS] = old_balances + D0: uint256 = self.get_D_mem(vp_rate, old_balances, amp) + for i in range(N_COINS): + new_balances[i] -= amounts[i] + D1: uint256 = self.get_D_mem(vp_rate, new_balances, amp) + + fees: uint256[N_COINS] = empty(uint256[N_COINS]) + for i in range(N_COINS): + ideal_balance: uint256 = D1 * old_balances[i] / D0 + difference: uint256 = 0 + if ideal_balance > new_balances[i]: + difference = ideal_balance - new_balances[i] + else: + difference = new_balances[i] - ideal_balance + fees[i] = _fee * difference / FEE_DENOMINATOR + self.balances[i] = new_balances[i] - (fees[i] * _admin_fee / FEE_DENOMINATOR) + new_balances[i] -= fees[i] + D2: uint256 = self.get_D_mem(vp_rate, new_balances, amp) + + token_amount: uint256 = (D0 - D2) * token_supply / D0 + assert token_amount != 0 # dev: zero tokens burned + token_amount += 1 # In case of rounding errors - make it unfavorable for the "attacker" + assert token_amount <= max_burn_amount, "Slippage screwed you" + + self.token.burnFrom(msg.sender, token_amount) # dev: insufficient funds + for i in range(N_COINS): + if amounts[i] != 0: + assert ERC20(self.coins[i]).transfer(msg.sender, amounts[i]) + + log RemoveLiquidityImbalance(msg.sender, amounts, fees, D1, token_supply - token_amount) + + return token_amount + + +@view +@internal +def get_y_D(A_: uint256, i: int128, xp: uint256[N_COINS], D: uint256) -> uint256: + """ + Calculate x[i] if one reduces D from being calculated for xp to D + + Done by solving quadratic equation iteratively. + x_1**2 + x1 * (sum' - (A*n**n - 1) * D / (A * n**n)) = D ** (n + 1) / (n ** (2 * n) * prod' * A) + x_1**2 + b*x_1 = c + + x_1 = (x_1**2 + c) / (2*x_1 + b) + """ + # x in the input is converted to the same price/precision + + assert i >= 0 # dev: i below zero + assert i < N_COINS # dev: i above N_COINS + + S_: uint256 = 0 + _x: uint256 = 0 + y_prev: uint256 = 0 + + c: uint256 = D + Ann: uint256 = A_ * N_COINS + + for _i in range(N_COINS): + if _i != i: + _x = xp[_i] + else: + continue + S_ += _x + c = c * D / (_x * N_COINS) + c = c * D * A_PRECISION / (Ann * N_COINS) + b: uint256 = S_ + D * A_PRECISION / Ann + y: uint256 = D + for _i in range(255): + y_prev = y + y = (y*y + c) / (2 * y + b - D) + # Equality with the precision of 1 + if y > y_prev: + if y - y_prev <= 1: + break + else: + if y_prev - y <= 1: + break + return y + + +@view +@internal +def _calc_withdraw_one_coin(_token_amount: uint256, i: int128, vp_rate: uint256) -> (uint256, uint256, uint256): + # First, need to calculate + # * Get current D + # * Solve Eqn against y_i for D - _token_amount + amp: uint256 = self._A() + xp: uint256[N_COINS] = self._xp(vp_rate) + D0: uint256 = self.get_D(xp, amp) + + total_supply: uint256 = self.token.totalSupply() + D1: uint256 = D0 - _token_amount * D0 / total_supply + new_y: uint256 = self.get_y_D(amp, i, xp, D1) + + _fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + rates: uint256[N_COINS] = RATES + rates[MAX_COIN] = vp_rate + + xp_reduced: uint256[N_COINS] = xp + dy_0: uint256 = (xp[i] - new_y) * PRECISION / rates[i] # w/o fees + + for j in range(N_COINS): + dx_expected: uint256 = 0 + if j == i: + dx_expected = xp[j] * D1 / D0 - new_y + else: + dx_expected = xp[j] - xp[j] * D1 / D0 + xp_reduced[j] -= _fee * dx_expected / FEE_DENOMINATOR + + dy: uint256 = xp_reduced[i] - self.get_y_D(amp, i, xp_reduced, D1) + dy = (dy - 1) * PRECISION / rates[i] # Withdraw less to account for rounding errors + + return dy, dy_0 - dy, total_supply + + +@view +@external +def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: + """ + @notice Calculate the amount received when withdrawing a single coin + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @return Amount of coin received + """ + vp_rate: uint256 = self._vp_rate_ro() + return self._calc_withdraw_one_coin(_token_amount, i, vp_rate)[0] + + +@external +@nonreentrant('lock') +def remove_liquidity_one_coin(_token_amount: uint256, i: int128, _min_amount: uint256) -> uint256: + """ + @notice Withdraw a single coin from the pool + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @param _min_amount Minimum amount of coin to receive + @return Amount of coin received + """ + assert not self.is_killed # dev: is killed + + vp_rate: uint256 = self._vp_rate() + dy: uint256 = 0 + dy_fee: uint256 = 0 + total_supply: uint256 = 0 + dy, dy_fee, total_supply = self._calc_withdraw_one_coin(_token_amount, i, vp_rate) + assert dy >= _min_amount, "Not enough coins removed" + + self.balances[i] -= (dy + dy_fee * self.admin_fee / FEE_DENOMINATOR) + self.token.burnFrom(msg.sender, _token_amount) # dev: insufficient funds + assert ERC20(self.coins[i]).transfer(msg.sender, dy) + + log RemoveLiquidityOne(msg.sender, _token_amount, dy, total_supply - _token_amount) + + return dy + + +### Admin functions ### +@external +def ramp_A(_future_A: uint256, _future_time: uint256): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.initial_A_time + MIN_RAMP_TIME + assert _future_time >= block.timestamp + MIN_RAMP_TIME # dev: insufficient time + + _initial_A: uint256 = self._A() + _future_A_p: uint256 = _future_A * A_PRECISION + + assert _future_A > 0 and _future_A < MAX_A + if _future_A_p < _initial_A: + assert _future_A_p * MAX_A_CHANGE >= _initial_A + else: + assert _future_A_p <= _initial_A * MAX_A_CHANGE + + self.initial_A = _initial_A + self.future_A = _future_A_p + self.initial_A_time = block.timestamp + self.future_A_time = _future_time + + log RampA(_initial_A, _future_A_p, block.timestamp, _future_time) + + +@external +def stop_ramp_A(): + assert msg.sender == self.owner # dev: only owner + + current_A: uint256 = self._A() + self.initial_A = current_A + self.future_A = current_A + self.initial_A_time = block.timestamp + self.future_A_time = block.timestamp + # now (block.timestamp < t1) is always False, so we return saved A + + log StopRampA(current_A, block.timestamp) + + +@external +def commit_new_fee(new_fee: uint256, new_admin_fee: uint256): + assert msg.sender == self.owner # dev: only owner + assert self.admin_actions_deadline == 0 # dev: active action + assert new_fee <= MAX_FEE # dev: fee exceeds maximum + assert new_admin_fee <= MAX_ADMIN_FEE # dev: admin fee exceeds maximum + + _deadline: uint256 = block.timestamp + ADMIN_ACTIONS_DELAY + self.admin_actions_deadline = _deadline + self.future_fee = new_fee + self.future_admin_fee = new_admin_fee + + log CommitNewFee(_deadline, new_fee, new_admin_fee) + + +@external +def apply_new_fee(): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.admin_actions_deadline # dev: insufficient time + assert self.admin_actions_deadline != 0 # dev: no active action + + self.admin_actions_deadline = 0 + _fee: uint256 = self.future_fee + _admin_fee: uint256 = self.future_admin_fee + self.fee = _fee + self.admin_fee = _admin_fee + + log NewFee(_fee, _admin_fee) + + +@external +def revert_new_parameters(): + assert msg.sender == self.owner # dev: only owner + + self.admin_actions_deadline = 0 + + +@external +def commit_transfer_ownership(_owner: address): + assert msg.sender == self.owner # dev: only owner + assert self.transfer_ownership_deadline == 0 # dev: active transfer + + _deadline: uint256 = block.timestamp + ADMIN_ACTIONS_DELAY + self.transfer_ownership_deadline = _deadline + self.future_owner = _owner + + log CommitNewAdmin(_deadline, _owner) + + +@external +def apply_transfer_ownership(): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.transfer_ownership_deadline # dev: insufficient time + assert self.transfer_ownership_deadline != 0 # dev: no active transfer + + self.transfer_ownership_deadline = 0 + _owner: address = self.future_owner + self.owner = _owner + + log NewAdmin(_owner) + + +@external +def revert_transfer_ownership(): + assert msg.sender == self.owner # dev: only owner + + self.transfer_ownership_deadline = 0 + + +@view +@external +def admin_balances(i: uint256) -> uint256: + return ERC20(self.coins[i]).balanceOf(self) - self.balances[i] + + +@external +def withdraw_admin_fees(): + assert msg.sender == self.owner # dev: only owner + + for i in range(N_COINS): + c: address = self.coins[i] + value: uint256 = ERC20(c).balanceOf(self) - self.balances[i] + if value > 0: + assert ERC20(c).transfer(msg.sender, value) + + +@external +def donate_admin_fees(): + assert msg.sender == self.owner # dev: only owner + for i in range(N_COINS): + self.balances[i] = ERC20(self.coins[i]).balanceOf(self) + + +@external +def kill_me(): + assert msg.sender == self.owner # dev: only owner + assert self.kill_deadline > block.timestamp # dev: deadline has passed + self.is_killed = True + + +@external +def unkill_me(): + assert msg.sender == self.owner # dev: only owner + self.is_killed = False diff --git a/contracts/pools/tbtc2/pooldata.json b/contracts/pools/tbtc2/pooldata.json new file mode 100644 index 00000000..3ad4e977 --- /dev/null +++ b/contracts/pools/tbtc2/pooldata.json @@ -0,0 +1,37 @@ +{ + "base_pool": "sbtc", + "pool_types": ["meta"], + "swap_address": "0x9e56512566236b8872b5798C8CB3a2b1a572A16C", + "lp_token_address": "0xB6881e8b21a3cd6D23c4F90724E26e35BB8980bE", + "zap_address": "0x3264834ADA73a8b0B132ee52Fd5a367CF60E86C6", + "gauge_addresses": ["0xF816CFE922E03C2347664a4a61CAec409fcFF738"], + "lp_contract": "CurveTokenV3", + "lp_constructor": { + "symbol": "tBTCv2/sbtcCRV", + "name": "Curve.fi tBTCv2/sbtcCRV" + }, + "swap_constructor": { + "_A": 200, + "_fee": 4000000, + "_admin_fee": 0 + }, + "coins": [ + { + "name": "tBTCv2", + "decimals": 18, + "tethered": false, + "wrapped": false, + "underlying_address": "0x18084fbA666a33d37592fA2633fD49a74DD93a88" + }, + { + "name": "sbtcCRV", + "decimals": 18, + "wrapped": false, + "base_pool_token": true, + "underlying_address": "0x075b1bb99792c9E1041bA13afEf80C91a1e70fB3" + } + ], + "testing": { + "initial_amount": 100 + } +} diff --git a/tests/pools/meta/test_exchange_with_rate.py b/tests/pools/meta/test_exchange_with_rate.py index 10017012..8aa50b1c 100644 --- a/tests/pools/meta/test_exchange_with_rate.py +++ b/tests/pools/meta/test_exchange_with_rate.py @@ -3,7 +3,7 @@ pytestmark = pytest.mark.usefixtures("add_initial_liquidity", "approve_bob") -@pytest.mark.skip_pool("bbtc", "tbtc", "obtc", "pbtc", "template-meta") +@pytest.mark.skip_pool("bbtc", "tbtc", "tbtc2", "obtc", "pbtc", "template-meta") @pytest.mark.itercoins("sending", "receiving") def test_exchange_with_virtual_price( alice,