Vyper Vested Claims

First Flight #34
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: high
Invalid

Incorrect Vesting Implementation Leading to Token Theft inside the `VestedAirdrop::claim` function.

Description: The claim function has a critical flaw in how it verifies vesting claims. It doesn't store the total amount a user is entitled to in the contract state, but instead relies on the caller to provide it each time. This allows an attacker to claim amounts multiple times by simply calling the function repeatedly with valid merkle proofs.

Lines 180-206 in the claim function:

@external
def claim(user: address, total_amount: uint256, proof: DynArray[bytes32, 20]) -> bool:
"""
@notice This function is used to claim the tokens
@dev Anyone can claim for any user
@param user address, the address of the user
@param total_amount uint256, the total amount of tokens
@param proof DynArray[bytes32, 20], the merkle proof
@return bool True if the claim is successful
"""
# Checks
assert self.verify_proof(user, total_amount, proof), "Invalid proof"
assert block.timestamp >= self.vesting_start_time, "Claiming is not available yet"
claimable: uint256 = 0
current_amount: uint256 = self.claimed_amount[user]
vested: uint256 = self._calculate_vested_amount(total_amount)
# Calculate how much the user can claim now
if vested > current_amount:
claimable = vested - current_amount
assert claimable > 0, "Nothing to claim"
# Update the claimed amount - Effects
self.claimed_amount[user] += claimable
# invariant: claimed amount should always be less than or equal to amount (better safe then sorry)
assert current_amount + claimable <= total_amount, "Claimed amount exceeds total amount"
log Claimed(user, claimable)
# Transfer the claimable amount to the user - Interactions
_success: bool = extcall IERC20(self.token).transfer(user, claimable)
assert _success, "Transfer failed"
return True

Impact: An attacker can drain all of the vesting contract's tokens by:

  1. Creating a valid merkle proof for a small amount (e.g., 100 tokens)

  2. Repeatedly calling claim() with this proof

  3. The contract will continuously release more tokens based on the vesting schedule without tracking that these releases are for the same allocation

Each time the function is called with the same total_amount and a valid proof, it will calculate a new vested amount based on the current timestamp. The contract doesn't track that these multiple claims are for the same original allocation.

Proof of Concept: Assuming Alice has a valid proof for 1000 tokens:

  1. At day 1 of vesting, Alice claims 310 tokens (31% instant release)

  2. At day 100, Alice calls claim again with the same proof and receives additional tokens based on the linear vesting schedule

  3. Alice can repeat this process, effectively claiming multiple times for the same 1000 token allocation

Recommended Mitigation: Store the total allocation for each user alongside their claimed amount:

total_allocation: public(HashMap[address, uint256])
@external
def claim(user: address, total_amount: uint256, proof: DynArray[bytes32, 20]) -> bool:
# Checks
assert self.verify_proof(user, total_amount, proof), "Invalid proof"
assert block.timestamp >= self.vesting_start_time, "Claiming is not available yet"
# Check if this is the first claim for this user
if self.total_allocation[user] == 0:
self.total_allocation[user] = total_amount
else:
# Ensure the total_amount matches the previously recorded allocation
assert self.total_allocation[user] == total_amount, "Allocation mismatch"
claimable: uint256 = 0
current_amount: uint256 = self.claimed_amount[user]
vested: uint256 = self._calculate_vested_amount(total_amount)
# Rest of the function remains the same
# ...
Updates

Appeal created

bube Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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