From fc8cdf2dec0deb7853f41523344f8d5fbd2a5098 Mon Sep 17 00:00:00 2001 From: 0xkorin <0xkorin@proton.me> Date: Mon, 15 Jul 2024 17:21:48 +0400 Subject: [PATCH] Add strategy deposit facility --- contracts/StrategyDepositFacility.vy | 247 ++++++++++++++++++++++++ tests/test_strategy_deposit_facility.py | 200 +++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 contracts/StrategyDepositFacility.vy create mode 100644 tests/test_strategy_deposit_facility.py diff --git a/contracts/StrategyDepositFacility.vy b/contracts/StrategyDepositFacility.vy new file mode 100644 index 0000000..8dfb512 --- /dev/null +++ b/contracts/StrategyDepositFacility.vy @@ -0,0 +1,247 @@ +# @version 0.3.10 +""" +@title yETH strategy deposit/withdrawal facility +@author 0xkorin, Yearn Finance +@license GNU AGPLv3 +@notice + Facility to allow the v3 strategy to mint and burn yETH + in exchange for WETH +""" + +from vyper.interfaces import ERC20 +from vyper.interfaces import ERC4626 + +interface WETH: + def deposit(): payable + def withdraw(_amount: uint256): nonpayable + +interface Facility: + def available() -> (uint256, uint256): view + def mint(_recipient: address): payable + def burn(_amount: uint256): nonpayable + +token: public(immutable(address)) +staking: public(immutable(ERC4626)) +facility: public(immutable(Facility)) +weth: public(immutable(WETH)) + +management: public(address) +pending_management: public(address) + +treasury: public(address) +strategy: public(address) +packed_fee_rates: public(uint256) + +event Deposit: + amount_in: uint256 + amount_out: uint256 + stake: bool + +event Withdraw: + amount_in: uint256 + amount_out: uint256 + +event ClaimFees: + amount: uint256 + +event SetFeeRates: + deposit_fee_rate: uint256 + withdraw_fee_rate: uint256 + +event SetStrategy: + strategy: address + +event SetTreasury: + treasury: address + +event PendingManagement: + management: indexed(address) + +event SetManagement: + management: indexed(address) + +FEE_RATE_SCALE: constant(uint256) = 10_000 +MAX_FEE_RATE: constant(uint256) = FEE_RATE_SCALE / 100 +MASK: constant(uint256) = (1 << 128) - 1 + +@external +def __init__(_token: address, _staking: address, _facility: address, _weth: address): + """ + @notice Constructor + @param _token yETH token contract address + @param _staking st-yETH token contract address + @param _facility ETH deposit facility address + @param _weth Wrapped ETH contract address + """ + token = _token + staking = ERC4626(_staking) + facility = Facility(_facility) + weth = WETH(_weth) + self.management = msg.sender + self.treasury = msg.sender + + assert ERC20(token).approve(_staking, max_value(uint256), default_return_value=True) + +@external +@payable +def __default__(): + """ + @notice Receive ETH + @dev Can only be called by the WETH contract and the ETH facility + """ + assert msg.sender in [weth.address, facility.address] + +@external +@view +def available() -> (uint256, uint256): + """ + @notice Available capacity of the facility + @return Amount of yETH that can be minted, amount of yETH that can be burned + """ + return facility.available() + +@external +def deposit(_amount: uint256, _stake: bool) -> uint256: + """ + @notice Deposit WETH and mint yETH + @param _amount Amount of WETH to transfer in + @param _stake True: stake the minted into st-yETH + @return Amount of (st-)yETH minted + @dev Can only be called by the strategy + """ + assert msg.sender == self.strategy + + recipient: address = msg.sender + if _stake: + recipient = self + amount: uint256 = _amount + + assert ERC20(weth.address).transferFrom(msg.sender, self, amount, default_return_value=True) + weth.withdraw(amount) + + deposit_fee: uint256 = self.packed_fee_rates >> 128 + if deposit_fee > 0: + deposit_fee = amount * deposit_fee / FEE_RATE_SCALE + amount -= deposit_fee + + facility.mint(recipient, value=amount) + if _stake: + amount = staking.deposit(amount, msg.sender) + + log Deposit(_amount, amount, _stake) + return amount + +@external +def withdraw(_amount: uint256) -> uint256: + """ + @notice Withdraw WETH and burn yETH + @param _amount Amount of yETH to burn + @return Amount of WETH withdrawn + @dev Can only be called by the strategy + """ + assert msg.sender == self.strategy + + amount: uint256 = _amount + assert ERC20(token).transferFrom(msg.sender, self, amount, default_return_value=True) + facility.burn(amount) + + withdraw_fee: uint256 = self.packed_fee_rates & MASK + if withdraw_fee > 0: + withdraw_fee = amount * withdraw_fee / FEE_RATE_SCALE + amount -= withdraw_fee + + weth.deposit(value=amount) + assert ERC20(weth.address).transfer(msg.sender, amount, default_return_value=True) + + log Withdraw(_amount, amount) + return amount + +@external +@view +def fee_rates() -> (uint256, uint256): + """ + @notice Get deposit and withdraw fee rates + @return Deposit fee rate (bps), withdraw fee rate (bps) + """ + packed_fee_rates: uint256 = self.packed_fee_rates + return packed_fee_rates >> 128, packed_fee_rates & MASK + +@external +@view +def pending_fees() -> uint256: + """ + @notice Get the amount of fees that can be sent to the treasury + """ + return self.balance + +@external +def claim_fees() -> uint256: + """ + @notice Claim the pending fees by sending them to the treasury + @return Amount of fees claimed + """ + fees: uint256 = self.balance + assert fees > 0 + raw_call(self.treasury, b"", value=fees) + log ClaimFees(fees) + return fees + +@external +def set_fee_rates(_deposit_fee_rate: uint256, _withdraw_fee_rate: uint256): + """ + @notice Set deposit and withdraw fee rates + @param _deposit_fee_rate Deposit fee rate (bps) + @param _withdraw_fee_rate Withdraw fee rate (bps) + @dev Can only be called by management + """ + assert msg.sender == self.management + assert _deposit_fee_rate <= MAX_FEE_RATE and _withdraw_fee_rate <= MAX_FEE_RATE + self.packed_fee_rates = (_deposit_fee_rate << 128) | _withdraw_fee_rate + log SetFeeRates(_deposit_fee_rate, _withdraw_fee_rate) + +@external +def set_strategy(_strategy: address): + """ + @notice Set yETH strategy address + @param _strategy Strategy address + @dev Can only be called by management + """ + assert msg.sender == self.management + self.strategy = _strategy + log SetStrategy(_strategy) + +@external +def set_treasury(_treasury: address): + """ + @notice Set treasury address + @param _treasury Treasury address + @dev Can only be called by management + """ + assert msg.sender == self.management + assert _treasury != empty(address) + self.treasury = _treasury + log SetTreasury(_treasury) + +@external +def set_management(_management: address): + """ + @notice + Set the pending management address. + Needs to be accepted by that account separately to transfer management over + @param _management New pending management address + """ + assert msg.sender == self.management + self.pending_management = _management + log PendingManagement(_management) + +@external +def accept_management(): + """ + @notice + Accept management role. + Can only be called by account previously marked as pending management by current management + """ + assert msg.sender == self.pending_management + self.pending_management = empty(address) + self.management = msg.sender + log SetManagement(msg.sender) diff --git a/tests/test_strategy_deposit_facility.py b/tests/test_strategy_deposit_facility.py new file mode 100644 index 0000000..4840bac --- /dev/null +++ b/tests/test_strategy_deposit_facility.py @@ -0,0 +1,200 @@ +import ape +from ape import Contract +import pytest + +ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +NATIVE = ZERO_ADDRESS +UNIT = 10**18 + +@pytest.fixture +def operator(accounts): + return accounts[4] + +@pytest.fixture +def strategy(accounts): + return accounts[5] + +@pytest.fixture +def token(): + return Contract('0x1BED97CBC3c24A4fb5C069C6E311a967386131f7') + +@pytest.fixture +def staking(): + return Contract('0x583019fF0f430721aDa9cfb4fac8F06cA104d0B4') + +@pytest.fixture +def weth(): + return Contract('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2') + +@pytest.fixture +def pol(project, deployer, token): + return project.POL.deploy(token, sender=deployer) + +@pytest.fixture +def facility(project, accounts, deployer, operator, token, pol): + facility = project.DepositFacility.deploy(token, pol, sender=deployer) + facility.set_operator(operator, sender=deployer) + facility.set_capacity(10 * UNIT, sender=deployer) + management = accounts[token.management()] + token.set_minter(facility, sender=management) + return facility + +@pytest.fixture +def strategy_facility(project, deployer, strategy, token, staking, weth, facility): + strategy_facility = project.StrategyDepositFacility.deploy(token, staking, facility, weth, sender=deployer) + strategy_facility.set_strategy(strategy, sender=deployer) + facility.set_mint_whitelist(strategy_facility, True, sender=deployer) + facility.set_burn_whitelist(strategy_facility, True, sender=deployer) + return strategy_facility + +def test_deposit(strategy, token, weth, strategy_facility): + weth.deposit(value=3 * UNIT, sender=strategy) + weth.approve(strategy_facility, UNIT, sender=strategy) + + assert weth.balanceOf(strategy) == 3 * UNIT + assert token.balanceOf(strategy) == 0 + assert strategy_facility.available() == (10 * UNIT, 0) + assert strategy_facility.deposit(UNIT, False, sender=strategy).return_value == UNIT + assert weth.balanceOf(strategy) == 2 * UNIT + assert token.balanceOf(strategy) == UNIT + assert strategy_facility.available() == (9 * UNIT, UNIT) + +def test_deposit_stake(strategy, token, staking, weth, strategy_facility): + weth.deposit(value=3 * UNIT, sender=strategy) + weth.approve(strategy_facility, UNIT, sender=strategy) + + assert weth.balanceOf(strategy) == 3 * UNIT + assert token.balanceOf(strategy) == 0 + actual = strategy_facility.deposit(UNIT, True, sender=strategy).return_value + shares = staking.convertToShares(UNIT) + assert actual == shares + assert weth.balanceOf(strategy) == 2 * UNIT + assert staking.balanceOf(strategy) == shares + +def test_deposit_fee(deployer, strategy, token, weth, strategy_facility): + strategy_facility.set_fee_rates(100, 0, sender=deployer) + weth.deposit(value=3 * UNIT, sender=strategy) + weth.approve(strategy_facility, UNIT, sender=strategy) + + fee = UNIT // 100 + assert weth.balanceOf(strategy) == 3 * UNIT + assert token.balanceOf(strategy) == 0 + assert strategy_facility.pending_fees() == 0 + assert strategy_facility.balance == 0 + assert strategy_facility.deposit(UNIT, False, sender=strategy).return_value == UNIT - fee + assert weth.balanceOf(strategy) == 2 * UNIT + assert token.balanceOf(strategy) == UNIT - fee + assert strategy_facility.pending_fees() == fee + assert strategy_facility.balance == fee + +def test_deposit_stake_fee(deployer, strategy, token, staking, weth, strategy_facility): + strategy_facility.set_fee_rates(100, 0, sender=deployer) + weth.deposit(value=3 * UNIT, sender=strategy) + weth.approve(strategy_facility, UNIT, sender=strategy) + + fee = UNIT // 100 + assert weth.balanceOf(strategy) == 3 * UNIT + assert token.balanceOf(strategy) == 0 + assert strategy_facility.pending_fees() == 0 + assert strategy_facility.balance == 0 + actual = strategy_facility.deposit(UNIT, True, sender=strategy).return_value + shares = staking.convertToShares(UNIT - fee) + assert actual == shares + assert weth.balanceOf(strategy) == 2 * UNIT + assert staking.balanceOf(strategy) == shares + assert strategy_facility.pending_fees() == fee + assert strategy_facility.balance == fee + +def test_withdraw(deployer, strategy, token, weth, facility, strategy_facility): + facility.set_mint_whitelist(deployer, True, sender=deployer) + facility.mint(value=3 * UNIT, sender=deployer) + token.transfer(strategy, 3 * UNIT, sender=deployer) + token.approve(strategy_facility, UNIT, sender=strategy) + + assert weth.balanceOf(strategy) == 0 + assert token.balanceOf(strategy) == 3 * UNIT + assert strategy_facility.available() == (7 * UNIT, 3 * UNIT) + assert strategy_facility.withdraw(UNIT, sender=strategy).return_value == UNIT + assert weth.balanceOf(strategy) == UNIT + assert token.balanceOf(strategy) == 2 * UNIT + assert strategy_facility.available() == (8 * UNIT, 2 * UNIT) + +def test_withdraw_fee(deployer, strategy, token, weth, facility, strategy_facility): + strategy_facility.set_fee_rates(0, 100, sender=deployer) + facility.set_mint_whitelist(deployer, True, sender=deployer) + facility.mint(value=3 * UNIT, sender=deployer) + token.transfer(strategy, 3 * UNIT, sender=deployer) + token.approve(strategy_facility, UNIT, sender=strategy) + + fee = UNIT // 100 + assert weth.balanceOf(strategy) == 0 + assert token.balanceOf(strategy) == 3 * UNIT + assert strategy_facility.pending_fees() == 0 + assert strategy_facility.balance == 0 + assert strategy_facility.withdraw(UNIT, sender=strategy).return_value == UNIT - fee + assert weth.balanceOf(strategy) == UNIT - fee + assert token.balanceOf(strategy) == 2 * UNIT + assert strategy_facility.pending_fees() == fee + assert strategy_facility.balance == fee + +def test_claim_fees(deployer, alice, operator, strategy, token, weth, strategy_facility): + weth.deposit(value=3 * UNIT, sender=strategy) + weth.approve(strategy_facility, 3 * UNIT, sender=strategy) + token.approve(strategy_facility, UNIT, sender=strategy) + strategy_facility.deposit(UNIT, False, sender=strategy) + strategy_facility.set_fee_rates(100, 100, sender=deployer) + strategy_facility.set_treasury(operator, sender=deployer) + + strategy_facility.deposit(2 * UNIT, False, sender=strategy) + strategy_facility.withdraw(UNIT, sender=strategy) + assert strategy_facility.pending_fees() == UNIT * 3 // 100 + bal = operator.balance + assert strategy_facility.claim_fees(sender=alice).return_value == UNIT * 3 // 100 + assert strategy_facility.pending_fees() == 0 + assert operator.balance == bal + UNIT * 3 // 100 + +def test_set_fee_rates(deployer, strategy_facility): + strategy_facility.set_fee_rates(20, 30, sender=deployer) + assert strategy_facility.fee_rates() == (20, 30) + +def test_set_fee_rates_permission(alice, strategy_facility): + with ape.reverts(): + strategy_facility.set_fee_rates(20, 30, sender=alice) + +def test_set_strategy(deployer, alice, strategy, strategy_facility): + assert strategy_facility.strategy() == strategy + strategy_facility.set_strategy(alice, sender=deployer) + assert strategy_facility.strategy() == alice + +def test_set_strategy_permission(alice, strategy_facility): + with ape.reverts(): + strategy_facility.set_strategy(alice, sender=alice) + +def test_set_treasury(deployer, alice, strategy_facility): + assert strategy_facility.treasury() == deployer + strategy_facility.set_treasury(alice, sender=deployer) + assert strategy_facility.treasury() == alice + +def test_set_treasury_permission(alice, strategy_facility): + with ape.reverts(): + strategy_facility.set_treasury(alice, sender=alice) + +def test_transfer_management(deployer, alice, bob, strategy_facility): + assert strategy_facility.management() == deployer + assert strategy_facility.pending_management() == ZERO_ADDRESS + + with ape.reverts(): + strategy_facility.set_management(alice, sender=alice) + with ape.reverts(): + strategy_facility.accept_management(sender=alice) + + strategy_facility.set_management(alice, sender=deployer) + assert strategy_facility.management() == deployer + assert strategy_facility.pending_management() == alice + + with ape.reverts(): + strategy_facility.accept_management(sender=bob) + + strategy_facility.accept_management(sender=alice) + assert strategy_facility.management() == alice + assert strategy_facility.pending_management() == ZERO_ADDRESS