Liquid Ron
Liquid staking for RON on the Ronin chain — deposits are delegated to validators through staking proxies and rewards auto-compound.
Delayed operator-fee collection gives earlier withdrawers a better share price than later ones
Summary
In LiquidRon, the operator fee accrued from harvests stays inside the vault's asset balance until it is manually fetched. Users who withdraw before the fee is fetched redeem against an inflated totalAssets and burn fewer shares; users who withdraw after the fetch burn more shares for the same asset amount. The share-to-asset rate depends on withdrawal timing.
Root cause
harvest books the operator fee into operatorFeeAmount but leaves the underlying ETH in the vault:
function harvest(uint256 _proxyIndex, address[] calldata _consensusAddrs) external onlyOperator whenNotPaused {
uint256 harvestedAmount = ILiquidProxy(stakingProxies[_proxyIndex]).harvest(_consensusAddrs);
@> operatorFeeAmount += (harvestedAmount * operatorFee) / BIPS;
emit Harvest(_proxyIndex, harvestedAmount);
}
totalAssets() still counts that ETH until fetchOperatorFee() removes it. Between accrual and fetch, the vault reports more assets than are actually distributable to depositors.
Impact
Unequal withdrawal outcomes. If the fee accumulates and is only fetched sporadically, the effective share/asset exchange rate can diverge meaningfully, systematically disadvantaging users who withdraw after a fee fetch.
Proof of concept
function test_operator_fee_impact_on_withdrawal() public {
address bob = makeAddr("bob");
address alice = makeAddr("alice");
vm.deal(bob, 100000 ether);
vm.deal(alice, 100000 ether);
uint256 amount = 10000 ether;
uint256[] memory amounts = new uint256[](5);
for (uint256 i = 0; i < 5; i++) amounts[i] = amount / 5;
vm.prank(bob); liquidRon.deposit{value: amount}();
vm.prank(address(this)); liquidRon.delegateAmount(0, amounts, consensusAddrs);
vm.prank(alice); liquidRon.deposit{value: amount}();
vm.prank(address(this)); liquidRon.delegateAmount(0, amounts, consensusAddrs);
skip(86400 * 365 + 1);
vm.prank(address(this)); liquidRon.harvest(0, consensusAddrs);
assertGt(liquidRon.operatorFeeAmount(), 0);
uint256 totalBefore = liquidRon.totalAssets();
uint256 withdrawAmount = 100 ether;
// Bob withdraws before the fee is fetched
vm.prank(bob);
uint256 sharesBurnedBefore = liquidRon.withdraw(withdrawAmount, bob, bob);
vm.prank(address(this)); liquidRon.fetchOperatorFee();
assertEq(liquidRon.operatorFeeAmount(), 0);
assertLt(liquidRon.totalAssets(), totalBefore);
// Alice withdraws after
vm.prank(alice);
uint256 sharesBurnedAfter = liquidRon.withdraw(withdrawAmount, alice, alice);
assertGt(sharesBurnedAfter, sharesBurnedBefore);
}
Bob (before fetch) burns fewer shares than Alice (after fetch) for the same withdrawal.
Recommendation
Either collect the operator fee on a fixed schedule / automatically so it can't accumulate, or exclude accrued fees from totalAssets so the conversion rate never counts non-distributable ETH.
Broken onlyOperator modifier locks approved operators out, leaving only the owner
Summary
The onlyOperator modifier in LiquidRon is meant to allow the owner or an approved operator. The condition uses || where it should use &&, so an approved operator always fails the check and only the owner can call the protected functions.
Root cause
modifier onlyOperator() {
if (msg.sender != owner() || operator[msg.sender]) revert ErrInvalidOperator();
_;
}
For an approved operator, operator[msg.sender] is true, so the || is true and the call reverts. The check only passes for the owner (for whom both operands are false).
Impact
Approved operators cannot perform any privileged action. Every operator-gated operation falls back to the owner, removing the intended delegation and creating an operational bottleneck.
Proof of concept
function test_operator_action_revert() public {
address operatorAddr = makeAddr("operator");
liquidRon.updateOperator(operatorAddr, true);
assertEq(liquidRon.operator(operatorAddr), true);
uint256 amount = 10000 ether;
liquidRon.deposit{value: amount}();
uint256[] memory amounts = new uint256[](5);
for (uint256 i = 0; i < 5; i++) amounts[i] = amount / 5;
vm.startPrank(operatorAddr);
liquidRon.delegateAmount(0, amounts, consensusAddrs); // reverts ErrInvalidOperator()
vm.stopPrank();
}
Recommendation
modifier onlyOperator() {
if (msg.sender != owner() && !operator[msg.sender]) revert ErrInvalidOperator();
_;
}