Platform: Code4rena
Start Date: 25/01/2024
Pot Size: $16,425 USDC
Total HM: 4
Participants: 5
Period: 4 days
Judge: Alex the Entrprenerd
Total Solo HM: 1
Id: 326
League: ETH
Rank: 4/5
Findings: 1
Award: $0.00
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: erebus
Data not available
When calculating the amount of CANTO per share in update_market
, dividing by 1e18
in cantoReward
and multiplying by the same value in accCantoPerShare
rounds down the final value, making the amount of rewards users will receive be less than expected.
It's well known that Solidity rounds down when doing an integer division, and because of that, it is always recommended to multiply before dividing to avoid that precision loss. However, if we go to
LendingLedger, function update_market
function update_market(address _market) public { require(lendingMarketWhitelist[_market], "Market not whitelisted"); MarketInfo storage market = marketInfo[_market]; if (block.number > market.lastRewardBlock) { uint256 marketSupply = lendingMarketTotalBalance[_market]; if (marketSupply > 0) { uint256 i = market.lastRewardBlock; while (i < block.number) { uint256 epoch = (i / BLOCK_EPOCH) * BLOCK_EPOCH; // Rewards and voting weights are aligned on a weekly basis uint256 nextEpoch = i + BLOCK_EPOCH; uint256 blockDelta = Math.min(nextEpoch, block.number) - i; uint256 cantoReward = (blockDelta * cantoPerBlock[epoch] * gaugeController.gauge_relative_weight_write(_market, epoch)) / 1e18; market.accCantoPerShare += uint128((cantoReward * 1e18) / marketSupply); market.secRewardsPerShare += uint128((blockDelta * 1e18) / marketSupply); // TODO: Scaling i += blockDelta; } } market.lastRewardBlock = uint64(block.number); } }
and we expand the maths behind accCantoPerShare
and cantoReward
:
uint256 cantoReward = (blockDelta * cantoPerBlock[epoch] * gaugeController.gauge_relative_weight_write(_market, epoch)) / 1e18; market.accCantoPerShare += uint128((cantoReward * 1e18) / marketSupply);
like follows:
we see there is a hidden division before a multiplication that is rounding down the whole expression. This is bad as the precision loss can be significant, which leads to the given market having less rewards to offer to its users. Run the next test inside a foundry project to see such a divergence in the precision if we multiply before dividing:
// @audit the assumes are to avoid under/overflows errors function testPOC(uint256 x) external pure { vm.assume(x < type(uint256).max / 1e18); vm.assume(x > 1e18); console2.log("(x / 1e18) * 1e18", (x / 1e18) * 1e18); console2.log("(x * 1e18) / 1e18", (x * 1e18) / 1e18); }
Some examples:
[7725] POC::testPOC(1164518589284217277370 [1.164e21]) ├─ [0] VM::assume(true) [staticcall] │ └─ ← () ├─ [0] VM::assume(true) [staticcall] │ └─ ← () ├─ [0] console::log((x / 1e18) * 1e18, 1164000000000000000000 [1.164e21]) [staticcall] │ └─ ← () ├─ [0] console::log((x * 1e18) / 1e18, 1164518589284217277370 [1.164e21]) [staticcall] │ └─ ← () └─ ← () [7725] POC::testPOC(16826228168456047587 [1.682e19]) ├─ [0] VM::assume(true) [staticcall] │ └─ ← () ├─ [0] VM::assume(true) [staticcall] │ └─ ← () ├─ [0] console::log((x / 1e18) * 1e18, 16000000000000000000 [1.6e19]) [staticcall] │ └─ ← () ├─ [0] console::log((x * 1e18) / 1e18, 16826228168456047587 [1.682e19]) [staticcall] │ └─ ← () └─ ← () [7725] POC::testPOC(5693222591586418917 [5.693e18]) ├─ [0] VM::assume(true) [staticcall] │ └─ ← () ├─ [0] VM::assume(true) [staticcall] │ └─ ← () ├─ [0] console::log((x / 1e18) * 1e18, 5000000000000000000 [5e18]) [staticcall] │ └─ ← () ├─ [0] console::log((x * 1e18) / 1e18, 5693222591586418917 [5.693e18]) [staticcall] │ └─ ← () └─ ← ()
Change the update_market
function to:
function update_market(address _market) public { require(lendingMarketWhitelist[_market], "Market not whitelisted"); MarketInfo storage market = marketInfo[_market]; if (block.number > market.lastRewardBlock) { uint256 marketSupply = lendingMarketTotalBalance[_market]; if (marketSupply > 0) { uint256 i = market.lastRewardBlock; while (i < block.number) { uint256 epoch = (i / BLOCK_EPOCH) * BLOCK_EPOCH; // Rewards and voting weights are aligned on a weekly basis uint256 nextEpoch = i + BLOCK_EPOCH; uint256 blockDelta = Math.min(nextEpoch, block.number) - i; uint256 cantoReward = (blockDelta * cantoPerBlock[epoch] * - gaugeController.gauge_relative_weight_write(_market, epoch)) / 1e18; - market.accCantoPerShare += uint128((cantoReward * 1e18) / marketSupply); + gaugeController.gauge_relative_weight_write(_market, epoch)) * 1e18; + market.accCantoPerShare += uint128((cantoReward / 1e18) / marketSupply); market.secRewardsPerShare += uint128((blockDelta * 1e18) / marketSupply); // TODO: Scaling i += blockDelta; } } market.lastRewardBlock = uint64(block.number); } }
Math
#0 - c4-judge
2024-01-30T15:20:09Z
GalloDaSballo marked the issue as primary issue
#1 - OpenCoreCH
2024-01-31T10:48:06Z
The given examples do not directly apply in our case, we divide a number with 36 decimals by 1e18
and then multiply by 1e18
again (blockDelta
has 0, cantoPerBlock
18, gauge_relative_weight_write
18). Looking at it again, this is pretty inefficient, but I cannot think of a situation where it causes a non-negligible (higher than a few wei) difference in the end. Will double check that
#2 - OpenCoreCH
2024-01-31T11:33:50Z
Ok so I think it could lead to situations where up to 1 CANTO is not distributed, e.g. if (blockDelta * cantoPerBlock[epoch] * gaugeController.gauge_relative_weight_write(_market, epoch))
is 0.99e18, it will round down to 0. Not a huge loss, but also not ideal (especially because it can be avoided easily), so will be changed.
#3 - c4-sponsor
2024-01-31T11:33:56Z
OpenCoreCH (sponsor) confirmed
#4 - c4-judge
2024-02-01T10:12:12Z
GalloDaSballo marked the issue as selected for report
#5 - GalloDaSballo
2024-02-01T10:13:00Z
The Warden has shown how, due to rounding a small loss of yield can accrue
Due to the small scope of the codebase and the specificity of the finding, I'm leaving as Medium as of now
#6 - OpenCoreCH
2024-02-02T11:02:54Z
🌟 Selected for report: rvierdiiev
Data not available
If the block generation rate ever changes in the Canto network, it will lead to epochs being shorter or longer than expected, which affects directly to the amount of CANTO rewards and secundary rewards per share:
uint256 public constant BLOCK_EPOCH = 100_000; // 100000 blocks, roughly 1 week
function update_market(address _market) public { ... uint256 epoch = (i / BLOCK_EPOCH) * BLOCK_EPOCH; // Rewards and voting weights are aligned on a weekly basis uint256 nextEpoch = i + BLOCK_EPOCH; uint256 blockDelta = Math.min(nextEpoch, block.number) - i; uint256 cantoReward = (blockDelta * cantoPerBlock[epoch] * gaugeController.gauge_relative_weight_write(_market, epoch)) / 1e18; market.accCantoPerShare += uint128((cantoReward * 1e18) / marketSupply); market.secRewardsPerShare += uint128((blockDelta * 1e18) / marketSupply); // TODO: Scaling ... }
Consider making it a non-constant variable which can be set by governance:
uint256 public BLOCK_EPOCH = 100_000; // 100000 blocks, roughly 1 week function setBlockEpoch(uint256 epoch) external onlyGovernance { BLOCK_EPOCH = epoch; }
require
LendingLedger, modifier onlyGovernance
modifier onlyGovernance() { require(msg.sender == governance); _; }
Change it to something like:
modifier onlyGovernance() { require(msg.sender == governance, "Not authorized"); _; }
update_market
There are no comments explaining what does update_marked. Consider adding a brief explanation.
Consider emitting events when doing changes to state variables like governance
or when a market is being whitelisted, so that off-chain actors can keep track of the contract state:
Namely, VotingEscrow
, IERC20
and SafeERC20
:
import {VotingEscrow} from "./VotingEscrow.sol"; import {GaugeController} from "./GaugeController.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
#0 - GalloDaSballo
2024-01-30T08:31:14Z
L
require
R
update_market
NC
R
R
1L 3R 1NC + 1 L from dups
2L 3R 1NC
#1 - c4-judge
2024-01-30T15:20:35Z
GalloDaSballo marked the issue as grade-a
#2 - c4-sponsor
2024-01-31T10:57:23Z
OpenCoreCH (sponsor) confirmed
#3 - c4-judge
2024-02-01T10:25:37Z
GalloDaSballo marked the issue as selected for report
#4 - c4-judge
2024-02-01T10:26:06Z
GalloDaSballo marked the issue as not selected for report