From 91704a3fef4f313ec416350579785afd2bce310c Mon Sep 17 00:00:00 2001 From: Schlag <89420541+Schlagonia@users.noreply.github.com> Date: Fri, 8 Mar 2024 12:30:34 -0700 Subject: [PATCH] build: splitter contract (#38) * build: splitter contract * feat: add events * feat: auction option * build: generic splitter * fix: naming * test: conftest * chore: remove hh config * test: splitter * chore: deploy splitter * chore: role manager script * test: import reverts --- contracts/splitter/Splitter.vy | 193 +++++++++++++++++++ contracts/splitter/SplitterFactory.vy | 50 +++++ hardhat.config.js | 16 -- scripts/deploy_role_manager.py | 3 +- scripts/deploy_splitter_factory.py | 52 +++++ scripts/deployments.py | 2 + tests/conftest.py | 29 +++ tests/splitter/test_splitter.py | 268 ++++++++++++++++++++++++++ 8 files changed, 596 insertions(+), 17 deletions(-) create mode 100644 contracts/splitter/Splitter.vy create mode 100644 contracts/splitter/SplitterFactory.vy delete mode 100644 hardhat.config.js create mode 100644 scripts/deploy_splitter_factory.py create mode 100644 tests/splitter/test_splitter.py diff --git a/contracts/splitter/Splitter.vy b/contracts/splitter/Splitter.vy new file mode 100644 index 0000000..25b1afa --- /dev/null +++ b/contracts/splitter/Splitter.vy @@ -0,0 +1,193 @@ +# @version 0.3.7 + +interface IVault: + def asset() -> address: view + def balanceOf(owner: address) -> uint256: view + def redeem(shares: uint256, receiver: address, owner: address, max_loss: uint256) -> uint256: nonpayable + def transfer(receiver: address, amount: uint256) -> bool: nonpayable + +event UpdateManagerRecipient: + newManagerRecipient: indexed(address) + +event UpdateSplitee: + newSplitee: indexed(address) + +event UpdateSplit: + newSplit: uint256 + +event UpdateMaxLoss: + newMaxLoss: uint256 + +event UpdateAuction: + newAuction: address + +MAX_BPS: constant(uint256) = 10_000 +MAX_ARRAY_SIZE: public(constant(uint256)) = 20 + +name: public(String[64]) + +# Bid daddy yankee in charge of the splitter +manager: public(address) +# Address to receive the managers shares +managerRecipient: public(address) +# Team to receive the rest of the split +splitee: public(address) + +# Percent that is sent to `managerRecipient` +split: public(uint256) +# Max loss to use on vault redeems +maxLoss: public(uint256) + +# Address of the contract to conduct dutch auctions for token sales +auction: public(address) + +@external +def initialize( + name: String[64], + manager: address, + manager_recipient: address, + splitee: address, + original_split: uint256 +): + assert self.manager == empty(address), "initialized" + assert manager != empty(address), "ZERO_ADDRESS" + assert manager_recipient != empty(address), "ZERO_ADDRESS" + assert splitee != empty(address), "ZERO_ADDRESS" + assert original_split != 0, "zero split" + + self.name = name + self.manager = manager + self.managerRecipient = manager_recipient + self.splitee = splitee + self.split = original_split + self.maxLoss = 1 + + +###### UNWRAP VAULT TOKENS ###### + +@external +def unwrapVault(vault: address): + assert msg.sender == self.splitee or msg.sender == self.manager, "!allowed" + self._unwrapVault(vault, self.maxLoss) + +@external +def unwrapVaults(vaults: DynArray[address, MAX_ARRAY_SIZE]): + assert msg.sender == self.splitee or msg.sender == self.manager, "!allowed" + + max_loss: uint256 = self.maxLoss + + for vault in vaults: + self._unwrapVault(vault, max_loss) + +@internal +def _unwrapVault(vault: address, max_loss: uint256): + vault_balance: uint256 = IVault(vault).balanceOf(self) + IVault(vault).redeem(vault_balance, self, self, max_loss) + +###### DISTRIBUTE TOKENS ###### + +# split one token +@external +def distributeToken(token: address): + splitee: address = self.splitee + assert msg.sender == splitee or msg.sender == self.manager, "!allowed" + self._distribute(token, self.split, self.managerRecipient, splitee) + +# split an array of tokens +@external +def distributeTokens(tokens: DynArray[address, MAX_ARRAY_SIZE]): + splitee: address = self.splitee + assert msg.sender == splitee or msg.sender == self.manager, "!allowed" + + # Cache the split storage variables + split: uint256 = self.split + manager_recipient: address = self.managerRecipient + + for token in tokens: + self._distribute(token, split, manager_recipient, splitee) + +@internal +def _distribute(token: address, split: uint256, manager_recipient: address, splitee: address): + current_balance: uint256 = IVault(token).balanceOf(self) + manager_split: uint256 = current_balance + + if split != MAX_BPS: + manager_split = unsafe_div(unsafe_mul(current_balance, split), MAX_BPS) + self._transferERC20(token, splitee, unsafe_sub(current_balance, manager_split)) + + self._transferERC20(token, manager_recipient, manager_split) + +###### AUCTION INITIATORS ###### + +@external +def fundAuctions(tokens: DynArray[address, MAX_ARRAY_SIZE]): + assert msg.sender == self.splitee or msg.sender == self.manager, "!allowed" + auction: address = self.auction + + for token in tokens: + amount: uint256 = IVault(token).balanceOf(self) + self._transferERC20(token, auction, amount) + +@external +def fundAuction(token: address, amount: uint256 = max_value(uint256)): + assert msg.sender == self.splitee or msg.sender == self.manager, "!allowed" + + to_send: uint256 = amount + if(amount == max_value(uint256)): + to_send = IVault(token).balanceOf(self) + + self._transferERC20(token, self.auction, to_send) + +@internal +def _transferERC20(token: address, recipient: address, amount: uint256): + # Send tokens to the auction contract. + assert IVault(token).transfer(recipient, amount, default_return_value=True), "transfer failed" + +###### SETTERS ###### + +# update recipients +@external +def setMangerRecipient(new_recipient: address): + assert msg.sender == self.manager, "!manager" + assert new_recipient != empty(address), "ZERO_ADDRESS" + + self.managerRecipient = new_recipient + + log UpdateManagerRecipient(new_recipient) + +@external +def setSplitee(new_splitee: address): + assert msg.sender == self.splitee, "!splitee" + assert new_splitee != empty(address), "ZERO_ADDRESS" + + self.splitee = new_splitee + + log UpdateSplitee(new_splitee) + +# Update Split +@external +def setSplit(new_split: uint256): + assert msg.sender == self.manager, "!manager" + assert new_split != 0, "zero split" + + self.split = new_split + + log UpdateSplit(new_split) + +# Set max loss +@external +def setMaxLoss(new_max_loss: uint256): + assert msg.sender == self.manager, "!manager" + assert new_max_loss <= MAX_BPS, "MAX_BPS" + + self.maxLoss = new_max_loss + + log UpdateMaxLoss(new_max_loss) + +@external +def setAuction(new_auction: address): + assert msg.sender == self.manager, "!manager" + + self.auction = new_auction + + log UpdateAuction(new_auction) \ No newline at end of file diff --git a/contracts/splitter/SplitterFactory.vy b/contracts/splitter/SplitterFactory.vy new file mode 100644 index 0000000..0300c80 --- /dev/null +++ b/contracts/splitter/SplitterFactory.vy @@ -0,0 +1,50 @@ +# @version 0.3.7 + +interface ISplitter: + def initialize( + name: String[64], + manager: address, + manager_recipient: address, + splitee: address, + original_split: uint256 + ): nonpayable + +event NewSplitter: + splitter: indexed(address) + manager: indexed(address) + manager_recipient: indexed(address) + splitee: address + +# The address that all newly deployed vaults are based from. +ORIGINAL: public(immutable(address)) + +@external +def __init__(original: address): + ORIGINAL = original + + +@external +def newSplitter( + name: String[64], + manager: address, + manager_recipient: address, + splitee: address, + original_split: uint256 +) -> address: + + # Clone a new version of the splitter + new_splitter: address = create_minimal_proxy_to( + ORIGINAL, + value=0 + ) + + ISplitter(new_splitter).initialize( + name, + manager, + manager_recipient, + splitee, + original_split + ) + + log NewSplitter(new_splitter, manager, manager_recipient, splitee) + return new_splitter \ No newline at end of file diff --git a/hardhat.config.js b/hardhat.config.js deleted file mode 100644 index 16366e8..0000000 --- a/hardhat.config.js +++ /dev/null @@ -1,16 +0,0 @@ - -// See https://hardhat.org/config/ for config options. -module.exports = { - networks: { - hardhat: { - hardfork: "london", - // Base fee of 0 allows use of 0 gas price when testing - initialBaseFeePerGas: 0, - accounts: { - mnemonic: "test test test test test test test test test test test junk", - path: "m/44'/60'/0'", - count: 10 - } - }, - }, - }; \ No newline at end of file diff --git a/scripts/deploy_role_manager.py b/scripts/deploy_role_manager.py index cea034a..e1cab53 100644 --- a/scripts/deploy_role_manager.py +++ b/scripts/deploy_role_manager.py @@ -29,9 +29,10 @@ def deploy_role_manager(): security = input("Security? ") keeper = input("Keeper? ") strategy_manager = input("Strategy manager? ") + registry = input("Registry? ") constructor = role_manager.constructor.encode_input( - gov, daddy, brain, security, keeper, strategy_manager + gov, daddy, brain, security, keeper, strategy_manager, registry ) deploy_bytecode = HexBytes( diff --git a/scripts/deploy_splitter_factory.py b/scripts/deploy_splitter_factory.py new file mode 100644 index 0000000..7bba33a --- /dev/null +++ b/scripts/deploy_splitter_factory.py @@ -0,0 +1,52 @@ +from ape import project, accounts, Contract, chain, networks +from hexbytes import HexBytes +from scripts.deployments import getSalt, deploy_contract + + +def deploy_splitter_factory(): + print("Deploying Splitter Factory on ChainID", chain.chain_id) + + if input("Do you want to continue? ") == "n": + return + + splitter = project.Splitter + splitter_factory = project.SplitterFactory + + deployer = input("Name of account to use? ") + deployer = accounts.load(deployer) + + salt = getSalt("Splitter Factory") + + print(f"Salt we are using {salt}") + print("Init balance:", deployer.balance / 1e18) + + print(f"Deploying Original.") + + original_deploy_bytecode = HexBytes( + HexBytes(splitter.contract_type.deployment_bytecode.bytecode) + ) + + original_address = deploy_contract(original_deploy_bytecode, salt, deployer) + + print(f"Original deployed to {original_address}") + + allocator_constructor = splitter_factory.constructor.encode_input(original_address) + + # generate and deploy + deploy_bytecode = HexBytes( + HexBytes(splitter_factory.contract_type.deployment_bytecode.bytecode) + + allocator_constructor + ) + + print(f"Deploying the Factory...") + + deploy_contract(deploy_bytecode, salt, deployer) + + print("------------------") + print( + f"Encoded Constructor to use for verifaction {allocator_constructor.hex()[2:]}" + ) + + +def main(): + deploy_splitter_factory() diff --git a/scripts/deployments.py b/scripts/deployments.py index 5fe9b03..92e36ec 100644 --- a/scripts/deployments.py +++ b/scripts/deployments.py @@ -27,3 +27,5 @@ def deploy_contract(init_code, salt, deployer): print("------------------") print(f"Deployed the contract to {address}") + + return address diff --git a/tests/conftest.py b/tests/conftest.py index 9431f84..bf93a02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -433,3 +433,32 @@ def role_manager(deploy_role_manager, daddy, brain, accountant, debt_allocator_f @pytest.fixture(scope="session") def keeper(daddy): yield daddy.deploy(project.Keeper) + + +@pytest.fixture(scope="session") +def deploy_splitter_factory(project, daddy): + def deploy_splitter_factory(): + original = daddy.deploy(project.Splitter) + + splitter_factory = daddy.deploy(project.SplitterFactory, original) + + return splitter_factory + + yield deploy_splitter_factory + + +@pytest.fixture(scope="session") +def splitter_factory(deploy_splitter_factory): + splitter_factory = deploy_splitter_factory() + return splitter_factory + + +@pytest.fixture(scope="session") +def splitter(daddy, management, brain, splitter_factory): + tx = splitter_factory.newSplitter( + "Test Splitter", daddy, management, brain, 5_000, sender=daddy + ) + event = list(tx.decode_logs(splitter_factory.NewSplitter))[0] + splitter = project.Splitter.at(event.splitter) + + yield splitter diff --git a/tests/splitter/test_splitter.py b/tests/splitter/test_splitter.py new file mode 100644 index 0000000..f77a6d1 --- /dev/null +++ b/tests/splitter/test_splitter.py @@ -0,0 +1,268 @@ +import ape +from ape import chain, reverts +from utils.constants import ZERO_ADDRESS, MAX_INT + + +def test_split_setup(splitter_factory, splitter, daddy, brain, management): + assert splitter_factory.ORIGINAL() != ZERO_ADDRESS + assert splitter.address != ZERO_ADDRESS + assert splitter.manager() == daddy + assert splitter.managerRecipient() == management + assert splitter.splitee() == brain + assert splitter.split() == 5_000 + assert splitter.maxLoss() == 1 + assert splitter.auction() == ZERO_ADDRESS + + +def test_unwrap( + splitter, daddy, vault, mock_tokenized, deploy_mock_tokenized, asset, user, amount +): + assert splitter.manager() == daddy + + second_strategy = deploy_mock_tokenized() + + amount = amount // 3 + + asset.approve(vault, amount, sender=user) + asset.approve(mock_tokenized, amount, sender=user) + asset.approve(second_strategy, amount, sender=user) + + vault.deposit(amount, splitter, sender=user) + mock_tokenized.deposit(amount, splitter, sender=user) + second_strategy.deposit(amount, splitter, sender=user) + + assert vault.balanceOf(splitter) == amount + assert mock_tokenized.balanceOf(splitter) == amount + assert second_strategy.balanceOf(splitter) == amount + assert asset.balanceOf(splitter) == 0 + + with ape.reverts("!allowed"): + splitter.unwrapVault(second_strategy, sender=user) + + splitter.unwrapVault(second_strategy, sender=daddy) + + assert vault.balanceOf(splitter) == amount + assert mock_tokenized.balanceOf(splitter) == amount + assert second_strategy.balanceOf(splitter) == 0 + assert asset.balanceOf(splitter) == amount + + vaults = [vault, mock_tokenized] + + with ape.reverts("!allowed"): + splitter.unwrapVaults(vaults, sender=user) + + splitter.unwrapVaults(vaults, sender=daddy) + + assert vault.balanceOf(splitter) == 0 + assert mock_tokenized.balanceOf(splitter) == 0 + assert second_strategy.balanceOf(splitter) == 0 + assert asset.balanceOf(splitter) == amount * 3 + + +def test_distribute( + splitter, + daddy, + vault, + mock_tokenized, + deploy_mock_tokenized, + asset, + user, + management, + brain, + amount, +): + assert splitter.manager() == daddy + recipeint = management + splitee = brain + split = 5_000 + + second_strategy = deploy_mock_tokenized() + + amount = amount // 4 + + asset.approve(vault, amount, sender=user) + asset.approve(mock_tokenized, amount, sender=user) + asset.approve(second_strategy, amount, sender=user) + + vault.deposit(amount, splitter, sender=user) + mock_tokenized.deposit(amount, splitter, sender=user) + second_strategy.deposit(amount, splitter, sender=user) + + assert vault.balanceOf(splitter) == amount + assert vault.balanceOf(recipeint) == 0 + assert vault.balanceOf(splitee) == 0 + + assert mock_tokenized.balanceOf(splitter) == amount + assert mock_tokenized.balanceOf(recipeint) == 0 + assert mock_tokenized.balanceOf(splitee) == 0 + + assert second_strategy.balanceOf(splitter) == amount + assert second_strategy.balanceOf(recipeint) == 0 + assert second_strategy.balanceOf(splitee) == 0 + + with ape.reverts("!allowed"): + splitter.distributeToken(second_strategy, sender=user) + + splitter.distributeToken(second_strategy, sender=daddy) + + assert second_strategy.balanceOf(splitter) == 0 + assert second_strategy.balanceOf(recipeint) == amount / 2 + assert second_strategy.balanceOf(splitee) == amount / 2 + + vaults = [vault, mock_tokenized] + + with ape.reverts("!allowed"): + splitter.distributeTokens(vaults, sender=user) + + splitter.distributeTokens(vaults, sender=daddy) + + assert vault.balanceOf(splitter) == 0 + assert vault.balanceOf(recipeint) == amount / 2 + assert vault.balanceOf(splitee) == amount / 2 + + assert mock_tokenized.balanceOf(splitter) == 0 + assert mock_tokenized.balanceOf(recipeint) == amount / 2 + assert mock_tokenized.balanceOf(splitee) == amount / 2 + + +def test_auction( + splitter, + daddy, + vault, + mock_tokenized, + deploy_mock_tokenized, + asset, + user, + management, + brain, + amount, +): + assert splitter.manager() == daddy + auction = user + + second_strategy = deploy_mock_tokenized() + + amount = amount // 4 + + asset.approve(vault, amount, sender=user) + asset.approve(mock_tokenized, amount, sender=user) + asset.approve(second_strategy, amount, sender=user) + + vault.deposit(amount, splitter, sender=user) + mock_tokenized.deposit(amount, splitter, sender=user) + second_strategy.deposit(amount, splitter, sender=user) + + assert vault.balanceOf(splitter) == amount + assert vault.balanceOf(auction) == 0 + + assert mock_tokenized.balanceOf(splitter) == amount + assert mock_tokenized.balanceOf(auction) == 0 + + assert second_strategy.balanceOf(splitter) == amount + assert second_strategy.balanceOf(auction) == 0 + + with ape.reverts("!allowed"): + splitter.fundAuction(second_strategy, sender=user) + + with ape.reverts(): + splitter.fundAuction(second_strategy, sender=daddy) + + splitter.setAuction(auction, sender=daddy) + + splitter.fundAuction(second_strategy, sender=daddy) + + assert second_strategy.balanceOf(splitter) == 0 + assert second_strategy.balanceOf(auction) == amount + + vaults = [vault, mock_tokenized] + + with ape.reverts("!allowed"): + splitter.fundAuctions(vaults, sender=user) + + splitter.fundAuctions(vaults, sender=daddy) + + assert vault.balanceOf(splitter) == 0 + assert vault.balanceOf(auction) == amount + + assert mock_tokenized.balanceOf(splitter) == 0 + assert mock_tokenized.balanceOf(auction) == amount + + +def test_setters(splitter, daddy, user, brain, management): + recipeint = management + splitee = brain + split = 5_000 + max_loss = 1 + + new_recipient = user + + assert splitter.managerRecipient() == recipeint + + with ape.reverts("!manager"): + splitter.setMangerRecipient(new_recipient, sender=brain) + + assert splitter.managerRecipient() == recipeint + + tx = splitter.setMangerRecipient(new_recipient, sender=daddy) + + assert splitter.managerRecipient() == new_recipient + assert ( + list(tx.decode_logs(splitter.UpdateManagerRecipient))[0].newManagerRecipient + == new_recipient + ) + + new_splitee = user + + assert splitter.splitee() == splitee + + with ape.reverts("!splitee"): + splitter.setSplitee(new_splitee, sender=daddy) + + assert splitter.splitee() == splitee + + tx = splitter.setSplitee(new_splitee, sender=brain) + + assert splitter.splitee() == new_splitee + assert list(tx.decode_logs(splitter.UpdateSplitee))[0].newSplitee == new_splitee + + new_split = 123 + + assert splitter.split() == split + + with ape.reverts("!manager"): + splitter.setSplit(new_split, sender=brain) + + assert splitter.split() == split + + tx = splitter.setSplit(new_split, sender=daddy) + + assert splitter.split() == new_split + assert list(tx.decode_logs(splitter.UpdateSplit))[0].newSplit == new_split + + new_max_loss = 123 + + assert splitter.maxLoss() == max_loss + + with ape.reverts("!manager"): + splitter.setMaxLoss(new_max_loss, sender=brain) + + assert splitter.maxLoss() == max_loss + + tx = splitter.setMaxLoss(new_split, sender=daddy) + + assert splitter.maxLoss() == new_max_loss + assert list(tx.decode_logs(splitter.UpdateMaxLoss))[0].newMaxLoss == new_max_loss + + new_auction = user + + assert splitter.auction() == ZERO_ADDRESS + + with ape.reverts("!manager"): + splitter.setAuction(new_auction, sender=brain) + + assert splitter.auction() == ZERO_ADDRESS + + tx = splitter.setAuction(new_auction, sender=daddy) + + assert splitter.auction() == new_auction + assert list(tx.decode_logs(splitter.UpdateAuction))[0].newAuction == new_auction