From 9917d40f9ad5741de918b93abdd8836963493222 Mon Sep 17 00:00:00 2001 From: cwsnt Date: Wed, 28 Jan 2026 23:22:39 +0700 Subject: [PATCH 1/2] SOV-5302: introduce freeze logic for fee sharing collector --- .../FeeSharingCollector.sol | 40 +- .../FeeSharingCollectorStorage.sol | 5 + .../ContractsGuardianMultisig.json | 1141 +++++++++++++++++ hardhat/tasks/feeSharingCollector.js | 165 +++ tests/FeeSharingCollectorTest.js | 278 ++++ 5 files changed, 1627 insertions(+), 2 deletions(-) create mode 100644 deployment/deployments/rskSovrynMainnet/ContractsGuardianMultisig.json diff --git a/contracts/governance/FeeSharingCollector/FeeSharingCollector.sol b/contracts/governance/FeeSharingCollector/FeeSharingCollector.sol index 003550c99..6dddae196 100644 --- a/contracts/governance/FeeSharingCollector/FeeSharingCollector.sol +++ b/contracts/governance/FeeSharingCollector/FeeSharingCollector.sol @@ -116,6 +116,12 @@ contract FeeSharingCollector is address indexed newLoanTokenWrbtc ); + /// @notice An event emitted when contract is frozen + event Frozen(address indexed sender); + + /// @notice An event emitted when contract is unfrozen + event Unfrozen(address indexed sender); + /* Modifier */ modifier oneTimeExecution(bytes4 _funcSig) { require( @@ -126,6 +132,16 @@ contract FeeSharingCollector is isFunctionExecuted[_funcSig] = true; } + modifier whenNotFrozen() { + require(!frozen, "FeeSharingCollector: contract is frozen"); + _; + } + + modifier whenFrozen() { + require(frozen, "FeeSharingCollector: contract is not frozen"); + _; + } + /* Functions */ /// @dev fallback function to support rbtc transfer when unwrap the wrbtc. @@ -177,6 +193,26 @@ contract FeeSharingCollector is loanTokenWrbtcAddress = newLoanTokenWrbtcAddress; } + /** + * @notice Freeze the contract to prevent withdrawals. + * + * Only owner can perform this action. + * */ + function freeze() external onlyOwner whenNotFrozen { + frozen = true; + emit Frozen(msg.sender); + } + + /** + * @notice Unfreeze the contract to allow withdrawals. + * + * Only owner can perform this action. + * */ + function unfreeze() external onlyOwner whenFrozen { + frozen = false; + emit Unfrozen(msg.sender); + } + /** * @notice Withdraw fees for the given token: * lendingFee + tradingFee + borrowingFee @@ -398,7 +434,7 @@ contract FeeSharingCollector is address _token, uint32 _maxCheckpoints, address _receiver - ) public nonReentrant { + ) public nonReentrant whenNotFrozen { _withdraw(_token, _maxCheckpoints, _receiver); } @@ -561,7 +597,7 @@ contract FeeSharingCollector is TokenWithSkippedCheckpointsWithdraw[] calldata _tokensWithSkippedCheckpoints, uint32 _maxCheckpoints, address _receiver - ) external nonReentrant { + ) external nonReentrant whenNotFrozen { uint256 totalProcessedCheckpoints; /** Process normal multiple withdrawal for RBTC based tokens */ diff --git a/contracts/governance/FeeSharingCollector/FeeSharingCollectorStorage.sol b/contracts/governance/FeeSharingCollector/FeeSharingCollectorStorage.sol index b4ff4d71f..15bfc9b6d 100644 --- a/contracts/governance/FeeSharingCollector/FeeSharingCollectorStorage.sol +++ b/contracts/governance/FeeSharingCollector/FeeSharingCollectorStorage.sol @@ -86,6 +86,11 @@ contract FeeSharingCollectorStorage is Ownable { */ address public loanTokenWrbtcAddress; + /** + * @dev Contract frozen state + */ + bool public frozen; + /** * @dev Prevents a contract from calling itself, directly or indirectly. * If you mark a function `nonReentrant`, you should also diff --git a/deployment/deployments/rskSovrynMainnet/ContractsGuardianMultisig.json b/deployment/deployments/rskSovrynMainnet/ContractsGuardianMultisig.json new file mode 100644 index 000000000..6ff23c65a --- /dev/null +++ b/deployment/deployments/rskSovrynMainnet/ContractsGuardianMultisig.json @@ -0,0 +1,1141 @@ +{ + "address": "0xDd8e07A57560AdA0A2D84a96c457a5e6DDD488b7", + "abi": [ + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "AddedOwner", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "approvedHash", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "ApproveHash", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "handler", + "type": "address" + } + ], + "name": "ChangedFallbackHandler", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "guard", + "type": "address" + } + ], + "name": "ChangedGuard", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "threshold", + "type": "uint256" + } + ], + "name": "ChangedThreshold", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "DisabledModule", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "EnabledModule", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "payment", + "type": "uint256" + } + ], + "name": "ExecutionFailure", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "ExecutionFromModuleFailure", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "ExecutionFromModuleSuccess", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "payment", + "type": "uint256" + } + ], + "name": "ExecutionSuccess", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "RemovedOwner", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "module", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + } + ], + "name": "SafeModuleTransaction", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "safeTxGas", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "baseGas", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "gasPrice", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "gasToken", + "type": "address" + }, + { + "indexed": false, + "internalType": "address payable", + "name": "refundReceiver", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "signatures", + "type": "bytes" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "additionalInfo", + "type": "bytes" + } + ], + "name": "SafeMultiSigTransaction", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "SafeReceived", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "initiator", + "type": "address" + }, + { + "indexed": false, + "internalType": "address[]", + "name": "owners", + "type": "address[]" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "threshold", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "initializer", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "fallbackHandler", + "type": "address" + } + ], + "name": "SafeSetup", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "bytes32", + "name": "msgHash", + "type": "bytes32" + } + ], + "name": "SignMsg", + "type": "event" + }, + { + "stateMutability": "nonpayable", + "type": "fallback" + }, + { + "inputs": [], + "name": "VERSION", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_threshold", + "type": "uint256" + } + ], + "name": "addOwnerWithThreshold", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "hashToApprove", + "type": "bytes32" + } + ], + "name": "approveHash", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "approvedHashes", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_threshold", + "type": "uint256" + } + ], + "name": "changeThreshold", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "dataHash", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signatures", + "type": "bytes" + }, + { + "internalType": "uint256", + "name": "requiredSignatures", + "type": "uint256" + } + ], + "name": "checkNSignatures", + "outputs": [], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "dataHash", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "signatures", + "type": "bytes" + } + ], + "name": "checkSignatures", + "outputs": [], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "prevModule", + "type": "address" + }, + { + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "disableModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "domainSeparator", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "enableModule", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "gasToken", + "type": "address" + }, + { + "internalType": "address", + "name": "refundReceiver", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + } + ], + "name": "encodeTransactionData", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "gasToken", + "type": "address" + }, + { + "internalType": "address payable", + "name": "refundReceiver", + "type": "address" + }, + { + "internalType": "bytes", + "name": "signatures", + "type": "bytes" + } + ], + "name": "execTransaction", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + } + ], + "name": "execTransactionFromModule", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + } + ], + "name": "execTransactionFromModuleReturnData", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "returnData", + "type": "bytes" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "getChainId", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "start", + "type": "address" + }, + { + "internalType": "uint256", + "name": "pageSize", + "type": "uint256" + } + ], + "name": "getModulesPaginated", + "outputs": [ + { + "internalType": "address[]", + "name": "array", + "type": "address[]" + }, + { + "internalType": "address", + "name": "next", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getOwners", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "offset", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "length", + "type": "uint256" + } + ], + "name": "getStorageAt", + "outputs": [ + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "gasToken", + "type": "address" + }, + { + "internalType": "address", + "name": "refundReceiver", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + } + ], + "name": "getTransactionHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "module", + "type": "address" + } + ], + "name": "isModuleEnabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "isOwner", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "nonce", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "prevOwner", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_threshold", + "type": "uint256" + } + ], + "name": "removeOwner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + } + ], + "name": "requiredTxGas", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "handler", + "type": "address" + } + ], + "name": "setFallbackHandler", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "guard", + "type": "address" + } + ], + "name": "setGuard", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_owners", + "type": "address[]" + }, + { + "internalType": "uint256", + "name": "_threshold", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "address", + "name": "fallbackHandler", + "type": "address" + }, + { + "internalType": "address", + "name": "paymentToken", + "type": "address" + }, + { + "internalType": "uint256", + "name": "payment", + "type": "uint256" + }, + { + "internalType": "address payable", + "name": "paymentReceiver", + "type": "address" + } + ], + "name": "setup", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "signedMessages", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "targetContract", + "type": "address" + }, + { + "internalType": "bytes", + "name": "calldataPayload", + "type": "bytes" + } + ], + "name": "simulateAndRevert", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "prevOwner", + "type": "address" + }, + { + "internalType": "address", + "name": "oldOwner", + "type": "address" + }, + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "swapOwner", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "stateMutability": "payable", + "type": "receive" + } + ] +} diff --git a/hardhat/tasks/feeSharingCollector.js b/hardhat/tasks/feeSharingCollector.js index 37bb1941e..a3ccc9823 100644 --- a/hardhat/tasks/feeSharingCollector.js +++ b/hardhat/tasks/feeSharingCollector.js @@ -27,6 +27,36 @@ task( await setLoanTokenWrbtcAddress(hre, signer, true); }); +task("feeSharingCollector:freeze", "Freeze the FeeSharingCollector contract") + .addOptionalParam("signer", "Signer name: 'signer' or 'deployer'", "deployer") + .setAction(async ({ signer }, hre) => { + await freezeFeeSharingCollector(hre, signer); + }); + +task("feeSharingCollector:unfreeze", "Unfreeze the FeeSharingCollector contract") + .addOptionalParam("signer", "Signer name: 'signer' or 'deployer'", "deployer") + .setAction(async ({ signer }, hre) => { + await unfreezeFeeSharingCollector(hre, signer); + }); + +task( + "feeSharingCollector:setProxyOwner", + "Set the FeeSharingCollector proxy owner to ContractsGuardianMultisig" +) + .addOptionalParam("signer", "Signer name: 'signer' or 'deployer'", "deployer") + .setAction(async ({ signer }, hre) => { + await setFeeSharingCollectorProxyOwner(hre, signer); + }); + +task( + "feeSharingCollector:transferOwnership", + "Transfer FeeSharingCollector ownership to ContractsGuardianMultisig" +) + .addOptionalParam("signer", "Signer name: 'signer' or 'deployer'", "deployer") + .setAction(async ({ signer }, hre) => { + await transferFeeSharingCollectorOwnership(hre, signer); + }); + const initializeFeeSharingCollector = async (hre, signer) => { const { deployments: { get }, @@ -111,3 +141,138 @@ const setLoanTokenWrbtcAddress = async (hre, signer) => { let data = await iface.encodeFunctionData("setLoanTokenWrbtc", [loanWrbtcToken]); await sendWithMultisig(multisigDeployment.address, targetDeploymentAddress, data, signerAcc); }; + +const freezeFeeSharingCollector = async (hre, signer) => { + const { + deployments: { get }, + ethers, + } = hre; + + const feeSharingCollector = await ethers.getContract("FeeSharingCollector"); + const isFrozen = await feeSharingCollector.frozen(); + + if (isFrozen) { + logger.error("FeeSharingCollector is already frozen"); + return; + } + + const multisigDeployment = await get("MultiSigWallet"); + + const signerAcc = (await hre.getNamedAccounts())[signer]; + const targetDeploymentAddress = (await get("FeeSharingCollector")).address; + const iface = new ethers.utils.Interface(["function freeze()"]); + let data = await iface.encodeFunctionData("freeze", []); + + logger.warn("Creating multisig tx to freeze FeeSharingCollector..."); + await sendWithMultisig(multisigDeployment.address, targetDeploymentAddress, data, signerAcc); + logger.info(">>> DONE. Requires Multisig signing to execute tx <<<"); +}; + +const unfreezeFeeSharingCollector = async (hre, signer) => { + const { + deployments: { get }, + ethers, + } = hre; + + const feeSharingCollector = await ethers.getContract("FeeSharingCollector"); + const isFrozen = await feeSharingCollector.frozen(); + + if (!isFrozen) { + logger.error("FeeSharingCollector is not frozen"); + return; + } + + const multisigDeployment = await get("MultiSigWallet"); + + const signerAcc = (await hre.getNamedAccounts())[signer]; + const targetDeploymentAddress = (await get("FeeSharingCollector")).address; + const iface = new ethers.utils.Interface(["function unfreeze()"]); + let data = await iface.encodeFunctionData("unfreeze", []); + + logger.warn("Creating multisig tx to unfreeze FeeSharingCollector..."); + await sendWithMultisig(multisigDeployment.address, targetDeploymentAddress, data, signerAcc); + logger.info(">>> DONE. Requires Multisig signing to execute tx <<<"); +}; + +const setFeeSharingCollectorProxyOwner = async (hre, signer) => { + const { + deployments: { get }, + ethers, + } = hre; + + const contractsGuardianMultisig = (await get("ContractsGuardianMultisig")).address; + if (!ethers.utils.isAddress(contractsGuardianMultisig)) { + logger.error( + `ContractsGuardianMultisig - ${contractsGuardianMultisig} is invalid address` + ); + return; + } + + const feeSharingCollectorProxy = await get("FeeSharingCollector_Proxy"); + const proxy = await ethers.getContractAt( + "FeeSharingCollectorProxy", + feeSharingCollectorProxy.address + ); + + const currentProxyOwner = await proxy.getProxyOwner(); + logger.info(`Current proxy owner: ${currentProxyOwner}`); + + if (currentProxyOwner.toLowerCase() === contractsGuardianMultisig.toLowerCase()) { + logger.error("ContractsGuardianMultisig is already the proxy owner"); + return; + } + + const multisigDeployment = await get("MultiSigWallet"); + + const signerAcc = (await hre.getNamedAccounts())[signer]; + const iface = new ethers.utils.Interface(["function setProxyOwner(address _owner)"]); + let data = await iface.encodeFunctionData("setProxyOwner", [contractsGuardianMultisig]); + + logger.warn( + `Creating multisig tx to set proxy owner to ContractsGuardianMultisig (${contractsGuardianMultisig})...` + ); + await sendWithMultisig( + multisigDeployment.address, + feeSharingCollectorProxy.address, + data, + signerAcc + ); + logger.info(">>> DONE. Requires Multisig signing to execute tx <<<"); +}; + +const transferFeeSharingCollectorOwnership = async (hre, signer) => { + const { + deployments: { get }, + ethers, + } = hre; + + const contractsGuardianMultisig = (await get("ContractsGuardianMultisig")).address; + if (!ethers.utils.isAddress(contractsGuardianMultisig)) { + logger.error( + `ContractsGuardianMultisig - ${contractsGuardianMultisig} is invalid address` + ); + return; + } + + const feeSharingCollector = await ethers.getContract("FeeSharingCollector"); + const currentOwner = await feeSharingCollector.owner(); + logger.info(`Current owner: ${currentOwner}`); + + if (currentOwner.toLowerCase() === contractsGuardianMultisig.toLowerCase()) { + logger.error("ContractsGuardianMultisig is already the owner"); + return; + } + + const multisigDeployment = await get("MultiSigWallet"); + + const signerAcc = (await hre.getNamedAccounts())[signer]; + const targetDeploymentAddress = (await get("FeeSharingCollector")).address; + const iface = new ethers.utils.Interface(["function transferOwnership(address newOwner)"]); + let data = await iface.encodeFunctionData("transferOwnership", [contractsGuardianMultisig]); + + logger.warn( + `Creating multisig tx to transfer ownership to ContractsGuardianMultisig (${contractsGuardianMultisig})...` + ); + await sendWithMultisig(multisigDeployment.address, targetDeploymentAddress, data, signerAcc); + logger.info(">>> DONE. Requires Multisig signing to execute tx <<<"); +}; diff --git a/tests/FeeSharingCollectorTest.js b/tests/FeeSharingCollectorTest.js index a511b11f2..b757f1a8d 100644 --- a/tests/FeeSharingCollectorTest.js +++ b/tests/FeeSharingCollectorTest.js @@ -5369,6 +5369,284 @@ contract("FeeSharingCollector:", (accounts) => { }); }); + describe("freeze and unfreeze", async () => { + it("Should allow owner to freeze the contract", async () => { + await protocolDeploymentFixture(); + expect(await feeSharingCollector.frozen()).to.equal(false); + + const tx = await feeSharingCollector.freeze(); + + expect(await feeSharingCollector.frozen()).to.equal(true); + expectEvent(tx, "Frozen", { + sender: root, + }); + }); + + it("Should revert if non-owner tries to freeze", async () => { + await protocolDeploymentFixture(); + await expectRevert(feeSharingCollector.freeze({ from: account1 }), "unauthorized"); + }); + + it("Should revert if contract is already frozen", async () => { + await protocolDeploymentFixture(); + await feeSharingCollector.freeze(); + + await expectRevert( + feeSharingCollector.freeze(), + "FeeSharingCollector: contract is frozen" + ); + }); + + it("Should allow owner to unfreeze the contract", async () => { + await protocolDeploymentFixture(); + await feeSharingCollector.freeze(); + expect(await feeSharingCollector.frozen()).to.equal(true); + + const tx = await feeSharingCollector.unfreeze(); + + expect(await feeSharingCollector.frozen()).to.equal(false); + expectEvent(tx, "Unfrozen", { + sender: root, + }); + }); + + it("Should revert if non-owner tries to unfreeze", async () => { + await protocolDeploymentFixture(); + await feeSharingCollector.freeze(); + + await expectRevert(feeSharingCollector.unfreeze({ from: account1 }), "unauthorized"); + }); + + it("Should revert if contract is not frozen when trying to unfreeze", async () => { + await protocolDeploymentFixture(); + + await expectRevert( + feeSharingCollector.unfreeze(), + "FeeSharingCollector: contract is not frozen" + ); + }); + + it("Should block withdraw when contract is frozen", async () => { + await protocolDeploymentFixture(); + + // Setup: stake and create fees + let rootStake = 700; + await stake(rootStake, root); + let userStake = 300; + await SOVToken.transfer(account1, userStake); + await stake(userStake, account1); + + await setFeeTokensHeld( + new BN(wei("1", "gwei")), + new BN(wei("2", "gwei")), + new BN(wei("3", "gwei")), + false, + true + ); + await feeSharingCollector.withdrawFees([SOVToken.address]); + + // Freeze the contract + await feeSharingCollector.freeze(); + + // Try to withdraw - should fail + await expectRevert( + feeSharingCollector.withdraw(SOVToken.address, 10, ZERO_ADDRESS, { + from: account1, + }), + "FeeSharingCollector: contract is frozen" + ); + }); + + it("Should block claimAllCollectedFees when contract is frozen", async () => { + await protocolDeploymentFixture(); + + // Setup: stake and create fees + let rootStake = 700; + await stake(rootStake, root); + let userStake = 300; + await SOVToken.transfer(account1, userStake); + await stake(userStake, account1); + + await setFeeTokensHeld( + new BN(wei("1", "ether")), + new BN(wei("2", "ether")), + new BN(wei("3", "ether")) + ); + await feeSharingCollector.withdrawFees([SUSD.address]); + + // Freeze the contract + await feeSharingCollector.freeze(); + + // Try to claim - should fail + await expectRevert( + feeSharingCollector.claimAllCollectedFees( + [], + [RBTC_DUMMY_ADDRESS_FOR_CHECKPOINT], + [], + 1000, + ZERO_ADDRESS, + { + from: account1, + } + ), + "FeeSharingCollector: contract is frozen" + ); + }); + + it("Should allow withdraw after unfreezing", async () => { + await protocolDeploymentFixture(); + + // Setup: stake and create fees + let rootStake = 700; + await stake(rootStake, root); + let userStake = 300; + if (MOCK_PRIOR_WEIGHTED_STAKE) { + await staking.MOCK_priorWeightedStake(userStake * 10); + } + await SOVToken.transfer(account1, userStake); + await stake(userStake, account1); + + let feeAmount = await setFeeTokensHeld( + new BN(wei("1", "gwei")), + new BN(wei("2", "gwei")), + new BN(wei("3", "gwei")), + false, + true + ); + await feeSharingCollector.withdrawFees([SOVToken.address]); + + // Freeze the contract + await feeSharingCollector.freeze(); + + // Verify withdraw fails when frozen + await expectRevert( + feeSharingCollector.withdraw(SOVToken.address, 10, ZERO_ADDRESS, { + from: account1, + }), + "FeeSharingCollector: contract is frozen" + ); + + // Unfreeze the contract + await feeSharingCollector.unfreeze(); + + // Now withdraw should succeed + let userInitialBalance = await SOVToken.balanceOf(account1); + await feeSharingCollector.withdraw(SOVToken.address, 10, ZERO_ADDRESS, { + from: account1, + }); + + let userFinalBalance = await SOVToken.balanceOf(account1); + expect(userFinalBalance.sub(userInitialBalance).toNumber()).to.be.equal( + (feeAmount * 3) / 10 + ); + }); + + it("Should allow claimAllCollectedFees after unfreezing", async () => { + await protocolDeploymentFixture(); + + // Setup: stake and create fees + let rootStake = 700; + await stake(rootStake, root); + let userStake = 300; + if (MOCK_PRIOR_WEIGHTED_STAKE) { + await staking.MOCK_priorWeightedStake(userStake * 10); + } + await SOVToken.transfer(account1, userStake); + await stake(userStake, account1); + + let feeAmount = await setFeeTokensHeld( + new BN(wei("1", "ether")), + new BN(wei("2", "ether")), + new BN(wei("3", "ether")) + ); + await feeSharingCollector.withdrawFees([SUSD.address]); + + // Freeze the contract + await feeSharingCollector.freeze(); + + // Verify claim fails when frozen + await expectRevert( + feeSharingCollector.claimAllCollectedFees( + [], + [RBTC_DUMMY_ADDRESS_FOR_CHECKPOINT], + [], + 1000, + ZERO_ADDRESS, + { + from: account1, + } + ), + "FeeSharingCollector: contract is frozen" + ); + + // Unfreeze the contract + await feeSharingCollector.unfreeze(); + + // Now claim should succeed + let tx = await feeSharingCollector.claimAllCollectedFees( + [], + [RBTC_DUMMY_ADDRESS_FOR_CHECKPOINT], + [], + 1000, + ZERO_ADDRESS, + { + from: account1, + } + ); + + expectEvent(tx, "RBTCWithdrawn", { + sender: account1, + receiver: account1, + amount: feeAmount.mul(new BN(3)).div(new BN(10)), + }); + }); + + it("Should still allow withdrawFees when frozen (only user withdrawals are blocked)", async () => { + await protocolDeploymentFixture(); + + let totalStake = 1000; + await stake(totalStake, root); + + await setFeeTokensHeld( + new BN(wei("1", "ether")), + new BN(wei("2", "ether")), + new BN(wei("3", "ether")) + ); + + // Freeze the contract + await feeSharingCollector.freeze(); + + // withdrawFees should still work (protocol collecting fees) + let tx = await feeSharingCollector.withdrawFees([SUSD.address]); + + expectEvent(tx, "FeeWithdrawnInRBTC", { + sender: root, + }); + }); + + it("Should still allow transferTokens when frozen", async () => { + await protocolDeploymentFixture(); + + let totalStake = 1000; + await stake(totalStake, root); + + let amount = 1000; + await SOVToken.approve(feeSharingCollector.address, amount); + + // Freeze the contract + await feeSharingCollector.freeze(); + + // transferTokens should still work + let tx = await feeSharingCollector.transferTokens(SOVToken.address, amount); + + expectEvent(tx, "TokensTransferred", { + sender: root, + token: SOVToken.address, + amount: new BN(amount), + }); + }); + }); + describe("test coverage", async () => { it("Token transfer failed", async () => { await protocolDeploymentFixture(); From e32ea248f3f3f70af7604ec6a2db4ea69433ab4d Mon Sep 17 00:00:00 2001 From: cwsnt Date: Wed, 4 Feb 2026 13:37:35 +0700 Subject: [PATCH 2/2] SOV-5302: add freeze & unfreeze safe tx json --- safe/mainnet/Freeze RSK Mainnet FeeSharingCollector.json | 1 + safe/mainnet/Unfreeze RSK Mainnet FeeSharingCollector.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 safe/mainnet/Freeze RSK Mainnet FeeSharingCollector.json create mode 100644 safe/mainnet/Unfreeze RSK Mainnet FeeSharingCollector.json diff --git a/safe/mainnet/Freeze RSK Mainnet FeeSharingCollector.json b/safe/mainnet/Freeze RSK Mainnet FeeSharingCollector.json new file mode 100644 index 000000000..00d8fd8b6 --- /dev/null +++ b/safe/mainnet/Freeze RSK Mainnet FeeSharingCollector.json @@ -0,0 +1 @@ +{"version":"1.0","chainId":"30","createdAt":1738713600000,"meta":{"name":"Freeze RSK Mainnet FeeSharingCollector","description":"Freeze the FeeSharingCollector contract to prevent withdrawals","txBuilderVersion":"1.16.1","createdFromSafeAddress":"0xDd8e07A57560AdA0A2D84a96c457a5e6DDD488b7","createdFromOwnerAddress":"","checksum":""},"transactions":[{"to":"0x115cAF168c51eD15ec535727F64684D33B7b08D1","value":"0","data":null,"contractMethod":{"inputs":[],"name":"freeze","payable":false},"contractInputsValues":{}}]} diff --git a/safe/mainnet/Unfreeze RSK Mainnet FeeSharingCollector.json b/safe/mainnet/Unfreeze RSK Mainnet FeeSharingCollector.json new file mode 100644 index 000000000..ce5ada5b6 --- /dev/null +++ b/safe/mainnet/Unfreeze RSK Mainnet FeeSharingCollector.json @@ -0,0 +1 @@ +{"version":"1.0","chainId":"30","createdAt":1738713600000,"meta":{"name":"Unfreeze RSK Mainnet FeeSharingCollector","description":"Unfreeze the FeeSharingCollector contract to allow withdrawals","txBuilderVersion":"1.16.1","createdFromSafeAddress":"0xDd8e07A57560AdA0A2D84a96c457a5e6DDD488b7","createdFromOwnerAddress":"","checksum":""},"transactions":[{"to":"0x115cAF168c51eD15ec535727F64684D33B7b08D1","value":"0","data":null,"contractMethod":{"inputs":[],"name":"unfreeze","payable":false},"contractInputsValues":{}}]}