diff --git a/src/periphery/FeeSplitter.sol b/src/periphery/FeeSplitter.sol new file mode 100644 index 0000000..c47a455 --- /dev/null +++ b/src/periphery/FeeSplitter.sol @@ -0,0 +1,135 @@ +pragma solidity ^0.8.18; + +import "lyra-utils/decimals/SignedDecimalMath.sol"; + +import {Ownable2Step} from "openzeppelin/access/Ownable2Step.sol"; +import {IManager} from "v2-core/src/interfaces/IManager.sol"; +import {ISubAccounts, IAsset} from "v2-core/src/interfaces/ISubAccounts.sol"; + +/** + * @title FeeSplitter + * @notice FeeSplitter is a contract that splits the balance of a subaccount held by this contract based on a % split + * @author Lyra + */ +contract FeeSplitter is Ownable2Step { + using SignedDecimalMath for int; + + ISubAccounts public immutable subAccounts; + IAsset public immutable cashAsset; + + uint public subAcc; + uint public splitPercent; + uint public accountA; + uint public accountB; + + constructor( + ISubAccounts _subAccounts, + IManager manager, + IAsset _cashAsset, + uint _splitPercent, + uint _accountA, + uint _accountB + ) { + subAccounts = _subAccounts; + subAcc = _subAccounts.createAccount(address(this), manager); + cashAsset = _cashAsset; + + _setSplit(_splitPercent); + _setSubAccounts(_accountA, _accountB); + } + + /////////// + // Admin // + /////////// + + /// @notice Set the % split + function setSplit(uint _splitPercent) external onlyOwner { + _setSplit(_splitPercent); + } + + function _setSplit(uint _splitPercent) internal { + if (_splitPercent > 1e18) { + revert FS_InvalidSplitPercentage(); + } + splitPercent = _splitPercent; + + emit SplitPercentSet(_splitPercent); + } + + /// @notice Set the subaccounts to split the balance between + function setSubAccounts(uint _accountA, uint _accountB) external onlyOwner { + _setSubAccounts(_accountA, _accountB); + } + + function _setSubAccounts(uint _accountA, uint _accountB) internal { + if (_accountA == 0 || _accountB == 0) { + revert FS_InvalidSubAccount(); + } + + accountA = _accountA; + accountB = _accountB; + + emit SubAccountsSet(_accountA, _accountB); + } + + /// @notice Recover a subaccount held by this contract, creating a new one in its place + function recoverSubAccount(address recipient) external onlyOwner { + uint oldSubAcc = subAcc; + subAccounts.transferFrom(address(this), recipient, oldSubAcc); + subAcc = subAccounts.createAccount(address(this), subAccounts.manager(oldSubAcc)); + + emit SubAccountRecovered(oldSubAcc, recipient, subAcc); + } + + ////////////// + // External // + ////////////// + /// @notice Work out the balance of the subaccount held by this contract, and split it based on the % split + function split() external { + int balance = subAccounts.getBalance(subAcc, cashAsset, 0); + + if (balance <= 0) { + revert FS_NoBalanceToSplit(); + } + + int splitAmountA = balance.multiplyDecimal(int(splitPercent)); + int splitAmountB = balance - splitAmountA; + + ISubAccounts.AssetTransfer[] memory transfers = new ISubAccounts.AssetTransfer[](2); + transfers[0] = ISubAccounts.AssetTransfer({ + fromAcc: subAcc, + toAcc: accountA, + asset: cashAsset, + subId: 0, + amount: int(splitAmountA), + assetData: bytes32(0) + }); + transfers[1] = ISubAccounts.AssetTransfer({ + fromAcc: subAcc, + toAcc: accountB, + asset: cashAsset, + subId: 0, + amount: int(splitAmountB), + assetData: bytes32(0) + }); + + subAccounts.submitTransfers(transfers, ""); + + emit BalanceSplit(subAcc, accountA, accountB, splitAmountA, splitAmountB); + } + + //////////// + // Errors // + //////////// + error FS_InvalidSplitPercentage(); + error FS_InvalidSubAccount(); + error FS_NoBalanceToSplit(); + + //////////// + // Events // + //////////// + event SplitPercentSet(uint splitPercent); + event SubAccountsSet(uint accountA, uint accountB); + event SubAccountRecovered(uint oldSubAcc, address recipient, uint newSubAcc); + event BalanceSplit(uint subAcc, uint accountA, uint accountB, int splitAmountA, int splitAmountB); +} diff --git a/test/periphery/FeeSplitter.t.sol b/test/periphery/FeeSplitter.t.sol new file mode 100644 index 0000000..560bd65 --- /dev/null +++ b/test/periphery/FeeSplitter.t.sol @@ -0,0 +1,109 @@ +pragma solidity ^0.8.18; + +import {IntegrationTestBase} from "v2-core/test/integration-tests/shared/IntegrationTestBase.t.sol"; +import {IManager} from "v2-core/src/interfaces/IManager.sol"; +import {IAsset} from "v2-core/src/interfaces/IAsset.sol"; +import {ISubAccounts} from "v2-core/src/interfaces/ISubAccounts.sol"; + +import "forge-std/console2.sol"; +import "../../src/periphery/FeeSplitter.sol"; + +contract FeeSplitterTest is IntegrationTestBase { + FeeSplitter public feeSplitter; + IAsset public asset; + uint public accountA; + uint public accountB; + + function setUp() public { + _setupIntegrationTestComplete(); + + accountA = subAccounts.createAccount(address(this), srm); + accountB = subAccounts.createAccount(address(this), markets["weth"].pmrm); + + feeSplitter = new FeeSplitter(subAccounts, srm, cash, 0.5e18, accountA, accountB); + uint amount = 100e6; + usdc.mint(address(this), amount); + usdc.approve(address(cash), amount); + cash.deposit(feeSplitter.subAcc(), amount); + } + + function test_constructor() public { + assertEq(address(feeSplitter.subAccounts()), address(subAccounts), "Incorrect subAccounts"); + assertEq(address(feeSplitter.cashAsset()), address(cash), "Incorrect cashAsset"); + assertFalse(feeSplitter.subAcc() == 0, "Incorrect subAcc"); + assertEq(feeSplitter.splitPercent(), 0.5e18, "Incorrect splitPercent"); + assertEq(feeSplitter.accountA(), accountA, "Incorrect accountA"); + assertEq(feeSplitter.accountB(), accountB, "Incorrect accountB"); + } + + function test_setSplit() public { + feeSplitter.setSplit(0.6e18); + assertEq(feeSplitter.splitPercent(), 0.6e18, "Incorrect splitPercent"); + + vm.expectRevert(FeeSplitter.FS_InvalidSplitPercentage.selector); + feeSplitter.setSplit(1.1e18); + } + + function test_split() public { + // before split + assertEq( + subAccounts.getBalance(feeSplitter.subAcc(), cash, 0), int(100e18), "Incorrect initial balance in FeeSplitter" + ); + assertEq(subAccounts.getBalance(accountA, cash, 0), 0, "Account A should initially have 0 balance"); + assertEq(subAccounts.getBalance(accountB, cash, 0), 0, "Account B should initially have 0 balance"); + + feeSplitter.split(); + + // after split + assertEq( + subAccounts.getBalance(accountA, cash, 0), int(50e18), "Account A should have half of the funds after split" + ); + assertEq( + subAccounts.getBalance(accountB, cash, 0), int(50e18), "Account B should have half of the funds after split" + ); + + vm.expectRevert(FeeSplitter.FS_NoBalanceToSplit.selector); + feeSplitter.split(); + } + + function test_recover() public { + uint oldSubAcc = feeSplitter.subAcc(); + feeSplitter.recoverSubAccount(address(this)); + uint newSubAcc = feeSplitter.subAcc(); + assertEq( + subAccounts.ownerOf(newSubAcc), address(feeSplitter), "New subAcc should be owned by fee splitter contract" + ); + assertEq(subAccounts.ownerOf(oldSubAcc), address(this), "Old subAcc should be owned by this contract"); + assertFalse(oldSubAcc == newSubAcc); + } + + function test_split100percent() public { + feeSplitter.setSplit(1e18); + feeSplitter.split(); + assertEq( + subAccounts.getBalance(accountA, cash, 0), int(100e18), "Account A should have all of the funds after split" + ); + assertEq(subAccounts.getBalance(accountB, cash, 0), 0, "Account B should have 0 balance after split"); + } + + function test_split0percent() public { + feeSplitter.setSplit(0); + feeSplitter.split(); + assertEq(subAccounts.getBalance(accountA, cash, 0), 0, "Account A should have 0 balance after split"); + assertEq( + subAccounts.getBalance(accountB, cash, 0), int(100e18), "Account B should have all of the funds after split" + ); + } + + function test_setSubAccounts() public { + feeSplitter.setSubAccounts(accountB, accountA); + assertEq(feeSplitter.accountA(), accountB, "Incorrect accountA"); + assertEq(feeSplitter.accountB(), accountA, "Incorrect accountB"); + + vm.expectRevert(FeeSplitter.FS_InvalidSubAccount.selector); + feeSplitter.setSubAccounts(0, accountA); + + vm.expectRevert(FeeSplitter.FS_InvalidSubAccount.selector); + feeSplitter.setSubAccounts(accountB, 0); + } +}