Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added audits/claim_contracts/fuzzing_labs.pdf
Binary file not shown.
Binary file added audits/claim_contracts/least_authority.pdf
Binary file not shown.
3 changes: 2 additions & 1 deletion claim_contracts/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ libs = ["lib"]
optimizer = true
optimizer_runs = 999_999

solc_version = "0.8.28"
solc_version = "0.8.35"

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
fs_permissions = [
{ access = "read-write", path = "script-out/" },
{ access = "read", path = "script-config/" },
{ access = "read", path = "test/fixtures/" },
]
2 changes: 1 addition & 1 deletion claim_contracts/src/AlignedToken.sol
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
pragma solidity 0.8.35;

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
Expand Down
31 changes: 22 additions & 9 deletions claim_contracts/src/ClaimableAirdrop.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
pragma solidity 0.8.35;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
Expand All @@ -11,13 +12,24 @@ import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/acces
/// @title Claimable Airdrop
/// @notice This contract is the implementation of the Claimable Airdrop
/// @dev This contract is upgradeable and should be used only through the proxy contract
/// @dev Operational edge cases that cannot be enforced on-chain (the contract only
/// stores the Merkle root, never the full set of leaves):
/// - Revoking unclaimed approvals: the owner can call `updateMerkleRoot` (while
/// paused) to replace the root with one that no longer contains previously valid
/// but unclaimed approvals, effectively revoking them.
/// - First-come, first-served distribution: the contract cannot verify that
/// `tokenDistributor` holds (and has approved) enough tokens to cover every
/// approval. If the distributor is underfunded, later claims revert until it is
/// topped up, so claimants should claim as early as possible.
/// @custom:security-contact security@alignedfoundation.org
contract ClaimableAirdrop is
Initializable,
ReentrancyGuardUpgradeable,
PausableUpgradeable,
Ownable2StepUpgradeable
{
using SafeERC20 for IERC20;

/// @notice Address of the token contract to claim.
address public tokenProxy;

Expand Down Expand Up @@ -94,12 +106,7 @@ contract ClaimableAirdrop is

_verifyAndMark(amount, validFrom, merkleProof);

bool success = IERC20(tokenProxy).transferFrom(
tokenDistributor,
msg.sender,
amount
);
require(success, "Failed to transfer funds");
IERC20(tokenProxy).safeTransferFrom(tokenDistributor, msg.sender, amount);

emit TokensClaimed(msg.sender, amount);
}
Expand Down Expand Up @@ -132,12 +139,11 @@ contract ClaimableAirdrop is

require(totalClaimable > 0, "Nothing to claim");

bool success = IERC20(tokenProxy).transferFrom(
IERC20(tokenProxy).safeTransferFrom(
tokenDistributor,
msg.sender,
totalClaimable
);
require(success, "Failed to transfer funds");

emit TokensClaimed(msg.sender, totalClaimable);
}
Expand Down Expand Up @@ -206,4 +212,11 @@ contract ClaimableAirdrop is
function unpause() external onlyOwner {
_unpause();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — unpause() has no precondition guards

unpause() can be called when either limitTimestampToClaim == 0 or claimMerkleRoot == bytes32(0) (or both). In either case the contract appears live but every claim immediately reverts:

  • limitTimestampToClaim == 0block.timestamp <= 0 is always false → "Drop is no longer claimable"
  • claimMerkleRoot == bytes32(0)MerkleProof.verify can never produce a zero root from a real leaf → "Invalid Merkle proof"

Recovery is always possible (the owner can re-pause, fix the missing precondition, and unpause again), but there is no on-chain warning. The intended call order — updateMerkleRoot, extendClaimPeriod, then unpause — is not enforced anywhere.

Suggested fix:

Suggested change
_unpause();
function unpause() external onlyOwner {
require(claimMerkleRoot != bytes32(0), "Merkle root not set");
require(limitTimestampToClaim > block.timestamp, "Claim period not set");
_unpause();
}

}

/// @notice Prevents the owner from renouncing ownership.
/// @dev Renouncing would set the owner to address(0), permanently disabling
/// `updateMerkleRoot`, `extendClaimPeriod`, `pause`, and `unpause`.
function renounceOwnership() public view override onlyOwner {
revert("Cannot renounce ownership");
}
}
Loading
Loading