Vyper Vested Claims

First Flight #34
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: medium
Valid

Integer Division Rounding in Vesting Calculation Can Lead to Token Loss

Summary

The _calculate_vested_amount function uses integer division to calculate the instant release and linear vesting portions, which can lead to token loss due to rounding errors when the total amount is not perfectly divisible by 100.

Vulnerability Details

The vesting calculation splits the total amount into two parts using integer division:

def _calculate_vested_amount(total_amount: uint256) -> uint256:
current_time: uint256 = block.timestamp
start_time: uint256 = self.vesting_start_time
end_time: uint256 = self.vesting_end_time
vested: uint256 = 0
if current_time >= end_time:
return total_amount
vesting_duration: uint256 = end_time - start_time
elapsed: uint256 = current_time - start_time
instant_release: uint256 = (total_amount * 31) // 100
linear_vesting: uint256 = (total_amount * 69) // 100
vested = instant_release + (linear_vesting * elapsed) // vesting_duration
return vested

When total_amount is not perfectly divisible by 100, both calculations will lose some precision due to truncation in integer division. This means that instant_release + linear_vesting might not equal total_amount, resulting in some tokens being permanently locked in the contract.

For example:

  • If total_amount = 101:

    • instant_release = (101 * 31) // 100 = 31.31 → 31 (truncated)

    • linear_vesting = (101 * 69) // 100 = 69.69 → 69 (truncated)

    • instant_release + linear_vesting = 31 + 69 = 100 (not 101!)

    • Result: 1 token is lost due to rounding errors

Proof of Concept

The following Python code demonstrates the rounding issue:

def test_rounding_issue():
"""
Proof of Concept for the rounding issue in _calculate_vested_amount
due to integer division in the instant_release and linear_vesting calculations
"""
# Test with different total amounts to show the rounding issue
test_amounts = [100, 101, 199, 999, 1000, 1001, 10001]
for total_amount in test_amounts:
# Calculate using the contract's method
instant_release = (total_amount * 31) // 100
linear_vesting = (total_amount * 69) // 100
total_calculated = instant_release + linear_vesting
# Calculate the loss
loss = total_amount - total_calculated
# Print the results
print(f"Total amount: {total_amount} tokens")
print(f"Instant release (31%): {instant_release} tokens")
print(f"Linear vesting (69%): {linear_vesting} tokens")
print(f"Sum of parts: {total_calculated} tokens")
if loss > 0:
print(f"LOSS DETECTED: {loss} tokens ({(loss/total_amount)*100:.4f}% of allocation)")
else:
print("No loss detected")

When executed, this PoC shows that:

  1. For amounts divisible by 100 (like 100, 1000), no tokens are lost

  2. For amounts not divisible by 100 (like 101, 199, 999, 1001, 10001), 1 token is lost due to rounding

  3. The percentage of loss varies by allocation size (0.99% for 101 tokens, 0.01% for 10001 tokens)

Impact

  • Token Loss: Some tokens might be permanently locked in the contract because they can never be claimed due to rounding errors

  • Accounting Discrepancy: The sum of all claimed tokens might be less than the total tokens allocated in the Merkle tree

  • Fairness Issue: Users with certain allocation amounts might lose more tokens proportionally than others

The severity is low because:

  1. The contract correctly returns the full amount at the end of vesting with if current_time >= end_time: return total_amount

  2. The percentage of tokens lost is very small (typically 1 token)

  3. It's a precision issue rather than a security vulnerability

Recommendation

While the contract already handles the end of vesting correctly, for better precision during the vesting period, consider one of these approaches:

  1. Adjust one of the percentages to ensure they sum to 100% after division:

instant_release = (total_amount * 31) // 100
linear_vesting = total_amount - instant_release # Ensure no tokens are lost
  1. Or calculate the percentage first, then apply it once:

def _calculate_vested_amount(total_amount: uint256) -> uint256:
current_time: uint256 = block.timestamp
start_time: uint256 = self.vesting_start_time
end_time: uint256 = self.vesting_end_time
if current_time < start_time:
return (total_amount * 31) // 100
if current_time >= end_time:
return total_amount
vesting_duration: uint256 = end_time - start_time
elapsed: uint256 = current_time - start_time
# Calculate percentage first, then apply to total amount once
instant_percent: uint256 = 31
linear_percent: uint256 = (elapsed * 69) // vesting_duration
total_percent: uint256 = instant_percent + linear_percent
vested = (total_amount * total_percent) // 100
return vested
Updates

Appeal created

bube Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Rounding issue in vesting calculation

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.