diff --git a/contracts/SdexEvents.sol b/contracts/SdexEvents.sol index 184921b..66f2882 100644 --- a/contracts/SdexEvents.sol +++ b/contracts/SdexEvents.sol @@ -61,7 +61,7 @@ library SdexEvents { /* @notice Emitted when accumulated protocol fees are collected by the treasury. * @param token The token of the fees being collected. * @param recv The vault the collected fees are being paid to. */ - event ProtocolDividend (address indexed token, address indexed recv); + event ProtocolDividend (address[] indexed tokens, address indexed recv); /* @notice Called when any proxy sidecar contract is upgraded. * @param proxy The address of the new proxy smart contract. diff --git a/contracts/callpaths/ColdPath.sol b/contracts/callpaths/ColdPath.sol index ede808e..bf3defa 100644 --- a/contracts/callpaths/ColdPath.sol +++ b/contracts/callpaths/ColdPath.sol @@ -34,6 +34,15 @@ contract ColdPath is MarketSequencer, DepositDesk, ProtocolAccount { using Chaining for Chaining.PairFlow; using ProtocolCmd for bytes; + /** @dev ONLY APPLY CONSTANT VARIABLE HERE */ + uint256 public constant TREASURY_START_TIME_OFFSET = 0 days; + + /* @dev access control for treasury role */ + modifier onlyTreasury() { + require(msg.sender == treasury_, "Only Treasury"); + _; + } + /* @notice Consolidated method for protocol control related commands. */ function protocolCmd (bytes calldata cmd) virtual public { uint8 code = uint8(cmd[31]); @@ -71,9 +80,7 @@ contract ColdPath is MarketSequencer, DepositDesk, ProtocolAccount { require(sudoMode_, "Sudo"); uint8 cmdCode = uint8(cmd[31]); - if (cmdCode == ProtocolCmd.COLLECT_TREASURY_CODE) { - collectProtocol(cmd); - } else if (cmdCode == ProtocolCmd.SET_TREASURY_CODE) { + if (cmdCode == ProtocolCmd.SET_TREASURY_CODE) { setTreasury(cmd); } else if (cmdCode == ProtocolCmd.AUTHORITY_TRANSFER_CODE) { transferAuthority(cmd); @@ -110,6 +117,8 @@ contract ColdPath is MarketSequencer, DepositDesk, ProtocolAccount { resetNonceCond(cmd); } else if (cmdCode == UserCmd.GATE_ORACLE_COND) { checkGateOracle(cmd); + } else if (cmdCode == UserCmd.COLLECT_TREASURY_CODE) { + collectProtocol(cmd); } else { revert("Invalid command"); } @@ -242,22 +251,22 @@ contract ColdPath is MarketSequencer, DepositDesk, ProtocolAccount { /* @notice Pays out the the protocol fees. * @param token The token for which the accumulated fees are being paid out. * (Or if 0x0 pays out native Ethereum.) */ - function collectProtocol (bytes calldata cmd) private { - (, address token) = abi.decode(cmd, (uint8, address)); + function collectProtocol (bytes calldata cmd) private onlyTreasury { + (, address[] memory tokens) = abi.decode(cmd, (uint8, address[])); require(block.timestamp >= treasuryStartTime_, "Treasury start"); - emit SdexEvents.ProtocolDividend(token, treasury_); - disburseProtocolFees(treasury_, token); + emit SdexEvents.ProtocolDividend(tokens, treasury_); + disburseProtocolFees(tokens); } /* @notice Sets the treasury address to receive protocol fees. Once set, the treasury cannot - * receive fees until 7 days after. */ + * receive fees until the start time offset. */ function setTreasury (bytes calldata cmd) private { (, address treasury) = abi.decode(cmd, (uint8, address)); require(treasury != address(0) && treasury.code.length != 0, "Treasury invalid"); treasury_ = treasury; - treasuryStartTime_ = uint64(block.timestamp + 7 days); + treasuryStartTime_ = uint64(block.timestamp + TREASURY_START_TIME_OFFSET); emit SdexEvents.TreasurySet(treasury_, treasuryStartTime_); } diff --git a/contracts/callpaths/WarmPath.sol b/contracts/callpaths/WarmPath.sol index ac589fd..dad11c7 100644 --- a/contracts/callpaths/WarmPath.sol +++ b/contracts/callpaths/WarmPath.sol @@ -11,7 +11,6 @@ import '../mixins/MarketSequencer.sol'; import '../mixins/SettleLayer.sol'; import '../mixins/PoolRegistry.sol'; import '../mixins/MarketSequencer.sol'; -import '../mixins/ProtocolAccount.sol'; import '../SdexEvents.sol'; /* @title Warm path callpath sidecar. @@ -31,7 +30,7 @@ import '../SdexEvents.sol'; * not state. As such it should never be called directly or externally, and should * only be invoked with DELEGATECALL so that it operates on the contract state * within the primary SdexSwap contract. */ -contract WarmPath is MarketSequencer, SettleLayer, ProtocolAccount { +contract WarmPath is MarketSequencer, SettleLayer { using SafeCast for uint128; using TokenFlow for TokenFlow.PairSeq; diff --git a/contracts/interfaces/IFeeProtocolCollector.sol b/contracts/interfaces/IFeeProtocolCollector.sol new file mode 100644 index 0000000..486b98e --- /dev/null +++ b/contracts/interfaces/IFeeProtocolCollector.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.19; + +/// @title Minimal ERC20 interface for Uniswap +/// @notice Contains a subset of the full ERC20 interface that is used in Uniswap V3 +interface IFeeProtocolCollector { + function transferTokens(address _token, uint96 _amount) external; +} diff --git a/contracts/libraries/ProtocolCmd.sol b/contracts/libraries/ProtocolCmd.sol index 70f51b6..2a2113b 100644 --- a/contracts/libraries/ProtocolCmd.sol +++ b/contracts/libraries/ProtocolCmd.sol @@ -27,8 +27,6 @@ library ProtocolCmd { uint8 constant HOT_OPEN_CODE = 22; // Code to toggle on or off emergency safe mode uint8 constant SAFE_MODE_CODE = 23; - // Code to collect accumulated protocol fees for the treasury. - uint8 constant COLLECT_TREASURY_CODE = 40; // Code to set the protocol treasury uint8 constant SET_TREASURY_CODE = 41; //////////////////////////////////////////////////////////////////////////// @@ -113,4 +111,11 @@ library UserCmd { uint8 constant BURN_KNOCKOUT = 92; uint8 constant CLAIM_KNOCKOUT = 93; uint8 constant RECOVER_KNOCKOUT = 94; + + + //////////////////////////////////////////////////////////////////////////// + // Protocol Fee command codes + //////////////////////////////////////////////////////////////////////////// + // Code to collect accumulated protocol fees for the treasury. + uint8 constant COLLECT_TREASURY_CODE = 40; } diff --git a/contracts/mixins/ProtocolAccount.sol b/contracts/mixins/ProtocolAccount.sol index 0c85f4b..8ee58b8 100644 --- a/contracts/mixins/ProtocolAccount.sol +++ b/contracts/mixins/ProtocolAccount.sol @@ -7,6 +7,8 @@ import '../libraries/TokenFlow.sol'; import '../libraries/SafeCast.sol'; import './StorageLayout.sol'; +import '../interfaces/IFeeProtocolCollector.sol'; + /* @title Protocol Account Mixin * @notice Tracks and pays out the accumulated protocol fees across the entire exchange * These are the fees belonging to the SdexSwap protocol, not the liquidity @@ -16,6 +18,9 @@ import './StorageLayout.sol'; contract ProtocolAccount is StorageLayout { using SafeCast for uint256; using TokenFlow for address; + + address public constant PROTOCOL_FEES_RECEIVER_HASH = + address(uint160(uint256(keccak256("PROTOCOL_FEES_RECEIVER_HASH")))); /* @notice Called at the completion of a swap event, incrementing any protocol * fees accumulated in the swap. */ @@ -35,14 +40,27 @@ contract ProtocolAccount is StorageLayout { } /* @notice Pays out the earned, but unclaimed protocol fees in the pool. - * @param recv - The receiver of the protocol fees. - * @param token - The token address of the quote token. */ - function disburseProtocolFees (address recv, address token) internal { - uint128 collected = feesAccum_[token]; - feesAccum_[token] = 0; - if (collected > 0) { - bytes32 payoutKey = keccak256(abi.encode(recv, token)); - userBals_[payoutKey].surplusCollateral_ += collected; + * @param tokens - The token address of the quote token. + */ + function disburseProtocolFees (address[] memory tokens) internal { + for(uint256 i = 0; i < tokens.length; i++) { + address token = tokens[i]; + uint128 collected = feesAccum_[token]; + feesAccum_[token] = 0; + if (collected > 0) { + /** + * directly deposit token to fee protocol collector + */ + bytes32 payoutKey = keccak256(abi.encode(PROTOCOL_FEES_RECEIVER_HASH, token)); + userBals_[payoutKey].surplusCollateral_ += collected; + require(userBals_[payoutKey].surplusCollateral_ <= type(uint96).max, "Value exceeds uint96 range"); + + uint256 amountToTransfer = uint96(userBals_[payoutKey].surplusCollateral_); + + IERC20Minimal(token).approve(treasury_, amountToTransfer); + IFeeProtocolCollector(treasury_).transferTokens(token, uint96(amountToTransfer)); + userBals_[payoutKey].surplusCollateral_ = 0; + } } } } diff --git a/contracts/test/MockTimelock.sol b/contracts/test/MockTimelock.sol index c5170e4..64219f5 100644 --- a/contracts/test/MockTimelock.sol +++ b/contracts/test/MockTimelock.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.19; import "../governance/SdexPolicy.sol"; +import "../SdexSwapDex.sol"; import "hardhat/console.sol"; contract MockTimelock { @@ -47,4 +48,8 @@ contract MockTimelock { return SdexPolicy(policy_).setPolicy(conduit, proxyPath, policy); } + function userCmd (address minion, uint16 proxyPath, + bytes calldata cmd) public { + SdexSwapDex(minion).userCmd(proxyPath, cmd); + } } diff --git a/contracts/test/TestProtocolAcct.sol b/contracts/test/TestProtocolAcct.sol index cd8d7a5..94ee78b 100644 --- a/contracts/test/TestProtocolAcct.sol +++ b/contracts/test/TestProtocolAcct.sol @@ -32,8 +32,8 @@ contract TestProtocolAccount is ProtocolAccount { return feesAccum_[token]; } - function disburseProtocol (address recv, address token) public { - disburseProtocolFees(recv, token); + function disburseProtocol (address[] memory tokens) public { + disburseProtocolFees(tokens); } function getPaidFees (address recv, address token) public view returns (uint128) { diff --git a/test/TestPool.govern.ts b/test/TestPool.govern.ts index 9eeacdb..b7be25c 100644 --- a/test/TestPool.govern.ts +++ b/test/TestPool.govern.ts @@ -148,44 +148,39 @@ describe('Pool Governance', () => { }) - it("collect treasury", async() => { + it("successfully collect treasury without time delay", async() => { await test.testRevisePool(feeRate, 128, 1) // Turn on protocol fee - await pool.connect(await test.auth).protocolCmd(test.COLD_PROXY, transferCmd(policy.address), true) + await pool.connect(await test.auth).protocolCmd(test.COLD_PROXY, transferCmd(policy2.address), true) await test.testMintAmbient(10000) await test.testSwap(true, false, 100000, MAX_PRICE) - await treasury.treasuryResolution(pool.address, test.COLD_PROXY, treasurySetCmd(policy2.address), true) - await hre.ethers.provider.send("evm_increaseTime", [3600*24*7+1]) // 7 days + await treasury2.treasuryResolution(pool.address, test.COLD_PROXY, treasurySetCmd(treasury2.address), true) // Unauthorized attempts to collect treasury - await expect(pool.protocolCmd(test.COLD_PROXY, collectCmd(), true)).to.be.reverted - await expect(ops.opsResolution(pool.address, test.COLD_PROXY, collectCmd())).to.be.reverted - await expect(ops.treasuryResolution(pool.address, test.COLD_PROXY, collectCmd(), true)).to.be.reverted + await expect(treasury.userCmd(pool.address, test.COLD_PROXY, collectCmd())).to.be.revertedWith("Only Treasury") // Successful treasury payout - let snap = await (await test.query).querySurplus(policy2.address, baseToken.address) - await treasury.treasuryResolution(pool.address, test.COLD_PROXY, collectCmd(), true) - expect(await (await test.query).querySurplus(policy2.address, baseToken.address)).to.gt(snap); + let snap = await (await test.query).querySurplus(treasury2.address, baseToken.address) + await treasury2.userCmd(pool.address, test.COLD_PROXY, collectCmd()) + expect(await (await test.query).querySurplus(treasury2.address, baseToken.address)).to.gt(snap); }) - it("collect treasury time delay", async() => { + it("successfully collect treasury with time delay", async() => { await test.testRevisePool(feeRate, 128, 1) // Turn on protocol fee - await pool.connect(await test.auth).protocolCmd(test.COLD_PROXY, transferCmd(policy.address), true) + await pool.connect(await test.auth).protocolCmd(test.COLD_PROXY, transferCmd(policy2.address), true) await test.testMintAmbient(10000) await test.testSwap(true, false, 100000, MAX_PRICE) - await treasury.treasuryResolution(pool.address, test.COLD_PROXY, treasurySetCmd(policy2.address), true) + await treasury2.treasuryResolution(pool.address, test.COLD_PROXY, treasurySetCmd(treasury2.address), true) - // Will fail because treasury can only be collected 7 days after treasury address is set - await expect(treasury.treasuryResolution(pool.address, test.COLD_PROXY, collectCmd(), true)).to.be.reverted - await hre.ethers.provider.send("evm_increaseTime", [3600*24*6]) // 6 days - await expect(treasury.treasuryResolution(pool.address, test.COLD_PROXY, collectCmd(), true)).to.be.reverted - await hre.ethers.provider.send("evm_increaseTime", [3600*24+1]) // One more day... treasury valid + // Unauthorized attempts to collect treasury + await expect(treasury.userCmd(pool.address, test.COLD_PROXY, collectCmd())).to.be.revertedWith("Only Treasury") + await hre.ethers.provider.send("evm_increaseTime", [3600*24*6]) // 6 days // Successful treasury payout - let snap = await (await test.query).querySurplus(policy2.address, baseToken.address) - await treasury.treasuryResolution(pool.address, test.COLD_PROXY, collectCmd(), true) - expect(await (await test.query).querySurplus(policy2.address, baseToken.address)).to.gt(snap); + let snap = await (await test.query).querySurplus(treasury2.address, baseToken.address) + await treasury2.userCmd(pool.address, test.COLD_PROXY, collectCmd()) + expect(await (await test.query).querySurplus(treasury2.address, baseToken.address)).to.gt(snap); })