Skip to content

Commit 21751c2

Browse files
Agusx1211claude
andauthored
(pausable option C) Pausable sapient signer (#96)
* Pausable base contract * Pausable sapient * emit set operator events on constructor * Revert on paused * disable renounceOwnership * Use enumerable set for getOperators * add co-signer need comment to pausable --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3611e8a commit 21751c2

7 files changed

Lines changed: 515 additions & 4 deletions

File tree

src/autoRecovery/Allowlist.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ contract Allowlist is Ownable {
1414
error NotAllowed(address addr);
1515
/// @notice The provided index hint does not point at the expected address.
1616
error IndexMismatch(uint256 index, address expected, address actual);
17+
/// @notice Ownership renunciation is disabled to keep allowlist administration available.
18+
error OwnershipRenunciationDisabled();
1719

1820
mapping(address => bool) private _allowed;
1921
address[] private _entries;
@@ -74,6 +76,11 @@ contract Allowlist is Ownable {
7476
return _entries;
7577
}
7678

79+
/// @notice Disables ownership renunciation to avoid permanently locked administration.
80+
function renounceOwnership() public view override onlyOwner {
81+
revert OwnershipRenunciationDisabled();
82+
}
83+
7784
function _add(address addr) private {
7885
if (addr == address(0)) revert ZeroAddress();
7986
if (_allowed[addr]) revert AlreadyAllowed(addr);

src/pausable/Pausable.sol

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.27;
3+
4+
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
5+
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
6+
7+
/// @title Pausable
8+
/// @notice Owner-controlled pause state with additional operators that may only pause.
9+
abstract contract Pausable is Ownable {
10+
using EnumerableSet for EnumerableSet.AddressSet;
11+
12+
/// @notice The zero address was provided where an operator address is required.
13+
error ZeroAddress();
14+
/// @notice The caller is neither the owner nor a pause operator.
15+
error UnauthorizedPauser(address account);
16+
/// @notice The contract is paused.
17+
error EnforcedPause();
18+
/// @notice The contract is not paused.
19+
error ExpectedPause();
20+
/// @notice Ownership renunciation is disabled to keep pause recovery available.
21+
error OwnershipRenunciationDisabled();
22+
23+
EnumerableSet.AddressSet private _operators;
24+
/// @notice Returns whether the contract is currently paused.
25+
bool public paused;
26+
27+
/// @notice Emitted when `account` pauses the contract.
28+
event Paused(address indexed account);
29+
/// @notice Emitted when `account` unpauses the contract.
30+
event Unpaused(address indexed account);
31+
/// @notice Emitted when an operator permission is updated.
32+
event OperatorSet(address indexed operator, bool allowed);
33+
34+
/// @notice Initializes the owner and optional initial pause operators.
35+
/// @param owner_ The owner allowed to manage operators and unpause.
36+
/// @param initialOperators The initial set of addresses allowed to pause.
37+
constructor(address owner_, address[] memory initialOperators) Ownable(owner_) {
38+
for (uint256 i; i < initialOperators.length; i++) {
39+
_setOperator(initialOperators[i], true);
40+
}
41+
}
42+
43+
/// @notice Reverts when the contract is paused.
44+
modifier whenNotPaused() {
45+
if (paused) {
46+
revert EnforcedPause();
47+
}
48+
_;
49+
}
50+
51+
/// @notice Reverts when the contract is not paused.
52+
modifier whenPaused() {
53+
if (!paused) {
54+
revert ExpectedPause();
55+
}
56+
_;
57+
}
58+
59+
/// @notice Pauses the contract.
60+
/// @dev Callable by the owner or an approved operator.
61+
function pause() external whenNotPaused {
62+
if (msg.sender != owner() && !_operators.contains(msg.sender)) {
63+
revert UnauthorizedPauser(msg.sender);
64+
}
65+
66+
paused = true;
67+
emit Paused(msg.sender);
68+
}
69+
70+
/// @notice Unpauses the contract.
71+
/// @dev Callable only by the owner.
72+
function unpause() external onlyOwner whenPaused {
73+
paused = false;
74+
emit Unpaused(msg.sender);
75+
}
76+
77+
/// @notice Updates whether `operator` is allowed to pause.
78+
/// @param operator The address to update.
79+
/// @param allowed Whether the address may pause.
80+
function setOperator(address operator, bool allowed) external onlyOwner {
81+
_setOperator(operator, allowed);
82+
}
83+
84+
/// @notice Returns whether `account` is allowed to pause.
85+
function isOperator(address account) external view returns (bool) {
86+
return _operators.contains(account);
87+
}
88+
89+
/// @notice Returns the current pause operators in storage order.
90+
/// @dev Ordering is not guaranteed and may change when operators are removed.
91+
function getOperators() external view returns (address[] memory) {
92+
return _operators.values();
93+
}
94+
95+
/// @notice Disables ownership renunciation to avoid permanently locked pause state.
96+
function renounceOwnership() public view override onlyOwner {
97+
revert OwnershipRenunciationDisabled();
98+
}
99+
100+
function _setOperator(address operator, bool allowed) private {
101+
if (operator == address(0)) {
102+
revert ZeroAddress();
103+
}
104+
105+
if (allowed) {
106+
_operators.add(operator);
107+
} else {
108+
_operators.remove(operator);
109+
}
110+
111+
emit OperatorSet(operator, allowed);
112+
}
113+
}

src/pausable/PausableSapient.sol

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.27;
3+
4+
import {Pausable} from "src/pausable/Pausable.sol";
5+
import {ISapientCompact} from "wallet-contracts-v3/modules/interfaces/ISapient.sol";
6+
7+
/// @title PausableSapient
8+
/// @notice Compact sapient signer that returns a fixed image hash unless paused.
9+
/// @dev Reverts with `EnforcedPause` while paused.
10+
/// @dev Does not verify signatures; use co-signers with real verification at the nested tree level.
11+
contract PausableSapient is ISapientCompact, Pausable {
12+
/// @notice Image hash returned while the signer is active.
13+
bytes32 public constant UNPAUSED_IMAGE_HASH = bytes32(uint256(1));
14+
15+
/// @notice Initializes the pause controller.
16+
/// @param owner_ The owner allowed to manage operators and unpause.
17+
/// @param initialOperators The initial set of addresses allowed to pause.
18+
constructor(address owner_, address[] memory initialOperators) Pausable(owner_, initialOperators) {}
19+
20+
/// @inheritdoc ISapientCompact
21+
function recoverSapientSignatureCompact(bytes32, bytes calldata)
22+
external
23+
view
24+
whenNotPaused
25+
returns (bytes32 imageHash)
26+
{
27+
return UNPAUSED_IMAGE_HASH;
28+
}
29+
}

test/Allowlist.t.sol

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,29 @@ contract AllowlistTest is Test {
441441
assertEq(allowlist.getAllowed().length, 0);
442442
}
443443

444+
function testFuzz_renounceOwnership_revertsAndKeepsOwner(address owner_) external {
445+
vm.assume(owner_ != address(0));
446+
447+
Allowlist allowlist = _newAllowlist(owner_);
448+
449+
vm.prank(owner_);
450+
vm.expectRevert(Allowlist.OwnershipRenunciationDisabled.selector);
451+
allowlist.renounceOwnership();
452+
453+
assertEq(allowlist.owner(), owner_);
454+
}
455+
456+
function testFuzz_renounceOwnership_revertsForNonOwner(address owner_, address caller) external {
457+
vm.assume(owner_ != address(0));
458+
vm.assume(caller != owner_);
459+
460+
Allowlist allowlist = _newAllowlist(owner_);
461+
462+
vm.prank(caller);
463+
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller));
464+
allowlist.renounceOwnership();
465+
}
466+
444467
function testFuzz_addRemoveAdd(address owner_, address addr) external {
445468
vm.assume(owner_ != address(0));
446469
vm.assume(addr != address(0));

test/MalleableSapient.t.sol

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,9 @@ contract MalleableSapientTest is Test {
167167
});
168168
}
169169

170-
// Prevent overlap by requring separate tindex for each repeat section
170+
// Prevent overlap by requiring separate tindex for each repeat section.
171171
uint256[] memory tindexWithRepeat = new uint256[](sections * 2);
172+
uint256 repeatTindexCount;
172173

173174
SignatureParts memory parts;
174175
bytes memory signature;
@@ -185,12 +186,14 @@ contract MalleableSapientTest is Test {
185186
parts.tindex2 = uint8(uint256(keccak256(abi.encodePacked(seed, "t2", i))) % callCount);
186187
// Prevent collision by checking tindex is not repeated
187188
vm.assume(parts.tindex != parts.tindex2);
188-
for (uint256 j = 0; j < i * 2; j++) {
189+
for (uint256 j = 0; j < repeatTindexCount; j++) {
189190
vm.assume(parts.tindex != tindexWithRepeat[j]);
190191
vm.assume(parts.tindex2 != tindexWithRepeat[j]);
191192
}
192-
tindexWithRepeat[i] = parts.tindex;
193-
tindexWithRepeat[i + 1] = parts.tindex2;
193+
tindexWithRepeat[repeatTindexCount] = parts.tindex;
194+
repeatTindexCount++;
195+
tindexWithRepeat[repeatTindexCount] = parts.tindex2;
196+
repeatTindexCount++;
194197

195198
uint256 dataLen2 = payload.calls[parts.tindex2].data.length;
196199
// forge-lint: disable-next-line(unsafe-typecast)

0 commit comments

Comments
 (0)