Precision Loss Accumulation: The "Two Parser Bug" Lurking in the Shadows
Author: gbaleeeee
Recently, i read the danielvf’s Two Parser Bug twitter(highly recommend!). It also refer to it as the Asymmetric Pattern Bug. This bug introduces the risk that the same input (state) can cause different results when processed through different parsing paths.
simple explain the two parser bug:
Two different implementations tracking the same input(state), and parsing differences cause diverging behavior from different parts of a system.
Some real-world exploit examples(e.g.Wise Lending hack).
I have started to look for this type of bug during my audit process. In most cases, the Two Parser Bug in business logic is unlikely to occur because it’s usually obvious enough for developers to consider. However, I noticed that different parsing paths can lead to small precision loss differences, which can accumulate and cause unexpected results. This bug is not easy to catch, but its probability of occurrence is higher than one might think. I call it the Precision Loss Accumulation Bug. It is tiny but interesting.
Let us drop into this rabbit hole!
What is “Precision Loss Accumulation Bug”?
Due to the smart contract only support fixed-point number, it will be happen precision loss in division operation. And during the function execution, the precision loss may accumulate.
The most simple example is the following:
uint256 public a = 3;
uint256 public b = 2;
function test_PrecisionLossAccumulation() public pure {
uint256 result_1 = a / b + a / b;
uint256 result_2 = (a + a) / b;
assertEq(result_1, result_2);
}
Output:
[FAIL. Reason: assertion failed] test_PrecisionLossAccumulation()
Logs:
Error: a == b not satisfied [uint]
Left: 2
Right: 3
The test will fail, because twice precision loss happened in result_1 calculation process but zero in result_2 calculation process.
The example above seems unrealistic and unlikely to occur in the real world. However, developers often overlook small differences between expected function execution and actual execution. In such cases, precision loss can accumulate and ultimately break the code. I will describe this bug in detail with two findings from my audit project.
Scenario 1
This is a reward distribution platform where users are allocated rewards and can claim them. When users claim their reward tokens, a fee in fee tokens will be deducted from their reward amount. The rewarder will obtain the required amounts of fee and reward tokens by invoking the function getTotalTokenAmount().
Reward distribution platform:
IERC20 rewardToken;
IERC20 feeToken;
uint256 protocolFeeRate;
uint256 denominator = 10000;
mapping(address => uint256) public userRewardAmount;
address[] public users;
address public feeTo;
function allocateReward(address[] calldata _users, uint256[] calldata _rewardAmounts) public onlyRewarder{
`````
}
function getTotalTokenAmount() public view returns (uint256 feeAmount, uint256 rewardAmount) {
uint256 totalRewardAmount;
for (uint256 i = 0; i < users.length; i++) {
totalRewardAmount += userRewardAmount[users[i]];
}
totalFeeAmount = totalRewardAmount * protocolFeeRate / denominator;
totalRewardAmount = totalRewardAmount - totalFeeAmount;
}
function userClaimReward() public {
uint256 rewardAmount = userRewardAmount[msg.sender];
userRewardAmount[msg.sender] = 0;
uint256 feeAmount = rewardAmount * protocolFeeRate / denominator;
rewardToken.transfer(msg.sender, rewardAmount - feeAmount);
feeToken.transfer(feeTo, feeAmount);
}
work flow:
1. rewarder invoke allocateReward() to allocate reward amount for each user.
2. rewarder invoke getTotalTokenAmount() to calculate the total reward token amount and fee token amount.
3. rewarder transfer reward token and fee token to the contract.
4. user invoke userClaimReward() to claim reward token.
The code looks easy and fine, but it is not. Let’s think about what different between the function expected behavior and real execution.
The function getTotalTokenAmount() accumulates the totalRewardAmount by iterating over userRewardAmount and then calculates the totalFeeAmount using the formula totalRewardAmount * protocolFeeRate / denominator. In this case, precision loss occurs only once during the final calculation.
However, in the user claim reward process, each user calculates the feeAmount using rewardAmount * protocolFeeRate / denominator, leading to precision loss occurring multiple times, depending on the number of users. As a result, the calculated totalFeeAmount for user will exceed the actual requirement.
Consequently, the rewarder will transfer more fee tokens to the contract than necessary, resulting in an insufficient amount of reward tokens. Ultimately, the final user will be unable to successfully claim the reward tokens.
Simple PoC:
feeRate: 10%
userA: reward token 105, fee token 105 * 10% = 10
userB: reward token 105, fee token 105 * 10% = 10
expected:
totalFeeAmount: 210 * 10% = 21
totalRewardAmount: 210 - 21 = 189
actual:
totalFeeAmount: 10 + 10 = 20
totalRewardAmount: 210 - 20 = 190
The root cause of this issue is the inconsistent precision loss occurring in different execution paths. The function getTotalTokenAmount() exhibits inconsistent behavior compared to the actual execution scenario.
Scenario 2
Consider a token sale with two phases: private and public. In the private phase, the price is fixed, while in the public phase, the price is dynamic based on the total deposit amount. The public phase will start after the private phase ends. When the public phase ends, the public phase price will be calculated by the formula publicPhaseAllocatedAmount * denominator / totalPublicDepositAmount. Users can claim their tokens after the claim phase starts based on their deposit amount and the current phase price.
Token Launchpad:
IERC20 launchToken;
IERC20 stableCoin;
uint256 fixedPrivatePhasePrice;
uint256 dynamicPublicPhasePrice;
uint256 DENOMINATOR = 1e18;
uint256 saleCap = 100 * 1e18;
uint256 privatePhaseAllocatedAmount;
uint256 publicPhaseAllocatedAmount;
mapping(address => uint256) public userPrivateDepositAmount;
mapping(address => uint256) public userPublicDepositAmount;
uint256 public totalPublicDepositAmount;
function privateSale(uint256 stableCoinAmount) public onlyInPrivatePhase{
stableCoin.transferFrom(msg.sender, address(this), stableCoinAmount);
uint256 launchTokenAmount =
stableCoinAmount * fixedPrivatePhasePrice / DENOMINATOR;
userPrivateDepositAmount[msg.sender] += stableCoinAmount;
privatePhaseAllocatedAmount += launchTokenAmount;
publicPhaseAllocatedAmount = saleCap - privatePhaseAllocatedAmount;
}
function publicSale(uint256 stableCoinAmount) public onlyInPublicPhase{
stableCoin.transferFrom(msg.sender, address(this), stableCoinAmount);
userPublicDepositAmount[msg.sender] += stableCoinAmount;
totalPublicDepositAmount += stableCoinAmount;
calculatePublicPhasePrice();
}
function calculatePublicPhasePrice() public returns (uint256){
dynamicPublicPhasePrice =
publicPhaseAllocatedAmount * denominator / totalPublicDepositAmount;
}
function userClaimLaunchToken() public onlyInClaimPhase{
uint256 launchTokenAmount =
userPublicDepositAmount[msg.sender] * dynamicPublicPhasePrice / denominator;
launchTokenAmount +=
userPrivateDepositAmount[msg.sender] * fixedPrivatePhasePrice / denominator;
userPublicDepositAmount[msg.sender] = 0;
userPrivateDepositAmount[msg.sender] = 0;
launchToken.transfer(msg.sender, launchTokenAmount);
}
work flow:
1. privatePhase begin, user invoke privateSale() to buy token in private phase.(deposit stableCoin, get launchToken with fixed price)
2. publicPhase begin, user invoke publicSale() to buy token in public phase. (deposit stableCoin, update dynamic price)
3. claimPhase begin, user invoke userClaimLaunchToken to claim token. (claim token both private and public phase)
Can you identify where the Precision Loss Accumulation Bug occurs? It takes place between the privateSale() and userClaimLaunchToken() functions. During the private phase, the privatePhaseAllocatedAmount accumulates the launch token amount when the user invokes the privateSale() function. This amount is calculated as stableCoinAmount * fixedPrivatePhasePrice / denominator, and then the stableCoinAmount is added to userPrivateDepositAmount[msg.sender].
In the userClaimLaunchToken() function, the actual launch token amount a user claims is calculated using userPrivateDepositAmount[msg.sender] * fixedPrivatePhasePrice / denominator. If a user invokes the privateSale function multiple times during the private phase, precision loss will occur multiple times in the calculation of privatePhaseAllocatedAmount, but only once in the userClaimLaunchToken() function. Consequently, the privatePhaseAllocatedAmount will record a value lower than the user’s actual claimed private launch token amount.
At first glance, this tiny difference may seem harmless, as the difference amount will be claimed by users during the public phase.
Let’s dig deeper
The publicPhaseAllocatedAmount is calculated as saleCap — privatePhaseAllocatedAmount, so a lower privatePhaseAllocatedAmount will result in a higher publicPhaseAllocatedAmount. This leads to the dynamicPublicPhasePrice being slightly inflated. Users in the public phase will end up claiming more launch tokens at this elevated price, finally the total user try to claim launch token amount maybe exceed the saleCap!
Additionally, it’s important to note that precision loss also occurs in the calculations for userPublicDepositAmount[msg.sender] * dynamicPublicPhasePrice / denominator and userPrivateDepositAmount[msg.sender] * fixedPrivatePhasePrice / denominator in the userClaimLaunchToken() function. Consequently, each user will receive 2 wei fewer launch tokens than expected. The new question occurs: is this enough to offset the impact of the higher dynamicPublicPhasePrice?
This scenario may seem a bit convoluted, but using the Foundry fuzz testing, we can easily identify cases where this issue arises, providing proof that the bug is valid.
Fuzz test:
uint256 DENOMINATOR = 1e18;
function privateSale(uint256 stableCoinAmount, uint256 fixedPrivatePhasePrice) public returns(uint256) {
uint256 launchTokenAmount = stableCoinAmount * fixedPrivatePhasePrice / DENOMINATOR;
return launchTokenAmount;
}
function calculatePublicPhasePrice(uint256 publicPhaseAllocatedAmount, uint256 totalPublicDepositAmount) public returns (uint256){
uint256 dynamicPublicPhasePrice = publicPhaseAllocatedAmount * DENOMINATOR / totalPublicDepositAmount;
return dynamicPublicPhasePrice;
}
function testFuzz_A(
uint256 fixedPrivatePhasePrice,
uint256 perStableCoinAmountInPrivatePhase,
uint256 depositCountInPrivatePhase,
uint256 saleCap,
uint256 perStableCoinAmountInPublicPhase,
uint256 depositCountInPublicPhase)
public
{
fixedPrivatePhasePrice = bound(fixedPrivatePhasePrice, 1e18 - 5e9, 1e18 + 5e9);
perStableCoinAmountInPrivatePhase = bound(perStableCoinAmountInPrivatePhase, 1e18, 1e18 * 100);
depositCountInPrivatePhase = bound(depositCountInPrivatePhase, 1, 10);
/* private phase */
uint256 alltokenAmount;
uint256 privatePhaseAllocatedAmount;
for(uint256 i = 0; i < depositCountInPrivatePhase; i++) {
// calculate the record privatePhaseAllocatedAmount
privatePhaseAllocatedAmount += privateSale(perStableCoinAmountInPrivatePhase, fixedPrivatePhasePrice);
alltokenAmount += perStableCoinAmountInPrivatePhase;
}
// calcualte the real claimed launch token in privatePhase
uint256 realSumTokenAmountInPrivatePhase = privateSale(alltokenAmount, fixedPrivatePhasePrice);
console.log("privatePhaseAllocatedAmount", privatePhaseAllocatedAmount);
console.log("realSumTokenAmountInPrivatePhase", realSumTokenAmountInPrivatePhase);
console.log("diff", realSumTokenAmountInPrivatePhase - privatePhaseAllocatedAmount);
/* public phase */
saleCap = bound(saleCap, 1000e18, 2000e18);
perStableCoinAmountInPublicPhase = bound(perStableCoinAmountInPublicPhase, 1e18, 1e18 * 100);
depositCountInPublicPhase = bound(depositCountInPublicPhase, 1 , 200);
uint256 sumTokenDepositInPublic;
for(uint256 i; i < depositCountInPublicPhase; i++) {
sumTokenDepositInPublic += perStableCoinAmountInPublicPhase;
}
uint256 publicPhaseAllocatedAmount = saleCap - privatePhaseAllocatedAmount;
uint256 dynamicPublicPhasePrice = calculatePublicPhasePrice(publicPhaseAllocatedAmount, sumTokenDepositInPublic);
uint256 dynamicPublicPhasePriceInReal = calculatePublicPhasePrice(saleCap - realSumTokenAmountInPrivatePhase, sumTokenDepositInPublic);
console.log("dynamicPublicPhasePrice", dynamicPublicPhasePrice);
console.log("dynamicPublicPhasePriceInReal", dynamicPublicPhasePriceInReal);
console.log("diff", dynamicPublicPhasePrice - dynamicPublicPhasePriceInReal);
uint256 realSumTokenAmountInPublicPhase;
for(uint256 i; i < depositCountInPublicPhase; i++) {
realSumTokenAmountInPublicPhase += perStableCoinAmountInPublicPhase * dynamicPublicPhasePrice / DENOMINATOR;
}
/* claim phase */
assertGe(saleCap - realSumTokenAmountInPrivatePhase, realSumTokenAmountInPublicPhase);
}
Test result:
FAIL. Reason: assertion failed;
Logs:
Bound Result 999999999914108029
Bound Result 37795041055948721052
Bound Result 6
privatePhaseAllocatedAmount 226770246316214582886
realSumTokenAmountInPrivatePhase 226770246316214582890
diff 4
Bound Result 1422754590674657271191
Bound Result 3981678329177736289
Bound Result 3
dynamicPublicPhasePrice 100123971642332343955
dynamicPublicPhasePriceInReal 100123971642332343954
diff 1
Error: a >= b not satisfied [uint]
Value a: 1195984344358442688301
Value b: 1195984344358442688303
In the private phase, the diff variable represents the difference between the actual claimed launch token amount and the recorded privatePhaseAllocatedAmount. When a user invokes privateSale five times, the diff is approximately 4.
In the public phase, the diff variable reflects the discrepancy between the dynamicpublicphaseprice calculated using publicPhaseAllocatedAmount and the actual dynamic public phase price calculated as saleCap — realSumTokenAmountInPrivatePhase. When three users invoke userClaimLaunchToken at a higher price of 1 wei, the total amount they attempt to claim exceeds the saleCap by 2 wei. This issue is valid!
The bug demonstrates that even tiny accumulations of precision loss can lead to significant unexpected results. In this case, the precision loss is subtle and difficult to detect, making it challenging to assess the true impact. However, with fuzz or invariant testing, it can be more easily evaluate the impact and likelihood of such issues.
How to catch precision loss accumulation bug?
Asymmetric pattern between the developer’s expected behavior and actual execution is often the source of this bug. When reviewing the code, we should pay close attention to division operations and consider whether precision loss may accumulate across different execution paths. Additionally, we should check for the presence of multiple parsers in the code. Since precision loss is subtle and difficult to quantify, fuzz or invariant testing tools can help us assess its real impact.
Recommended Articles
1. Exploiting Precision Loss via Fuzz Testing.
2. Different Parsers, Different Results.