Skip to content

Commit 2d0d606

Browse files
authored
fix(security): timelock storage slot, drip mutex, inflation pool deregister (#116)
Code-level follow-ups from the pre-mainnet audit (#115). TangleTimelock - updateDelay restored to onlySelf (parent's policy); the previous override silently degraded it to onlyRole(DEFAULT_ADMIN_ROLE). - _setMinDelay now writes to the correct OZ ERC-7201 namespaced slot for TimelockControllerStorage._minDelay (TIMELOCK_CONTROLLER_STORAGE_LOCATION + 1) instead of slot 0x33. The old write was a no-op for getMinDelay() and could silently corrupt slot 51 of the proxy. - Bounds (MIN_DELAY=1d, MAX_DELAY=30d) still enforced. StreamingPaymentManager - dripAndGetChunk and dripOperatorStreams now nonReentrant. Closes the same-tx double-drip race where the transfer-to-distributor hook could re-enter and process additional chunks before the first frame finished. InflationPool - New deregisterOperator / deregisterOperators / deregisterCustomer / deregisterDeveloper paths. Inactive operators no longer accumulate in trackedOperators, capping distribution-loop gas growth over time. The swap-and-pop helper clears membership flags and registration epochs; pending rewards remain claimable. Re-registration is permitted. Tests - test/security/TimelockSetMinDelayTest.t.sol: 3 tests (caller auth, persistence through getMinDelay, bounds enforcement). - test/security/InflationPoolDeregisterTest.t.sol: 3 tests (removal, non-admin revert, re-registration). - Full suite: 1478/1478 passing.
1 parent 54e7b07 commit 2d0d606

5 files changed

Lines changed: 220 additions & 18 deletions

File tree

src/governance/TangleTimelock.sol

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,29 +72,33 @@ contract TangleTimelock is Initializable, TimelockControllerUpgradeable, UUPSUpg
7272
// M-16 FIX: DELAY VALIDATION
7373
// ═══════════════════════════════════════════════════════════════════════════
7474

75-
/// @notice Update the minimum delay
76-
/// @dev M-16 FIX: Validates bounds before allowing delay changes. Only callable by
77-
/// the timelock itself (via a governance proposal).
78-
/// @param newDelay The new delay to set
79-
function updateDelay(uint256 newDelay) external override onlyRole(DEFAULT_ADMIN_ROLE) {
80-
// M-16 FIX: Enforce delay bounds to prevent timelock bypass
75+
/// @notice ERC-7201 storage location for `TimelockControllerStorage`.
76+
/// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.TimelockController")) - 1)) & ~bytes32(uint256(0xff))
77+
/// @dev Pinned to OpenZeppelin Contracts Upgradeable 5.1.0; verify before bumping.
78+
bytes32 private constant TIMELOCK_CONTROLLER_STORAGE_LOCATION =
79+
0x9a37c2aa9d186a0969ff8a8267bf4e07e864c2f2768f5040949e28a624fb3600;
80+
81+
/// @notice Update the minimum delay. Only callable by the timelock itself via a
82+
/// scheduled governance proposal (matches the parent's `onlySelf` policy).
83+
/// @param newDelay The new delay to set, bounded by [MIN_DELAY, MAX_DELAY].
84+
function updateDelay(uint256 newDelay) external override {
85+
if (msg.sender != address(this)) revert TimelockUnauthorizedCaller(msg.sender);
8186
if (newDelay < MIN_DELAY) revert DelayTooShort(newDelay, MIN_DELAY);
8287
if (newDelay > MAX_DELAY) revert DelayTooLong(newDelay, MAX_DELAY);
8388

84-
// Parent implementation handles the actual update and event emission
85-
// Note: This calls TimelockControllerUpgradeable.updateDelay which is onlyRole(DEFAULT_ADMIN_ROLE)
8689
emit MinDelayChange(getMinDelay(), newDelay);
8790
_setMinDelay(newDelay);
8891
}
8992

90-
/// @notice Internal function to set the minimum delay (matching OZ 5.x pattern)
93+
/// @dev Writes `newDelay` directly into the OZ ERC-7201 namespaced storage slot for
94+
/// `TimelockControllerStorage._minDelay`. The struct layout is:
95+
/// slot 0: _timestamps mapping
96+
/// slot 1: _minDelay
97+
/// so the absolute slot is `TIMELOCK_CONTROLLER_STORAGE_LOCATION + 1`.
9198
function _setMinDelay(uint256 newDelay) private {
92-
// Storage slot for _minDelay in TimelockControllerUpgradeable
93-
// Using direct storage write since OZ 5.x TimelockControllerUpgradeable
94-
// doesn't expose a setter function (it's an immutable in the non-upgradeable version)
99+
bytes32 slot = bytes32(uint256(TIMELOCK_CONTROLLER_STORAGE_LOCATION) + 1);
95100
assembly {
96-
// _minDelay storage slot (position 0x33 = 51 in TimelockControllerUpgradeable)
97-
sstore(0x33, newDelay)
101+
sstore(slot, newDelay)
98102
}
99103
}
100104

src/rewards/InflationPool.sol

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,56 @@ contract InflationPool is Initializable, UUPSUpgradeable, AccessControlUpgradeab
980980
}
981981
}
982982

983+
/// @notice Remove an operator from `trackedOperators` so distributions stop iterating it.
984+
/// @dev Pending rewards stay claimable. The address can be re-registered later if it
985+
/// becomes active again. Without this path the iteration cost grows monotonically.
986+
function deregisterOperator(address operator) external onlyRole(ADMIN_ROLE) {
987+
_removeFromTrackedList(trackedOperators, isTrackedOperator, operator);
988+
delete operatorRegistrationEpoch[operator];
989+
delete operatorLastScoredEpoch[operator];
990+
}
991+
992+
/// @notice Batch deregister inactive operators.
993+
function deregisterOperators(address[] calldata operators) external onlyRole(ADMIN_ROLE) {
994+
for (uint256 i = 0; i < operators.length; i++) {
995+
_removeFromTrackedList(trackedOperators, isTrackedOperator, operators[i]);
996+
delete operatorRegistrationEpoch[operators[i]];
997+
delete operatorLastScoredEpoch[operators[i]];
998+
}
999+
}
1000+
1001+
/// @notice Remove a customer from `trackedCustomers`.
1002+
function deregisterCustomer(address customer) external onlyRole(ADMIN_ROLE) {
1003+
_removeFromTrackedList(trackedCustomers, isTrackedCustomer, customer);
1004+
delete customerRegistrationEpoch[customer];
1005+
}
1006+
1007+
/// @notice Remove a developer from `trackedDevelopers`.
1008+
function deregisterDeveloper(address developer) external onlyRole(ADMIN_ROLE) {
1009+
_removeFromTrackedList(trackedDevelopers, isTrackedDeveloper, developer);
1010+
delete developerRegistrationEpoch[developer];
1011+
}
1012+
1013+
/// @dev Swap-and-pop the address out of the tracked list and clear its membership flag.
1014+
function _removeFromTrackedList(
1015+
address[] storage list,
1016+
mapping(address => bool) storage membership,
1017+
address account
1018+
) internal {
1019+
if (!membership[account]) return;
1020+
uint256 len = list.length;
1021+
for (uint256 i = 0; i < len; i++) {
1022+
if (list[i] == account) {
1023+
if (i != len - 1) {
1024+
list[i] = list[len - 1];
1025+
}
1026+
list.pop();
1027+
break;
1028+
}
1029+
}
1030+
membership[account] = false;
1031+
}
1032+
9831033
// ═══════════════════════════════════════════════════════════════════════════
9841034
// ADMIN CONFIGURATION
9851035
// ═══════════════════════════════════════════════════════════════════════════

src/rewards/StreamingPaymentManager.sol

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,15 +223,21 @@ contract StreamingPaymentManager is
223223
return (chunk, durationSeconds);
224224
}
225225

226-
/// @notice Drip a specific stream and return chunk info for distribution
227-
/// @dev Called by ServiceFeeDistributor to get the drip amount. Transfers dripped tokens to caller.
226+
/// @notice Drip a specific stream and return chunk info for distribution.
227+
/// @dev `nonReentrant` is load-bearing: without it, a re-entrant call from the
228+
/// transfer hook (or a same-tx call to `dripOperatorStreams`) would observe
229+
/// `lastDripTime == block.timestamp` only after the first call mutated it,
230+
/// but the second call could otherwise re-enter and process additional state
231+
/// before the first frame finishes. Combined with timestamp-granular dripping
232+
/// this also closes the same-block double-drip race.
228233
function dripAndGetChunk(
229234
uint64 serviceId,
230235
address operator
231236
)
232237
external
233238
override
234239
onlyRole(DISTRIBUTOR_ROLE)
240+
nonReentrant
235241
returns (uint256 amount, uint256 durationSeconds, uint64 blueprintId, address paymentToken)
236242
{
237243
StreamingPayment storage p = streamingPayments[serviceId][operator];
@@ -245,12 +251,16 @@ contract StreamingPaymentManager is
245251
}
246252
}
247253

248-
/// @notice Drip all active streams for an operator
249-
/// @dev Called by ServiceFeeDistributor before score changes. Transfers dripped tokens to caller.
254+
/// @notice Drip all active streams for an operator.
255+
/// @dev `nonReentrant` mutex prevents `dripOperatorStreams` and `dripAndGetChunk`
256+
/// from executing in the same transaction. Without it, two distributor calls
257+
/// in the same block would each compute the same `durationSeconds` and pay
258+
/// the chunk twice.
250259
function dripOperatorStreams(address operator)
251260
external
252261
override
253262
onlyRole(DISTRIBUTOR_ROLE)
263+
nonReentrant
254264
returns (
255265
uint64[] memory serviceIds,
256266
uint64[] memory blueprintIds,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.26;
3+
4+
import { Test } from "forge-std/Test.sol";
5+
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
6+
import { InflationPool } from "../../src/rewards/InflationPool.sol";
7+
8+
/// @title InflationPoolDeregisterTest
9+
/// @notice Regression: `deregisterOperator` removes an operator from `trackedOperators`,
10+
/// clears its registration epoch, and lets the index shrink instead of growing
11+
/// monotonically as inactive operators accumulate.
12+
contract InflationPoolDeregisterTest is Test {
13+
InflationPool pool;
14+
address admin = makeAddr("admin");
15+
address tnt = address(0xDEAD);
16+
17+
function setUp() public {
18+
InflationPool impl = new InflationPool();
19+
ERC1967Proxy proxy = new ERC1967Proxy(
20+
address(impl),
21+
abi.encodeCall(InflationPool.initialize, (admin, tnt, address(0), address(0), 7 days))
22+
);
23+
pool = InflationPool(payable(address(proxy)));
24+
}
25+
26+
function _trackedCount() internal view returns (uint256 count) {
27+
// `trackedOperators` is `address[] public`; length is exposed indirectly.
28+
// Iterate by slot until we hit a revert or zero - simpler to just probe with try/catch.
29+
while (true) {
30+
try pool.trackedOperators(count) returns (address) {
31+
count++;
32+
} catch {
33+
return count;
34+
}
35+
}
36+
}
37+
38+
function test_DeregisterOperator_RemovesFromTrackedList() public {
39+
address[] memory ops = new address[](3);
40+
ops[0] = makeAddr("op1");
41+
ops[1] = makeAddr("op2");
42+
ops[2] = makeAddr("op3");
43+
44+
vm.prank(admin);
45+
pool.registerOperators(ops);
46+
47+
assertEq(_trackedCount(), 3, "all three tracked");
48+
assertTrue(pool.isTrackedOperator(ops[1]));
49+
50+
vm.prank(admin);
51+
pool.deregisterOperator(ops[1]);
52+
53+
assertEq(_trackedCount(), 2, "shrinks after deregister");
54+
assertFalse(pool.isTrackedOperator(ops[1]));
55+
assertEq(pool.operatorRegistrationEpoch(ops[1]), 0, "registration epoch cleared");
56+
}
57+
58+
function test_DeregisterOperator_NonAdminReverts() public {
59+
address attacker = makeAddr("attacker");
60+
vm.prank(admin);
61+
pool.registerOperator(makeAddr("op"));
62+
63+
vm.prank(attacker);
64+
vm.expectRevert();
65+
pool.deregisterOperator(makeAddr("op"));
66+
}
67+
68+
function test_DeregisterOperator_AllowsReregistration() public {
69+
address op = makeAddr("op");
70+
71+
vm.startPrank(admin);
72+
pool.registerOperator(op);
73+
pool.deregisterOperator(op);
74+
pool.registerOperator(op);
75+
vm.stopPrank();
76+
77+
assertTrue(pool.isTrackedOperator(op));
78+
assertEq(_trackedCount(), 1);
79+
}
80+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.26;
3+
4+
import { Test } from "forge-std/Test.sol";
5+
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
6+
import { TangleTimelock } from "../../src/governance/TangleTimelock.sol";
7+
import {
8+
TimelockControllerUpgradeable
9+
} from "@openzeppelin/contracts-upgradeable/governance/TimelockControllerUpgradeable.sol";
10+
11+
/// @title TimelockSetMinDelayTest
12+
/// @notice Regression test: `updateDelay` must (a) reject callers other than the timelock
13+
/// itself and (b) actually persist the new delay (i.e. write to the OZ ERC-7201
14+
/// namespaced `_minDelay` slot, not slot 0x33).
15+
contract TimelockSetMinDelayTest is Test {
16+
TangleTimelock timelock;
17+
address admin = makeAddr("admin");
18+
19+
function setUp() public {
20+
TangleTimelock impl = new TangleTimelock();
21+
address[] memory empty = new address[](0);
22+
ERC1967Proxy proxy = new ERC1967Proxy(
23+
address(impl),
24+
abi.encodeCall(TangleTimelock.initialize, (1 days, empty, empty, admin))
25+
);
26+
timelock = TangleTimelock(payable(address(proxy)));
27+
}
28+
29+
function test_UpdateDelay_RevertsWhenCallerNotTimelock() public {
30+
vm.prank(admin);
31+
vm.expectRevert(
32+
abi.encodeWithSelector(TimelockControllerUpgradeable.TimelockUnauthorizedCaller.selector, admin)
33+
);
34+
timelock.updateDelay(2 days);
35+
}
36+
37+
function test_UpdateDelay_PersistsNewValue() public {
38+
assertEq(timelock.getMinDelay(), 1 days, "initial delay");
39+
40+
// Calling from the timelock itself (the only caller `updateDelay` accepts) must
41+
// (1) succeed and (2) flow through `getMinDelay()`. If `_setMinDelay` writes to
42+
// the wrong slot, `getMinDelay()` will continue to return the old value.
43+
vm.prank(address(timelock));
44+
timelock.updateDelay(3 days);
45+
46+
assertEq(timelock.getMinDelay(), 3 days, "delay must persist after updateDelay");
47+
}
48+
49+
function test_UpdateDelay_BoundsEnforced() public {
50+
vm.prank(address(timelock));
51+
vm.expectRevert(abi.encodeWithSelector(TangleTimelock.DelayTooShort.selector, 1 hours, 1 days));
52+
timelock.updateDelay(1 hours);
53+
54+
vm.prank(address(timelock));
55+
vm.expectRevert(abi.encodeWithSelector(TangleTimelock.DelayTooLong.selector, 60 days, 30 days));
56+
timelock.updateDelay(60 days);
57+
}
58+
}

0 commit comments

Comments
 (0)