Skip to content

Commit 22f5352

Browse files
kupermindclaude
andcommitted
refactor(Dispenser): put the Dispenser behind a DispenserProxy (stable-address upgradeability)
Makes the Dispenser proxy-based, following the TokenomicsProxy / LiquidityManagerProxy pattern, so future Dispenser logic fixes become one-tx implementation swaps behind a stable address instead of a whole-stack redeploy (the L1 deposit processors bind to the dispenser address immutably, and the L2 target dispensers bind to their L1 processor — a stable proxy address means those bindings never have to change again). - New contracts/proxies/DispenserProxy.sol: EIP-1822-style proxy, implementation slot keccak256("PROXY_DISPENSER"), constructor (implementation, initData) delegatecall-init, Gnosis-Safe-style payable fallback (the Dispenser has payable claim functions). - Dispenser implementation constructor now sets only bytecode immutables (olas, tokenomics, retainer/retainerHash, defaultMinStakingWeight, defaultMaxStakingIncentive); mutable state moves to initialize() (treasury, voteWeighting, maxNumClaimingEpochs, maxNumStakingTargets), guarded by AlreadyInitialized. - tokenomics is promoted to an implementation immutable: it points at the stable TokenomicsProxy address and is read on every claim (hot path — immutables cost no SLOAD). changeManagers accordingly drops its tokenomics parameter (rewiring tokenomics now means deploying a new implementation and calling changeImplementation); treasury and voteWeighting stay mutable. - New changeImplementation(address) (owner-gated), same shape and name as LiquidityManagerCore / BuyBackBurner. - Storage layout below the immutables is frozen append-only for upgrade safety (documented in-code; no gap — flat contract, matching the Tokenomics convention). Tests: - New test/DispenserProxy.t.sol (9 tests): proxy constructor guards, initialization via the proxy delegatecall, re-init protection (proxy + direct impl), owner-gated changeImplementation preserving proxy state while swapping immutables/logic, slot content. - test/Dispenser.t.sol and the hardhat suites (DispenserDevIncentives, DispenserStakingIncentives) now deploy the Dispenser behind DispenserProxy, with the deploy order inverted (Tokenomics proxy first, then Dispenser impl+proxy, then repoint Tokenomics/Treasury) since tokenomics is a constructor immutable; constructor/initialize negative tests split accordingly. All suites green: forge (Dispenser 3, DispenserProxy 9, Depository, Treasury), hardhat full run, eslint/solhint clean on changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 029b557 commit 22f5352

6 files changed

Lines changed: 405 additions & 99 deletions

File tree

contracts/Dispenser.sol

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@ error WrongAmount(uint256 provided, uint256 expected);
228228
/// @dev Caught reentrancy violation.
229229
error ReentrancyGuard();
230230

231+
/// @dev The contract is already initialized.
232+
error AlreadyInitialized();
233+
231234
/// @dev The contract is paused.
232235
error Paused();
233236

@@ -263,7 +266,7 @@ contract Dispenser {
263266
}
264267

265268
event OwnerUpdated(address indexed owner);
266-
event TokenomicsUpdated(address indexed tokenomics);
269+
event ImplementationUpdated(address indexed implementation);
267270
event TreasuryUpdated(address indexed treasury);
268271
event VoteWeightingUpdated(address indexed voteWeighting);
269272
event StakingParamsUpdated(uint256 maxNumClaimingEpochs, uint256 maxNumStakingTargets);
@@ -280,17 +283,29 @@ contract Dispenser {
280283
event AddNomineeHash(bytes32 indexed nomineeHash);
281284
event RemoveNomineeHash(bytes32 indexed nomineeHash);
282285

286+
// Dispenser proxy address slot
287+
// keccak256("PROXY_DISPENSER") = "0x8bd249c73459f2c50400ebdc57436101fc7d9a76908baf1ba5be362b47b48f83"
288+
bytes32 public constant PROXY_DISPENSER = 0x8bd249c73459f2c50400ebdc57436101fc7d9a76908baf1ba5be362b47b48f83;
283289
// Maximum chain Id as per EVM specs
284290
uint256 public constant MAX_EVM_CHAIN_ID = type(uint64).max / 2 - 36;
291+
292+
// Immutables live in the implementation bytecode, not proxy storage: they are re-settable by deploying
293+
// a new implementation and calling changeImplementation. Every new implementation deploy must pass the
294+
// correct constructor args — a wrong value silently rewires the proxy.
285295
uint256 public immutable defaultMinStakingWeight;
286296
uint256 public immutable defaultMaxStakingIncentive;
287297
// OLAS token address
288298
address public immutable olas;
299+
// Tokenomics proxy address (stable by construction — the proxy address never changes)
300+
address public immutable tokenomics;
289301
// Retainer address in bytes32 form
290302
bytes32 public immutable retainer;
291303
// Retainer hash of a Nominee struct composed of retainer address with block.chainid
292304
bytes32 public immutable retainerHash;
293305

306+
// Storage layout below is consumed via the DispenserProxy delegatecall and is FROZEN: never remove,
307+
// reorder, retype or insert in the middle — new variables are appended strictly after the last mapping.
308+
294309
// Max number of epochs to claim staking incentives for
295310
uint256 public maxNumClaimingEpochs;
296311
// Max number of targets for a specific chain to claim staking incentives for
@@ -302,8 +317,6 @@ contract Dispenser {
302317
// Pause state
303318
Pause public paused;
304319

305-
// Tokenomics contract address
306-
address public tokenomics;
307320
// Treasury contract address
308321
address public treasury;
309322
// Vote Weighting contract address
@@ -320,36 +333,27 @@ contract Dispenser {
320333
// Mapping for epoch number => refunded all the staking inflation due to zero total voting power
321334
mapping(uint256 => bool) public mapZeroWeightEpochRefunded;
322335

323-
/// @dev Dispenser constructor.
336+
/// @dev Dispenser implementation constructor: sets the implementation-bytecode immutables only.
337+
/// @notice The mutable state is set via initialize() delegatecall-ed by the DispenserProxy constructor.
324338
/// @param _olas OLAS token address.
325-
/// @param _tokenomics Tokenomics address.
326-
/// @param _treasury Treasury address.
327-
/// @param _voteWeighting Vote Weighting address.
339+
/// @param _tokenomics Tokenomics proxy address.
340+
/// @param _retainer Retainer address in bytes32 form.
341+
/// @param _defaultMinStakingWeight Default min staking weight (bounded by uint16).
342+
/// @param _defaultMaxStakingIncentive Default max staking incentive (bounded by uint96).
328343
constructor(
329344
address _olas,
330345
address _tokenomics,
331-
address _treasury,
332-
address _voteWeighting,
333346
bytes32 _retainer,
334-
uint256 _maxNumClaimingEpochs,
335-
uint256 _maxNumStakingTargets,
336347
uint256 _defaultMinStakingWeight,
337348
uint256 _defaultMaxStakingIncentive
338349
) {
339-
owner = msg.sender;
340-
_locked = 1;
341-
// Staking incentives must be paused at the time of deployment because staking parameters are not live yet
342-
paused = Pause.StakingIncentivesPaused;
343-
344350
// Check for at least one zero contract address
345-
if (_olas == address(0) || _tokenomics == address(0) || _treasury == address(0) ||
346-
_voteWeighting == address(0) || _retainer == 0) {
351+
if (_olas == address(0) || _tokenomics == address(0) || _retainer == 0) {
347352
revert ZeroAddress();
348353
}
349354

350355
// Check for zero value staking parameters
351-
if (_maxNumClaimingEpochs == 0 || _maxNumStakingTargets == 0 || _defaultMinStakingWeight == 0 ||
352-
_defaultMaxStakingIncentive == 0) {
356+
if (_defaultMinStakingWeight == 0 || _defaultMaxStakingIncentive == 0) {
353357
revert ZeroValue();
354358
}
355359

@@ -363,17 +367,71 @@ contract Dispenser {
363367

364368
olas = _olas;
365369
tokenomics = _tokenomics;
366-
treasury = _treasury;
367-
voteWeighting = _voteWeighting;
368370

369371
retainer = _retainer;
370372
retainerHash = keccak256(abi.encode(IVoteWeighting.Nominee(retainer, block.chainid)));
371-
maxNumClaimingEpochs = _maxNumClaimingEpochs;
372-
maxNumStakingTargets = _maxNumStakingTargets;
373373
defaultMinStakingWeight = _defaultMinStakingWeight;
374374
defaultMaxStakingIncentive = _defaultMaxStakingIncentive;
375375
}
376376

377+
/// @dev Initializes the dispenser proxy storage.
378+
/// @notice Delegatecall-ed by the DispenserProxy constructor; the caller becomes the owner.
379+
/// @param _treasury Treasury address.
380+
/// @param _voteWeighting Vote Weighting address.
381+
/// @param _maxNumClaimingEpochs Maximum number of epochs to claim staking incentives for.
382+
/// @param _maxNumStakingTargets Maximum number of staking targets on a single chain Id.
383+
function initialize(
384+
address _treasury,
385+
address _voteWeighting,
386+
uint256 _maxNumClaimingEpochs,
387+
uint256 _maxNumStakingTargets
388+
) external {
389+
// Check that the proxy storage has not been initialized yet
390+
if (owner != address(0)) {
391+
revert AlreadyInitialized();
392+
}
393+
394+
// Check for at least one zero contract address
395+
if (_treasury == address(0) || _voteWeighting == address(0)) {
396+
revert ZeroAddress();
397+
}
398+
399+
// Check for zero value staking parameters
400+
if (_maxNumClaimingEpochs == 0 || _maxNumStakingTargets == 0) {
401+
revert ZeroValue();
402+
}
403+
404+
owner = msg.sender;
405+
_locked = 1;
406+
// Staking incentives must be paused at the time of deployment because staking parameters are not live yet
407+
paused = Pause.StakingIncentivesPaused;
408+
409+
treasury = _treasury;
410+
voteWeighting = _voteWeighting;
411+
maxNumClaimingEpochs = _maxNumClaimingEpochs;
412+
maxNumStakingTargets = _maxNumStakingTargets;
413+
}
414+
415+
/// @dev Changes the dispenser implementation contract address behind the proxy.
416+
/// @param implementation Dispenser implementation contract address.
417+
function changeImplementation(address implementation) external {
418+
// Check for the contract ownership
419+
if (msg.sender != owner) {
420+
revert OwnerOnly(msg.sender, owner);
421+
}
422+
423+
// Check for the zero address
424+
if (implementation == address(0)) {
425+
revert ZeroAddress();
426+
}
427+
428+
// Store the implementation address under the designated storage slot
429+
assembly {
430+
sstore(PROXY_DISPENSER, implementation)
431+
}
432+
emit ImplementationUpdated(implementation);
433+
}
434+
377435
/// @dev Checkpoints specified staking target (nominee in Vote Weighting) and gets claimed epoch counters.
378436
/// @param nomineeHash Hash of a Nominee(stakingTarget, chainId).
379437
/// @param numClaimedEpochs Specified number of claimed epochs.
@@ -685,21 +743,16 @@ contract Dispenser {
685743
}
686744

687745
/// @dev Changes various managing contract addresses.
688-
/// @param _tokenomics Tokenomics address.
746+
/// @notice The tokenomics address is an implementation immutable (it points at the stable TokenomicsProxy);
747+
/// changing it requires deploying a new implementation and calling changeImplementation.
689748
/// @param _treasury Treasury address.
690749
/// @param _voteWeighting Vote Weighting address.
691-
function changeManagers(address _tokenomics, address _treasury, address _voteWeighting) external {
750+
function changeManagers(address _treasury, address _voteWeighting) external {
692751
// Check for the contract ownership
693752
if (msg.sender != owner) {
694753
revert OwnerOnly(msg.sender, owner);
695754
}
696755

697-
// Change Tokenomics contract address
698-
if (_tokenomics != address(0)) {
699-
tokenomics = _tokenomics;
700-
emit TokenomicsUpdated(_tokenomics);
701-
}
702-
703756
// Change Treasury contract address
704757
if (_treasury != address(0)) {
705758
treasury = _treasury;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.25;
3+
4+
/// @dev Proxy initialization failed.
5+
error InitializationFailed();
6+
7+
/// @dev Zero address.
8+
error ZeroAddress();
9+
10+
/// @dev Zero Value.
11+
error ZeroValue();
12+
13+
/*
14+
* This is a proxy contract for dispenser.
15+
* Proxy implementation is created based on the Universal Upgradeable Proxy Standard (UUPS) EIP-1822.
16+
* The implementation address must be located in a unique storage slot of the proxy contract.
17+
* The upgrade logic must be located in the implementation contract.
18+
* Special dispenser implementation address slot is produced by hashing the "PROXY_DISPENSER" string
19+
* in order to make the slot unique.
20+
* The fallback() implementation for all the delegatecall-s is inspired by the Gnosis Safe set of contracts.
21+
*/
22+
23+
/// @title DispenserProxy - Smart contract for dispenser proxy
24+
/// @author Aleksandr Kuperman - <aleksandr.kuperman@valory.xyz>
25+
/// @author Andrey Lebedev - <andrey.lebedev@valory.xyz>
26+
contract DispenserProxy {
27+
// Code position in storage is keccak256("PROXY_DISPENSER") = "0x8bd249c73459f2c50400ebdc57436101fc7d9a76908baf1ba5be362b47b48f83"
28+
bytes32 public constant PROXY_DISPENSER = 0x8bd249c73459f2c50400ebdc57436101fc7d9a76908baf1ba5be362b47b48f83;
29+
30+
/// @dev DispenserProxy constructor.
31+
/// @param dispenser Dispenser implementation address.
32+
/// @param dispenserData Dispenser initialization data.
33+
constructor(address dispenser, bytes memory dispenserData) {
34+
// Check for the zero address, since the delegatecall works even with the zero one
35+
if (dispenser == address(0)) {
36+
revert ZeroAddress();
37+
}
38+
39+
// Check for the zero data
40+
if (dispenserData.length == 0) {
41+
revert ZeroValue();
42+
}
43+
44+
assembly {
45+
sstore(PROXY_DISPENSER, dispenser)
46+
}
47+
// Initialize proxy dispenser storage
48+
(bool success, ) = dispenser.delegatecall(dispenserData);
49+
if (!success) {
50+
revert InitializationFailed();
51+
}
52+
}
53+
54+
/// @dev Delegatecall to all the incoming data.
55+
fallback() external payable {
56+
assembly {
57+
let dispenser := sload(PROXY_DISPENSER)
58+
// Otherwise continue with the delegatecall to the dispenser implementation
59+
calldatacopy(0, 0, calldatasize())
60+
let success := delegatecall(gas(), dispenser, 0, calldatasize(), 0, 0)
61+
returndatacopy(0, 0, returndatasize())
62+
if eq(success, 0) {
63+
revert(0, returndatasize())
64+
}
65+
return(0, returndatasize())
66+
}
67+
}
68+
}

test/Dispenser.t.sol

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pragma solidity ^0.8.30;
33
import {Test} from "forge-std/Test.sol";
44
import {Utils} from "./utils/Utils.sol";
55
import {Dispenser} from "../contracts/Dispenser.sol";
6+
import {DispenserProxy} from "../contracts/proxies/DispenserProxy.sol";
67
import "../contracts/Tokenomics.sol";
78
import {TokenomicsProxy} from "../contracts/proxies/TokenomicsProxy.sol";
89
import {Treasury} from "../contracts/Treasury.sol";
@@ -56,24 +57,32 @@ contract BaseSetup is Test {
5657
componentRegistry = new MockRegistry();
5758
agentRegistry = new MockRegistry();
5859
serviceRegistry = new MockRegistry();
59-
dispenser = new Dispenser(address(olas), deployer, deployer, deployer, retainer, 100, 100, 100, 1 ether);
6060

61-
// Depository contract is irrelevant here, so we are using a deployer's address
62-
// Correct tokenomics address will be added below
63-
treasury = new Treasury(address(olas), deployer, deployer, address(dispenser));
61+
// Depository and dispenser contracts are irrelevant at this point, so we are using a deployer's address
62+
// Correct tokenomics and dispenser addresses will be added below
63+
treasury = new Treasury(address(olas), deployer, deployer, deployer);
6464

6565
Tokenomics tokenomicsMaster = new Tokenomics();
6666
// Depository contract is irrelevant here, so we are using a deployer's address
67+
// Dispenser address is a placeholder until the dispenser proxy is deployed below
6768
bytes memory proxyData = abi.encodeWithSelector(tokenomicsMaster.initializeTokenomics.selector,
68-
address(olas), address(treasury), deployer, address(dispenser), address(ve), epochLen,
69+
address(olas), address(treasury), deployer, deployer, address(ve), epochLen,
6970
address(componentRegistry), address(agentRegistry), address(serviceRegistry), address(0));
7071
TokenomicsProxy tokenomicsProxy = new TokenomicsProxy(address(tokenomicsMaster), proxyData);
7172
tokenomics = Tokenomics(address(tokenomicsProxy));
7273

73-
// Change tokenomics address
74-
treasury.changeManagers(address(tokenomics), address(0), address(0));
75-
// Change the tokenomics and treasury addresses in the dispenser to correct ones
76-
dispenser.changeManagers(address(tokenomics), address(treasury), address(0));
74+
// Deploy dispenser implementation (tokenomics proxy address is an implementation immutable) and proxy
75+
// Vote Weighting contract is irrelevant here, so we are using a deployer's address
76+
Dispenser dispenserMaster = new Dispenser(address(olas), address(tokenomics), retainer, 100, 1 ether);
77+
bytes memory dispenserData = abi.encodeWithSelector(dispenserMaster.initialize.selector,
78+
address(treasury), deployer, 100, 100);
79+
DispenserProxy dispenserProxy = new DispenserProxy(address(dispenserMaster), dispenserData);
80+
dispenser = Dispenser(address(dispenserProxy));
81+
82+
// Change tokenomics and dispenser addresses in treasury
83+
treasury.changeManagers(address(tokenomics), address(0), address(dispenser));
84+
// Change the dispenser address in tokenomics to the correct one
85+
tokenomics.changeManagers(address(0), address(0), address(dispenser));
7786

7887
// Set treasury contract as a minter for OLAS
7988
olas.changeMinter(address(treasury));

0 commit comments

Comments
 (0)