From 5e81fe72f7daa87f8f618bb710a4c2785eb702b3 Mon Sep 17 00:00:00 2001 From: akash Date: Thu, 6 Nov 2025 17:08:53 +0530 Subject: [PATCH 1/2] feat: added pausable to watcher,socket --- contracts/evmx/watcher/Watcher.sol | 21 +++++++++++++--- contracts/protocol/Socket.sol | 21 +++++++++++++--- contracts/utils/Pausable.sol | 40 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 contracts/utils/Pausable.sol diff --git a/contracts/evmx/watcher/Watcher.sol b/contracts/evmx/watcher/Watcher.sol index 61ad4a19..7c042489 100644 --- a/contracts/evmx/watcher/Watcher.sol +++ b/contracts/evmx/watcher/Watcher.sol @@ -8,12 +8,13 @@ import {IFeesManager} from "../interfaces/IFeesManager.sol"; import {IPromise} from "../interfaces/IPromise.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import "../../utils/common/IdUtils.sol"; +import "../../utils/Pausable.sol"; import "solady/utils/LibCall.sol"; /// @title Watcher /// @notice Minimal request → payloads container with no batch/auction logic. /// @dev Lives alongside existing Watcher without modifying current code. -contract Watcher is Initializable, Configurations { +contract Watcher is Initializable, Configurations, Pausable { using LibCall for address; uint256 public nextPayloadCount; @@ -70,7 +71,7 @@ contract Watcher is Initializable, Configurations { /// @notice Submit a request containing a single payload. No batches/auctions. /// @dev Deploys promise via asyncDeployer and stores payload directly. - function executePayload() external returns (address asyncPromise) { + function executePayload() external whenNotPaused returns (address asyncPromise) { if (latestAppGateway != msg.sender) revert AppGatewayMismatch(); if ( !feesManager__().isCreditSpendable( @@ -113,7 +114,7 @@ contract Watcher is Initializable, Configurations { emit PayloadSubmitted(_payloads[currentPayloadId]); } - function resolvePayload(WatcherMultiCallParams memory params_) external { + function resolvePayload(WatcherMultiCallParams memory params_) external whenNotPaused { _validateSignature(address(this), params_.data, params_.nonce, params_.signature); (PromiseReturnData memory resolvedPromise, uint256 feesUsed) = abi.decode(params_.data, (PromiseReturnData, uint256)); @@ -317,4 +318,18 @@ contract Watcher is Initializable, Configurations { ) external view returns (uint256) { return precompiles[callType_].getPrecompileFees(precompileData_); } + + //////////////////////////////////////////////////////// + ////////////////////// Pausable //////////////////////// + //////////////////////////////////////////////////////// + + /// @notice Pause the contract (only owner) + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause the contract (only owner) + function unpause() external onlyOwner { + _unpause(); + } } diff --git a/contracts/protocol/Socket.sol b/contracts/protocol/Socket.sol index 860e11f0..83af0006 100644 --- a/contracts/protocol/Socket.sol +++ b/contracts/protocol/Socket.sol @@ -5,13 +5,14 @@ import "./SocketUtils.sol"; import {WRITE} from "../utils/common/Constants.sol"; import {getVerificationInfo} from "../utils/common/IdUtils.sol"; +import "../utils/Pausable.sol"; /** * @title Socket * @dev Socket is an abstract contract that inherits from SocketUtils and SocketConfig and * provides functionality for payload execution, verification, and management of payload execution status */ -contract Socket is SocketUtils { +contract Socket is SocketUtils, Pausable { using LibCall for address; // mapping of payload id to execution status @@ -59,7 +60,7 @@ contract Socket is SocketUtils { function execute( ExecuteParams calldata executeParams_, TransmissionParams calldata transmissionParams_ - ) external payable returns (bool, bytes memory) { + ) external payable whenNotPaused returns (bool, bytes memory) { // check if the deadline has passed if (executeParams_.deadline < block.timestamp) revert DeadlinePassed(); @@ -216,7 +217,7 @@ contract Socket is SocketUtils { address plug_, uint256 value_, bytes calldata data_ - ) internal returns (bytes32 payloadId) { + ) internal whenNotPaused returns (bytes32 payloadId) { (uint64 switchboardId, address switchboardAddress) = _verifyPlugSwitchboard(plug_); bytes memory plugOverrides = IPlug(plug_).overrides(); @@ -269,4 +270,18 @@ contract Socket is SocketUtils { receive() external payable { revert("Socket does not accept ETH"); } + + //////////////////////////////////////////////////////// + ////////////////////// Pausable //////////////////////// + //////////////////////////////////////////////////////// + + /// @notice Pause the contract (only owner) + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause the contract (only owner) + function unpause() external onlyOwner { + _unpause(); + } } diff --git a/contracts/utils/Pausable.sol b/contracts/utils/Pausable.sol new file mode 100644 index 00000000..b529d97d --- /dev/null +++ b/contracts/utils/Pausable.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +/** + * @title Pausable + * @dev Base contract that provides pausable functionality + * @notice This contract can be inherited to add pause/unpause capabilities + */ +abstract contract Pausable { + /// @notice Thrown when the contract is paused + error ContractPaused(); + + /// @notice Paused state + bool public paused; + + /// @notice Event emitted when contract is paused + event Paused(); + + /// @notice Event emitted when contract is unpaused + event Unpaused(); + + /// @notice Modifier to check if contract is not paused + modifier whenNotPaused() { + if (paused) revert ContractPaused(); + _; + } + + /// @notice Internal function to pause the contract + function _pause() internal { + paused = true; + emit Paused(); + } + + /// @notice Internal function to unpause the contract + function _unpause() internal { + paused = false; + emit Unpaused(); + } +} + From 5dd37707c1f4a435bf3109552f7c7108a659aa45 Mon Sep 17 00:00:00 2001 From: akash Date: Fri, 7 Nov 2025 23:26:09 +0530 Subject: [PATCH 2/2] feat: pausable tests, review fixes --- contracts/evmx/watcher/Configurations.sol | 5 +- contracts/evmx/watcher/Watcher.sol | 9 +- contracts/protocol/Socket.sol | 9 +- contracts/utils/Pausable.sol | 39 +++- contracts/utils/common/AccessRoles.sol | 4 + test/PausableTest.t.sol | 271 ++++++++++++++++++++++ test/SetupTest.t.sol | 2 +- 7 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 test/PausableTest.t.sol diff --git a/contracts/evmx/watcher/Configurations.sol b/contracts/evmx/watcher/Configurations.sol index 6ea80ada..0e8a373a 100644 --- a/contracts/evmx/watcher/Configurations.sol +++ b/contracts/evmx/watcher/Configurations.sol @@ -4,8 +4,7 @@ pragma solidity ^0.8.21; import "../interfaces/IConfigurations.sol"; import "../../utils/common/Errors.sol"; import "../helpers/AddressResolverUtil.sol"; -import "solady/auth/Ownable.sol"; - +import "../../utils/AccessControl.sol"; import "../../utils/common/Converters.sol"; import "../../utils/common/Structs.sol"; import "solady/utils/ECDSA.sol"; @@ -46,7 +45,7 @@ abstract contract ConfigurationsStorage is IWatcher { /// @title Configurations /// @notice Configuration contract for the Watcher Precompile system /// @dev Handles the mapping between networks, plugs, and app gateways for payload execution -abstract contract Configurations is ConfigurationsStorage, Ownable, AddressResolverUtil { +abstract contract Configurations is ConfigurationsStorage, AccessControl, AddressResolverUtil { /// @notice Emitted when a new plug is configured for an app gateway /// @param appGatewayId The id of the app gateway /// @param chainSlug The identifier of the destination network diff --git a/contracts/evmx/watcher/Watcher.sol b/contracts/evmx/watcher/Watcher.sol index 7c042489..8e28500b 100644 --- a/contracts/evmx/watcher/Watcher.sol +++ b/contracts/evmx/watcher/Watcher.sol @@ -9,6 +9,7 @@ import {IPromise} from "../interfaces/IPromise.sol"; import {IERC20} from "../interfaces/IERC20.sol"; import "../../utils/common/IdUtils.sol"; import "../../utils/Pausable.sol"; +import {PAUSER_ROLE, UNPAUSER_ROLE} from "../../utils/common/AccessRoles.sol"; import "solady/utils/LibCall.sol"; /// @title Watcher @@ -323,13 +324,13 @@ contract Watcher is Initializable, Configurations, Pausable { ////////////////////// Pausable //////////////////////// //////////////////////////////////////////////////////// - /// @notice Pause the contract (only owner) - function pause() external onlyOwner { + /// @notice Pause the contract (only pauser role) + function pause() external onlyRole(PAUSER_ROLE) { _pause(); } - /// @notice Unpause the contract (only owner) - function unpause() external onlyOwner { + /// @notice Unpause the contract (only unpauser role) + function unpause() external onlyRole(UNPAUSER_ROLE) { _unpause(); } } diff --git a/contracts/protocol/Socket.sol b/contracts/protocol/Socket.sol index 83af0006..d044bed9 100644 --- a/contracts/protocol/Socket.sol +++ b/contracts/protocol/Socket.sol @@ -6,6 +6,7 @@ import "./SocketUtils.sol"; import {WRITE} from "../utils/common/Constants.sol"; import {getVerificationInfo} from "../utils/common/IdUtils.sol"; import "../utils/Pausable.sol"; +import {PAUSER_ROLE, UNPAUSER_ROLE} from "../utils/common/AccessRoles.sol"; /** * @title Socket @@ -275,13 +276,13 @@ contract Socket is SocketUtils, Pausable { ////////////////////// Pausable //////////////////////// //////////////////////////////////////////////////////// - /// @notice Pause the contract (only owner) - function pause() external onlyOwner { + /// @notice Pause the contract (only pauser role) + function pause() external onlyRole(PAUSER_ROLE) { _pause(); } - /// @notice Unpause the contract (only owner) - function unpause() external onlyOwner { + /// @notice Unpause the contract (only unpauser role) + function unpause() external onlyRole(UNPAUSER_ROLE) { _unpause(); } } diff --git a/contracts/utils/Pausable.sol b/contracts/utils/Pausable.sol index b529d97d..aadc803b 100644 --- a/contracts/utils/Pausable.sol +++ b/contracts/utils/Pausable.sol @@ -5,35 +5,62 @@ pragma solidity ^0.8.21; * @title Pausable * @dev Base contract that provides pausable functionality * @notice This contract can be inherited to add pause/unpause capabilities + * @dev Uses a dedicated storage slot to avoid storage collisions */ abstract contract Pausable { + /// @notice Storage slot for pausable state + bytes32 private constant STORAGE_SLOT = keccak256("socket.storage.Pausable"); + /// @notice Thrown when the contract is paused error ContractPaused(); - /// @notice Paused state - bool public paused; - /// @notice Event emitted when contract is paused event Paused(); /// @notice Event emitted when contract is unpaused event Unpaused(); + /// @notice Returns the paused state of the contract + function paused() public view returns (bool) { + bytes32 slot = STORAGE_SLOT; + bool result; + assembly { + result := sload(slot) + } + return result; + } + /// @notice Modifier to check if contract is not paused modifier whenNotPaused() { - if (paused) revert ContractPaused(); + if (paused()) revert ContractPaused(); _; } /// @notice Internal function to pause the contract function _pause() internal { - paused = true; + bytes32 slot = STORAGE_SLOT; + bool current; + assembly { + current := sload(slot) + } + if (current) return; + assembly { + sstore(slot, 1) + } emit Paused(); } /// @notice Internal function to unpause the contract function _unpause() internal { - paused = false; + bytes32 slot = STORAGE_SLOT; + bool current; + assembly { + current := sload(slot) + } + if (!current) return; + assembly { + sstore(slot, 0) + } emit Unpaused(); } } diff --git a/contracts/utils/common/AccessRoles.sol b/contracts/utils/common/AccessRoles.sol index e8bb602a..e665c989 100644 --- a/contracts/utils/common/AccessRoles.sol +++ b/contracts/utils/common/AccessRoles.sol @@ -16,3 +16,7 @@ bytes32 constant SWITCHBOARD_DISABLER_ROLE = keccak256("SWITCHBOARD_DISABLER_ROL bytes32 constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); // used by oracle to update minimum message value fees bytes32 constant FEE_UPDATER_ROLE = keccak256("FEE_UPDATER_ROLE"); + +bytes32 constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + +bytes32 constant UNPAUSER_ROLE = keccak256("UNPAUSER_ROLE"); \ No newline at end of file diff --git a/test/PausableTest.t.sol b/test/PausableTest.t.sol new file mode 100644 index 00000000..6f5e9bb9 --- /dev/null +++ b/test/PausableTest.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; +import "../contracts/protocol/Socket.sol"; +import "../contracts/evmx/watcher/Watcher.sol"; +import "../contracts/evmx/helpers/AddressResolver.sol"; +import "../contracts/utils/common/AccessRoles.sol"; +import "../contracts/utils/Pausable.sol"; +import "../contracts/utils/AccessControl.sol"; +import "solady/utils/ERC1967Factory.sol"; + +/** + * @title PausableTest + * @notice Unit tests for pause/unpause functionality with PAUSER_ROLE and UNPAUSER_ROLE + */ +contract PausableTest is Test { + // Test addresses + address owner = address(0x1000); + address pauser = address(0x2000); + address unpauser = address(0x3000); + address unauthorized = address(0x4000); + + // Test constants + uint32 constant CHAIN_SLUG = 1; + string constant VERSION = "test"; + + // Contracts + Socket socket; + Watcher watcher; + + // Events + event Paused(); + event Unpaused(); + event RoleGranted(bytes32 indexed role, address indexed grantee); + event RoleRevoked(bytes32 indexed role, address indexed revokee); + + AddressResolver addressResolver; + + function setUp() public { + // Deploy Socket + socket = new Socket(CHAIN_SLUG, owner, VERSION); + + ERC1967Factory proxyFactory = new ERC1967Factory(); + // Deploy and initialize Watcher + Watcher watcherImpl = new Watcher(); + bytes memory data = abi.encodeWithSelector(Watcher.initialize.selector, 1, owner, address(0), address(0), 0); + watcher = Watcher(proxyFactory.deployAndCall(address(watcherImpl), owner, data)); + } + + // ==================== Socket Tests ==================== + + function test_Socket_Pause_ByOwner_ShouldRevert() public { + vm.prank(owner); + vm.expectRevert(); + socket.pause(); + } + + function test_Socket_Pause_ByPauser_ShouldSucceed() public { + vm.prank(owner); + socket.grantRole(PAUSER_ROLE, pauser); + + vm.prank(pauser); + vm.expectEmit(true, false, false, false); + emit Paused(); + socket.pause(); + + assertTrue(socket.paused()); + } + + function test_Socket_Pause_ByUnauthorized_ShouldRevert() public { + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSelector(AccessControl.NoPermit.selector, PAUSER_ROLE)); + socket.pause(); + } + + function test_Socket_Unpause_ByOwner_ShouldRevert() public { + // First pause it + vm.prank(owner); + socket.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + socket.pause(); + + // Try to unpause as owner (should fail) + vm.prank(owner); + vm.expectRevert(); + socket.unpause(); + } + + function test_Socket_Unpause_ByUnpauser_ShouldSucceed() public { + // First pause it + vm.prank(owner); + socket.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + socket.pause(); + + // Grant unpauser role and unpause + vm.prank(owner); + socket.grantRole(UNPAUSER_ROLE, unpauser); + + vm.prank(unpauser); + vm.expectEmit(true, false, false, false); + emit Unpaused(); + socket.unpause(); + + assertFalse(socket.paused()); + } + + function test_Socket_Unpause_ByUnauthorized_ShouldRevert() public { + // First pause it + vm.prank(owner); + socket.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + socket.pause(); + + // Try to unpause as unauthorized + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSelector(AccessControl.NoPermit.selector, UNPAUSER_ROLE)); + socket.unpause(); + } + + + function test_Socket_Execute_WhenPaused_ShouldRevert() public { + // Pause the contract + vm.prank(owner); + socket.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + socket.pause(); + + ExecuteParams memory executeParams = ExecuteParams({ + callType: WRITE, + target: address(socket), + deadline: block.timestamp + 1000, + value: 0, + payloadId: bytes32(0), + prevBatchDigestHash: bytes32(0), + source: bytes(""), + payload: bytes(""), + extraData: bytes(""), + gasLimit: 1000000 + }); + TransmissionParams memory transmissionParams = TransmissionParams({ + socketFees: 0, + transmitterProof: bytes(""), + extraData: bytes(""), + refundAddress: address(0) + }); + + vm.expectRevert(abi.encodeWithSelector(Pausable.ContractPaused.selector)); + socket.execute(executeParams, transmissionParams); + } + // ==================== Watcher Tests ==================== + + function test_Watcher_Initialize_ThenPause() public { + // Note: Watcher needs initialization, but for testing pause functionality + // we can test the pause mechanism directly + // In a real scenario, Watcher would be initialized first + + // For this test, we'll assume Watcher is already initialized + // and focus on the pause/unpause functionality + + // Grant pauser role (owner would do this) + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + + vm.prank(pauser); + vm.expectEmit(true, false, false, false); + emit Paused(); + watcher.pause(); + + assertTrue(watcher.paused()); + } + + function test_Watcher_Pause_ByPauser_ShouldSucceed() public { + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + + vm.prank(pauser); + watcher.pause(); + + assertTrue(watcher.paused()); + } + + function test_Watcher_Pause_ByUnauthorized_ShouldRevert() public { + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSelector(AccessControl.NoPermit.selector, PAUSER_ROLE)); + watcher.pause(); + } + + function test_Watcher_Unpause_ByUnpauser_ShouldSucceed() public { + // First pause it + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + watcher.pause(); + + // Grant unpauser role and unpause + vm.prank(owner); + watcher.grantRole(UNPAUSER_ROLE, unpauser); + + vm.prank(unpauser); + vm.expectEmit(true, false, false, false); + emit Unpaused(); + watcher.unpause(); + + assertFalse(watcher.paused()); + } + + function test_Watcher_Unpause_ByUnauthorized_ShouldRevert() public { + // First pause it + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + watcher.pause(); + + // Try to unpause as unauthorized + vm.prank(unauthorized); + vm.expectRevert(abi.encodeWithSelector(AccessControl.NoPermit.selector, UNPAUSER_ROLE)); + watcher.unpause(); + } + + function test_Watcher_ExecutePayload_WhenPaused_ShouldRevert() public { + // Pause the contract + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + watcher.pause(); + + // The executePayload function should revert due to whenNotPaused modifier + assertTrue(watcher.paused()); + } + + function test_Watcher_ResolvePayload_WhenPaused_ShouldRevert() public { + // Pause the contract + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + watcher.pause(); + + // The resolvePayload function should revert due to whenNotPaused modifier + assertTrue(watcher.paused()); + } + + function test_Watcher_executePayload_WhenPaused_ShouldRevert() public { + // Pause the contract + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + watcher.pause(); + + vm.expectRevert(abi.encodeWithSelector(Pausable.ContractPaused.selector)); + watcher.executePayload(); + } + + function test_Watcher_resolvePayload_WhenPaused_ShouldRevert() public { + // Pause the contract + vm.prank(owner); + watcher.grantRole(PAUSER_ROLE, pauser); + vm.prank(pauser); + watcher.pause(); + + vm.expectRevert(abi.encodeWithSelector(Pausable.ContractPaused.selector)); + watcher.resolvePayload(WatcherMultiCallParams({ + contractAddress: address(watcher), + data: "0x", + nonce: 0, + signature: bytes("0x") + })); + } +} + diff --git a/test/SetupTest.t.sol b/test/SetupTest.t.sol index 16f1fbca..7527a7ca 100644 --- a/test/SetupTest.t.sol +++ b/test/SetupTest.t.sol @@ -431,7 +431,7 @@ contract FeesSetup is DeploySetup { vm.expectEmit(true, true, true, false); emit Deposited(chainSlug_, address(token), user_, credits_, native_); hoax(watcherEOA); - feesManager.deposit(abi.encode(chainSlug_, address(token), user_, native_, credits_)); + feesManager.deposit(abi.encode(chainSlug_, address(token), user_, credits_, native_ )); assertEq( feesManager.balanceOf(user_),