Description
-
A decentralized trading protocol should have distributed control to prevent single points of failure and abuse of power.
-
The contract owner has excessive control over critical protocol functions including fee withdrawal, token allowlist management, and emergency withdrawals, creating centralization risks.
function setAllowedSellToken(address _token, bool _isAllowed) external onlyOwner {
if (_token == address(0) || _token == address(iUSDC)) revert InvalidToken();
allowedSellToken[_token] = _isAllowed;
emit TokenAllowed(_token, _isAllowed);
}
function emergencyWithdrawERC20(address _tokenAddress, uint256 _amount, address _to) external onlyOwner {
IERC20 token = IERC20(_tokenAddress);
token.safeTransfer(_to, _amount);
emit EmergencyWithdrawal(_tokenAddress, _amount, _to);
}
function withdrawFees(address _to) external onlyOwner {
if (totalFees == 0) revert InvalidAmount();
if (_to == address(0)) revert InvalidAddress();
iUSDC.safeTransfer(_to, totalFees);
totalFees = 0;
emit FeesWithdrawn(_to);
}
Risk
Likelihood:
-
When the owner's private key is compromised or stolen
-
When the owner acts maliciously or makes poor decisions
-
When the owner becomes unavailable (lost keys, death, etc.)
Impact:
-
Owner can disable trading for any token by setting allowedSellToken
to false
-
Owner can extract all protocol fees at any time without community approval
-
Owner can withdraw any accidentally sent tokens, potentially including user funds
-
Protocol becomes unusable if owner key is lost
Proof of Concept
Centralization Attack Scenario: This demonstrates how the owner can abuse privileges to harm users and the protocol.
function testCentralizationRisk() public {
vm.startPrank(alice);
weth.approve(address(book), 1e18);
uint256 orderId = book.createSellOrder(address(weth), 1e18, 3000e6, 1 days);
vm.stopPrank();
vm.startPrank(dan);
usdc.approve(address(book), 3000e6);
book.buyOrder(orderId);
vm.stopPrank();
assertEq(book.totalFees(), 90e6);
vm.startPrank(owner);
book.withdrawFees(owner);
vm.stopPrank();
assertEq(book.totalFees(), 0);
assertEq(usdc.balanceOf(owner), 90e6);
vm.startPrank(owner);
book.setAllowedSellToken(address(weth), false);
vm.stopPrank();
vm.startPrank(alice);
vm.expectRevert(OrderBook.InvalidToken.selector);
book.createSellOrder(address(weth), 1e18, 3000e6, 1 days);
vm.stopPrank();
}
Abuse Scenarios:
Immediate fee extraction: Owner can withdraw all accumulated fees without community approval
Market manipulation: Owner can suddenly disable popular tokens, causing market disruption
Selective censorship: Owner can prevent specific users from trading by disabling their preferred tokens
Emergency abuse: Owner can use emergencyWithdrawERC20
to extract legitimate user funds under false pretenses
Key compromise: If owner's private key is stolen, attacker has full control over the protocol
Availability risk: Lost owner keys make the protocol permanently unable to adapt or collect fees
Recommended Mitigation
+ import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
- contract OrderBook is Ownable {
+ contract OrderBook is Ownable {
+ uint256 public constant TIMELOCK_DELAY = 48 hours;
+ mapping(bytes32 => uint256) public pendingActions;
+ modifier onlyAfterTimelock(bytes32 actionHash) {
+ require(pendingActions[actionHash] != 0, "Action not scheduled");
+ require(block.timestamp >= pendingActions[actionHash], "Timelock not expired");
+ _;
+ delete pendingActions[actionHash];
+ }
+ function scheduleAction(bytes32 actionHash) external onlyOwner {
+ pendingActions[actionHash] = block.timestamp + TIMELOCK_DELAY;
+ }
- function setAllowedSellToken(address _token, bool _isAllowed) external onlyOwner {
+ function setAllowedSellToken(address _token, bool _isAllowed) external onlyOwner onlyAfterTimelock(keccak256(abi.encode(_token, _isAllowed))) {
if (_token == address(0) || _token == address(iUSDC)) revert InvalidToken();
allowedSellToken[_token] = _isAllowed;
emit TokenAllowed(_token, _isAllowed);
}
- function withdrawFees(address _to) external onlyOwner {
+ function withdrawFees(address _to) external onlyOwner onlyAfterTimelock(keccak256(abi.encode(_to))) {
if (totalFees == 0) revert InvalidAmount();
if (_to == address(0)) revert InvalidAddress();
iUSDC.safeTransfer(_to, totalFees);
totalFees = 0;
emit FeesWithdrawn(_to);
}
}
Why this works:
-
Timelock mechanism: Forces a 48-hour delay before executing privileged actions
-
Community visibility: Users can see scheduled actions and exit if they disagree
-
Prevents immediate abuse: Compromised keys cannot instantly drain funds or disable tokens
-
Maintains owner control: Owner can still perform necessary actions after proper notice
Better alternatives for full decentralization:
Multi-signature wallet: Require multiple signatures for critical operations
DAO governance: Community voting on fee withdrawals and token changes
Immutable allowlist: Pre-approve tokens at deployment, remove owner token control
Automated fee distribution: Send fees to staking rewards or burn mechanisms
Proxy pattern with governance: Upgradeable contract controlled by token holders