diff --git a/audits/claim_contracts/fuzzing_labs.pdf b/audits/claim_contracts/fuzzing_labs.pdf new file mode 100644 index 0000000000..1f86cb64c6 Binary files /dev/null and b/audits/claim_contracts/fuzzing_labs.pdf differ diff --git a/audits/claim_contracts/least_authority.pdf b/audits/claim_contracts/least_authority.pdf new file mode 100644 index 0000000000..69f0dbed49 Binary files /dev/null and b/audits/claim_contracts/least_authority.pdf differ diff --git a/claim_contracts/foundry.toml b/claim_contracts/foundry.toml index 0853254495..57e3d926e1 100644 --- a/claim_contracts/foundry.toml +++ b/claim_contracts/foundry.toml @@ -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/" }, ] diff --git a/claim_contracts/src/AlignedToken.sol b/claim_contracts/src/AlignedToken.sol index 9ea9c81078..0370c56750 100644 --- a/claim_contracts/src/AlignedToken.sol +++ b/claim_contracts/src/AlignedToken.sol @@ -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"; diff --git a/claim_contracts/src/ClaimableAirdrop.sol b/claim_contracts/src/ClaimableAirdrop.sol index d79f93cf6d..ac2391c6be 100644 --- a/claim_contracts/src/ClaimableAirdrop.sol +++ b/claim_contracts/src/ClaimableAirdrop.sol @@ -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"; @@ -11,6 +12,15 @@ 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, @@ -18,6 +28,8 @@ contract ClaimableAirdrop is PausableUpgradeable, Ownable2StepUpgradeable { + using SafeERC20 for IERC20; + /// @notice Address of the token contract to claim. address public tokenProxy; @@ -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); } @@ -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); } @@ -206,4 +212,11 @@ contract ClaimableAirdrop is function unpause() external onlyOwner { _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"); + } } diff --git a/claim_contracts/test/ClaimableAirdrop.t.sol b/claim_contracts/test/ClaimableAirdrop.t.sol new file mode 100644 index 0000000000..66060d8dae --- /dev/null +++ b/claim_contracts/test/ClaimableAirdrop.t.sol @@ -0,0 +1,465 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.35; + +import {Test} from "forge-std/Test.sol"; +import {ClaimableAirdrop} from "../src/ClaimableAirdrop.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {ERC20ReturnFalseMock} from "@openzeppelin/contracts/mocks/token/ERC20ReturnFalseMock.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +/// @dev Concrete instance of the abstract OZ mock that returns `false` from +/// transfer/transferFrom instead of reverting. Used to exercise the +/// SafeERC20 path (FL-AL-2). +contract ERC20ReturnFalse is ERC20ReturnFalseMock { + constructor() ERC20("ReturnFalse", "RF") {} +} + +contract ClaimableAirdropTest is Test { + ClaimableAirdrop internal airdrop; + ERC20Mock internal token; + + address internal foundation = makeAddr("foundation"); + address internal distributor = makeAddr("distributor"); + address internal claimant; + uint256 internal claimantPk; + + uint256 internal constant AMOUNT = 1_000 ether; + uint256 internal constant DEADLINE = 1_000_000; + + function setUp() public { + (claimant, claimantPk) = makeAddrAndKey("claimant"); + + token = new ERC20Mock(); + + ClaimableAirdrop impl = new ClaimableAirdrop(); + bytes memory initData = abi.encodeCall( + ClaimableAirdrop.initialize, + (foundation, address(token), distributor) + ); + airdrop = ClaimableAirdrop(address(new ERC1967Proxy(address(impl), initData))); + + // Fund the distributor and approve the airdrop to pull tokens. + token.mint(distributor, 1_000_000 ether); + vm.prank(distributor); + token.approve(address(airdrop), type(uint256).max); + } + + /* -------------------------------------------------------------------------- */ + /* Merkle utils */ + /* -------------------------------------------------------------------------- */ + + function _leaf(address to, uint256 amount, uint256 validFrom) internal pure returns (bytes32) { + return keccak256(bytes.concat(keccak256(abi.encode(to, amount, validFrom)))); + } + + /// @dev Sets a single-leaf tree (root == leaf, empty proof) as the active root. + /// A single-leaf StandardMerkleTree has the leaf itself as its root and an + /// empty proof, so no proof generation is needed for the single-claim cases. + function _setSingleLeafRoot(address to, uint256 amount, uint256 validFrom) + internal + returns (bytes32[] memory proof) + { + bytes32 root = _leaf(to, amount, validFrom); + vm.prank(foundation); + airdrop.updateMerkleRoot(root); + proof = new bytes32[](0); + } + + /* -------------------------------------------------------------------------- */ + /* Merkle fixture (generated) */ + /* -------------------------------------------------------------------------- */ + + /// @dev One leaf and its proof, as produced by the Rust fixture generator + /// (test/fixtures/generator) which uses the same merkle-tree-rs library and + /// leaf encoding as the production proof generator. Proofs are NOT computed + /// in Solidity; the tests only consume the generator's output. + struct FixtureLeaf { + address account; + uint256 amount; + uint256 validFrom; + bytes32[] proof; + } + + string internal constant FIXTURE_PATH = "test/fixtures/proofs.json"; + + /// @dev Loads the generated Merkle root and every leaf+proof from the JSON fixture. + function _loadFixture() internal view returns (bytes32 root, FixtureLeaf[] memory leaves) { + string memory json = vm.readFile(FIXTURE_PATH); + root = vm.parseJsonBytes32(json, ".root"); + + uint256 count = vm.parseJsonUint(json, ".count"); + leaves = new FixtureLeaf[](count); + for (uint256 i = 0; i < count; i++) { + string memory base = string.concat(".leaves[", vm.toString(i), "]"); + leaves[i] = FixtureLeaf({ + account: vm.parseJsonAddress(json, string.concat(base, ".account")), + amount: vm.parseJsonUint(json, string.concat(base, ".amount")), + validFrom: vm.parseJsonUint(json, string.concat(base, ".validFrom")), + proof: vm.parseJsonBytes32Array(json, string.concat(base, ".proof")) + }); + } + } + + /// @dev Loads the fixture, installs its root, opens the claim period and unpauses. + function _armFixture() internal returns (FixtureLeaf[] memory leaves) { + bytes32 root; + (root, leaves) = _loadFixture(); + vm.prank(foundation); + airdrop.updateMerkleRoot(root); + vm.prank(foundation); + airdrop.extendClaimPeriod(DEADLINE); + vm.prank(foundation); + airdrop.unpause(); + } + + /* -------------------------------------------------------------------------- */ + /* Initialization */ + /* -------------------------------------------------------------------------- */ + + function test_initialize_setsState() public view { + assertEq(airdrop.tokenProxy(), address(token)); + assertEq(airdrop.tokenDistributor(), distributor); + assertEq(airdrop.owner(), foundation); + assertEq(airdrop.limitTimestampToClaim(), 0); + assertEq(airdrop.claimMerkleRoot(), bytes32(0)); + assertTrue(airdrop.paused()); + } + + function test_initialize_cannotBeCalledTwice() public { + vm.expectRevert(); + airdrop.initialize(foundation, address(token), distributor); + } + + /* -------------------------------------------------------------------------- */ + /* claim */ + /* -------------------------------------------------------------------------- */ + + function _arm(uint256 validFrom) internal returns (bytes32[] memory proof) { + proof = _setSingleLeafRoot(claimant, AMOUNT, validFrom); + vm.prank(foundation); + airdrop.extendClaimPeriod(DEADLINE); + vm.prank(foundation); + airdrop.unpause(); + } + + function test_claim_success() public { + bytes32[] memory proof = _arm(0); + + vm.prank(claimant); + airdrop.claim(AMOUNT, 0, proof); + + assertEq(token.balanceOf(claimant), AMOUNT); + assertTrue(airdrop.hasClaimed(_leaf(claimant, AMOUNT, 0))); + } + + function test_claim_revertsWhenPaused() public { + bytes32[] memory proof = _setSingleLeafRoot(claimant, AMOUNT, 0); + vm.prank(foundation); + airdrop.extendClaimPeriod(DEADLINE); + // still paused + + vm.prank(claimant); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + airdrop.claim(AMOUNT, 0, proof); + } + + function test_claim_revertsAfterDeadline() public { + bytes32[] memory proof = _arm(0); + vm.warp(DEADLINE + 1); + + vm.prank(claimant); + vm.expectRevert("Drop is no longer claimable"); + airdrop.claim(AMOUNT, 0, proof); + } + + function test_claim_revertsWhenStageNotYetClaimable() public { + uint256 validFrom = 500; + bytes32[] memory proof = _arm(validFrom); + vm.warp(validFrom - 1); + + vm.prank(claimant); + vm.expectRevert("Stage not yet claimable"); + airdrop.claim(AMOUNT, validFrom, proof); + } + + function test_claim_revertsOnDoubleClaim() public { + bytes32[] memory proof = _arm(0); + + vm.prank(claimant); + airdrop.claim(AMOUNT, 0, proof); + + vm.prank(claimant); + vm.expectRevert("Stage already claimed"); + airdrop.claim(AMOUNT, 0, proof); + } + + function test_claim_revertsOnInvalidProof() public { + _arm(0); + bytes32[] memory badProof = new bytes32[](1); + badProof[0] = keccak256("garbage"); + + vm.prank(claimant); + vm.expectRevert("Invalid Merkle proof"); + airdrop.claim(AMOUNT, 0, badProof); + } + + function test_claim_revertsForWrongAmount() public { + bytes32[] memory proof = _arm(0); + + vm.prank(claimant); + vm.expectRevert("Invalid Merkle proof"); + airdrop.claim(AMOUNT + 1, 0, proof); + } + + function test_claim_revertsForNonEntitledCaller() public { + bytes32[] memory proof = _arm(0); + address attacker = makeAddr("attacker"); + + vm.prank(attacker); + vm.expectRevert("Invalid Merkle proof"); + airdrop.claim(AMOUNT, 0, proof); + } + + /* -------------------------------------------------------------------------- */ + /* claim against the generated Merkle tree */ + /* -------------------------------------------------------------------------- */ + + /// @dev Every leaf in the generated tree can be claimed with its generator + /// proof. Validates the on-chain verifier against real merkle-tree-rs output + /// (multi-level proofs), not a Solidity reimplementation. + function test_claim_fixtureProofs_allClaim() public { + FixtureLeaf[] memory leaves = _armFixture(); + vm.warp(2000); // >= every leaf's validFrom, <= DEADLINE + + for (uint256 i = 0; i < leaves.length; i++) { + FixtureLeaf memory lf = leaves[i]; + assertGt(lf.proof.length, 1, "expected a multi-level proof"); + + uint256 balanceBefore = token.balanceOf(lf.account); + vm.prank(lf.account); + airdrop.claim(lf.amount, lf.validFrom, lf.proof); + + assertEq(token.balanceOf(lf.account), balanceBefore + lf.amount); + assertTrue(airdrop.hasClaimed(_leaf(lf.account, lf.amount, lf.validFrom))); + } + } + + /// @dev A valid leaf cannot be claimed with a different leaf's generator proof. + function test_claim_fixtureProofs_wrongProofReverts() public { + FixtureLeaf[] memory leaves = _armFixture(); + vm.warp(2000); + + // leaves[3] is claimed with leaves[4]'s proof. + FixtureLeaf memory victim = leaves[3]; + bytes32[] memory wrongProof = leaves[4].proof; + + vm.prank(victim.account); + vm.expectRevert("Invalid Merkle proof"); + airdrop.claim(victim.amount, victim.validFrom, wrongProof); + } + + /* -------------------------------------------------------------------------- */ + /* claimBatch */ + /* -------------------------------------------------------------------------- */ + + function test_claimBatch_success() public { + FixtureLeaf[] memory leaves = _armFixture(); + vm.warp(2000); // >= every stage's validFrom, <= DEADLINE + + // The fixture gives the first account multiple vesting stages; claim them all + // in a single batch using the generator proofs. + address account = leaves[0].account; + uint256 n; + for (uint256 i = 0; i < leaves.length; i++) { + if (leaves[i].account == account) n++; + } + require(n > 1, "fixture must have a multi-stage account"); + + uint256[] memory amounts = new uint256[](n); + uint256[] memory validFroms = new uint256[](n); + bytes32[][] memory proofs = new bytes32[][](n); + uint256 total; + uint256 k; + for (uint256 i = 0; i < leaves.length; i++) { + if (leaves[i].account != account) continue; + amounts[k] = leaves[i].amount; + validFroms[k] = leaves[i].validFrom; + proofs[k] = leaves[i].proof; + total += leaves[i].amount; + k++; + } + + vm.prank(account); + airdrop.claimBatch(amounts, validFroms, proofs); + + assertEq(token.balanceOf(account), total); + for (uint256 i = 0; i < n; i++) { + assertTrue(airdrop.hasClaimed(_leaf(account, amounts[i], validFroms[i]))); + } + } + + function test_claimBatch_revertsOnArrayLengthMismatch() public { + bytes32[] memory proof = _arm(0); + + uint256[] memory amounts = new uint256[](1); + amounts[0] = AMOUNT; + uint256[] memory validFroms = new uint256[](2); // mismatched + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = proof; + + vm.prank(claimant); + vm.expectRevert("Array length mismatch"); + airdrop.claimBatch(amounts, validFroms, proofs); + } + + /// @dev An empty batch sums to zero and must revert rather than be a no-op. + function test_claimBatch_revertsOnEmptyBatch() public { + _arm(0); // open the claim period + + uint256[] memory amounts = new uint256[](0); + uint256[] memory validFroms = new uint256[](0); + bytes32[][] memory proofs = new bytes32[][](0); + + vm.prank(claimant); + vm.expectRevert("Nothing to claim"); + airdrop.claimBatch(amounts, validFroms, proofs); + } + + /// @dev The same leaf cannot be claimed twice within a single batch: the first + /// entry marks it claimed, so the second hits the double-claim guard. + function test_claimBatch_revertsOnDuplicateLeaf() public { + FixtureLeaf[] memory leaves = _armFixture(); + vm.warp(2000); + + FixtureLeaf memory lf = leaves[0]; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = lf.amount; + amounts[1] = lf.amount; + uint256[] memory validFroms = new uint256[](2); + validFroms[0] = lf.validFrom; + validFroms[1] = lf.validFrom; + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = lf.proof; + proofs[1] = lf.proof; + + vm.prank(lf.account); + vm.expectRevert("Stage already claimed"); + airdrop.claimBatch(amounts, validFroms, proofs); + } + + /* -------------------------------------------------------------------------- */ + /* Access control */ + /* -------------------------------------------------------------------------- */ + + function test_updateMerkleRoot_onlyOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + airdrop.updateMerkleRoot(keccak256("root")); + } + + function test_updateMerkleRoot_requiresPaused() public { + _arm(0); // leaves the contract unpaused + vm.prank(foundation); + vm.expectRevert(PausableUpgradeable.ExpectedPause.selector); + airdrop.updateMerkleRoot(keccak256("new")); + } + + function test_extendClaimPeriod_onlyOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + airdrop.extendClaimPeriod(DEADLINE); + } + + function test_extendClaimPeriod_mustMoveForward() public { + vm.prank(foundation); + airdrop.extendClaimPeriod(DEADLINE); + vm.prank(foundation); + vm.expectRevert("Can only extend from current timestamp"); + airdrop.extendClaimPeriod(DEADLINE); // not greater than current + } + + function test_pause_unpause_onlyOwner() public { + vm.prank(foundation); + airdrop.unpause(); + assertFalse(airdrop.paused()); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + airdrop.pause(); + + vm.prank(foundation); + airdrop.pause(); + assertTrue(airdrop.paused()); + } + + /// @dev FL-AL-1: renouncing ownership must be impossible. + function test_renounceOwnership_reverts() public { + vm.prank(foundation); + vm.expectRevert("Cannot renounce ownership"); + airdrop.renounceOwnership(); + assertEq(airdrop.owner(), foundation); + } + + /* -------------------------------------------------------------------------- */ + /* SafeERC20 path */ + /* -------------------------------------------------------------------------- */ + + /// @dev FL-AL-2: a token that returns `false` (instead of reverting) must still + /// cause the claim to revert via SafeERC20, leaving no tokens transferred. + function test_claim_revertsWhenTokenReturnsFalse() public { + ERC20ReturnFalse badToken = new ERC20ReturnFalse(); + + ClaimableAirdrop impl = new ClaimableAirdrop(); + bytes memory initData = abi.encodeCall( + ClaimableAirdrop.initialize, + (foundation, address(badToken), distributor) + ); + ClaimableAirdrop badAirdrop = + ClaimableAirdrop(address(new ERC1967Proxy(address(impl), initData))); + + bytes32 root = _leaf(claimant, AMOUNT, 0); + vm.prank(foundation); + badAirdrop.updateMerkleRoot(root); + vm.prank(foundation); + badAirdrop.extendClaimPeriod(DEADLINE); + vm.prank(foundation); + badAirdrop.unpause(); + + bytes32[] memory proof = new bytes32[](0); + vm.prank(claimant); + vm.expectRevert(); // SafeERC20FailedOperation + badAirdrop.claim(AMOUNT, 0, proof); + } + + /// @dev FL-AL-2/FL-AL-3: a failed transfer rolls back the whole claim, including + /// the `hasClaimed` write, so the leaf is not stranded and the user can retry + /// once the distributor is funded/approved again. + function test_claim_failedTransferDoesNotStrandLeaf() public { + bytes32[] memory proof = _arm(0); + bytes32 leaf = _leaf(claimant, AMOUNT, 0); + + // Distributor revokes the approval -> the transfer (and thus the claim) reverts. + vm.prank(distributor); + token.approve(address(airdrop), 0); + + vm.prank(claimant); + vm.expectRevert(); // ERC20InsufficientAllowance, bubbled by SafeERC20 + airdrop.claim(AMOUNT, 0, proof); + + // The leaf must remain unclaimed and no tokens moved. + assertFalse(airdrop.hasClaimed(leaf)); + assertEq(token.balanceOf(claimant), 0); + + // After the distributor restores the approval, the same claim succeeds. + vm.prank(distributor); + token.approve(address(airdrop), type(uint256).max); + + vm.prank(claimant); + airdrop.claim(AMOUNT, 0, proof); + + assertEq(token.balanceOf(claimant), AMOUNT); + assertTrue(airdrop.hasClaimed(leaf)); + } +} diff --git a/claim_contracts/test/fixtures/README.md b/claim_contracts/test/fixtures/README.md new file mode 100644 index 0000000000..2f409cc5d4 --- /dev/null +++ b/claim_contracts/test/fixtures/README.md @@ -0,0 +1,26 @@ +# Merkle proof test fixtures + +`proofs.json` holds a Merkle root and the proof for every leaf of a small test +tree. It is consumed by `test/ClaimableAirdrop.t.sol` so the Solidity tests +verify the contract against **real proof-generator output** instead of a Merkle +implementation reimplemented in Solidity. + +The fixture is produced by `generator/`, a small Rust binary that uses the same +`merkle-tree-rs` revision and the same leaf encoding (`["address","uint256","uint256"]`) +as the production proof generator in +`aligned_airdrop_web/merkle_proof_generator`. This guarantees the proofs match +what real claimants receive. + +## Regenerating + +From the `claim_contracts/` directory: + +```sh +cargo run --release \ + --manifest-path test/fixtures/generator/Cargo.toml \ + -- test/fixtures/proofs.json +``` + +Edit the `LEAVES` table in `generator/src/main.rs` to change the test data, then +regenerate. Commit the updated `proofs.json`; the Rust toolchain is only needed +to regenerate it, never to run the Solidity tests. diff --git a/claim_contracts/test/fixtures/generator/.gitignore b/claim_contracts/test/fixtures/generator/.gitignore new file mode 100644 index 0000000000..96ef6c0b94 --- /dev/null +++ b/claim_contracts/test/fixtures/generator/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/claim_contracts/test/fixtures/generator/Cargo.toml b/claim_contracts/test/fixtures/generator/Cargo.toml new file mode 100644 index 0000000000..530ec26cad --- /dev/null +++ b/claim_contracts/test/fixtures/generator/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "merkle_fixture_gen" +version = "0.1.0" +edition = "2021" + +# Generates the Merkle proof fixture consumed by the Solidity test suite +# (test/ClaimableAirdrop.t.sol). It uses the SAME merkle-tree-rs revision as the +# production proof generator (aligned_airdrop_web/merkle_proof_generator) so the +# fixture matches what real claimants receive byte-for-byte. + +[[bin]] +name = "merkle_fixture_gen" +path = "src/main.rs" + +[dependencies] +merkle-tree-rs = { git = "https://github.com/lambdaclass/merkle-tree-rs", rev = "e52c7aa5d0517636c7a4706e21e6e52d07266ef1" } +ethers = "2" +serde_json = "1" diff --git a/claim_contracts/test/fixtures/generator/src/main.rs b/claim_contracts/test/fixtures/generator/src/main.rs new file mode 100644 index 0000000000..b0f6ed4bd8 --- /dev/null +++ b/claim_contracts/test/fixtures/generator/src/main.rs @@ -0,0 +1,77 @@ +// Generates the Merkle proof fixture used by the Solidity test suite. +// +// It builds an OpenZeppelin StandardMerkleTree with the SAME library +// (merkle-tree-rs) and SAME leaf encoding (["address","uint256","uint256"]) as +// the production proof generator, then writes the root and every leaf's proof to +// a JSON file. The Solidity tests load that file and verify the contract accepts +// the proofs, so the on-chain verifier is tested against real generator output +// rather than a Solidity reimplementation. +// +// Usage: cargo run -- + +use merkle_tree_rs::standard::{LeafType, StandardMerkleTree}; +use std::{env, fs}; + +// (address, amount (wei), valid_from). The first three leaves share one account +// with distinct (amount, valid_from) pairs so the batch-claim path can be tested. +const LEAVES: &[(&str, &str, &str)] = &[ + ("0x00000000000000000000000000000000000000a1", "100000000000000000000", "0"), + ("0x00000000000000000000000000000000000000a1", "200000000000000000000", "1000"), + ("0x00000000000000000000000000000000000000a1", "300000000000000000000", "2000"), + ("0x00000000000000000000000000000000000000b2", "400000000000000000000", "0"), + ("0x00000000000000000000000000000000000000c3", "500000000000000000000", "0"), + ("0x00000000000000000000000000000000000000d4", "600000000000000000000", "0"), + ("0x00000000000000000000000000000000000000e5", "700000000000000000000", "0"), + ("0x00000000000000000000000000000000000000f6", "800000000000000000000", "0"), +]; + +fn main() { + let out_path = env::args() + .nth(1) + .expect("usage: merkle_fixture_gen "); + + let values: Vec> = LEAVES + .iter() + .map(|(addr, amount, valid_from)| { + vec![addr.to_string(), amount.to_string(), valid_from.to_string()] + }) + .collect(); + + let tree = StandardMerkleTree::of( + &values, + &[ + "address".to_string(), + "uint256".to_string(), + "uint256".to_string(), + ], + ) + .expect("failed to build merkle tree"); + + let leaves_json: Vec = LEAVES + .iter() + .enumerate() + .map(|(i, (addr, amount, valid_from))| { + let proof = tree + .get_proof(LeafType::Number(i)) + .expect("failed to get proof"); + serde_json::json!({ + "account": addr, + "amount": amount, + "validFrom": valid_from, + "proof": proof, + }) + }) + .collect(); + + let fixture = serde_json::json!({ + "root": tree.root(), + "count": LEAVES.len(), + "leaves": leaves_json, + }); + + fs::write(&out_path, serde_json::to_string_pretty(&fixture).unwrap()) + .expect("failed to write fixture file"); + + println!("wrote {} leaves to {}", LEAVES.len(), out_path); + println!("root: {}", tree.root()); +} diff --git a/claim_contracts/test/fixtures/proofs.json b/claim_contracts/test/fixtures/proofs.json new file mode 100644 index 0000000000..2c417ab5c4 --- /dev/null +++ b/claim_contracts/test/fixtures/proofs.json @@ -0,0 +1,86 @@ +{ + "count": 8, + "leaves": [ + { + "account": "0x00000000000000000000000000000000000000a1", + "amount": "100000000000000000000", + "proof": [ + "0xb46d5842e1f1ef456cc2a1fefde2818fd9f20b8833385cfb7e6f8163b440836e", + "0x599220f14a217f7a7d0c3c43436f62c6595ac7c2eaf6d9328b83b9b2a6a0c818", + "0xea2422f772538edc01973b75e4b4ca33cb5a4263569a4ad1bb3ebc112d6b48a3" + ], + "validFrom": "0" + }, + { + "account": "0x00000000000000000000000000000000000000a1", + "amount": "200000000000000000000", + "proof": [ + "0xc467e73a71c5f8ebe66a92658b4a000b9c9a5a1d87c3f1e0a627a74ce0d5cfc6", + "0x599220f14a217f7a7d0c3c43436f62c6595ac7c2eaf6d9328b83b9b2a6a0c818", + "0xea2422f772538edc01973b75e4b4ca33cb5a4263569a4ad1bb3ebc112d6b48a3" + ], + "validFrom": "1000" + }, + { + "account": "0x00000000000000000000000000000000000000a1", + "amount": "300000000000000000000", + "proof": [ + "0x253335eb5fab5fa7d58667a676a2ab84a5777e74e7ab786e44dd7ff3b29a19ff", + "0x266a1ecb1de2422506a620ef109b0e3651ee9f287c433a3cce8447d9d3154aae", + "0xcfddf82fde9a123f4a6304e74b0498b0425d4a75cd711a2b8c2cc020ceac4fac" + ], + "validFrom": "2000" + }, + { + "account": "0x00000000000000000000000000000000000000b2", + "amount": "400000000000000000000", + "proof": [ + "0x183589af610c4b71737b995e05cebbda0966df567f40ced11a052a12a80b0313", + "0x9e7a53fb112a76f949cd345775870d18f9091df04b8f6f97ac55fb2405d09213", + "0xcfddf82fde9a123f4a6304e74b0498b0425d4a75cd711a2b8c2cc020ceac4fac" + ], + "validFrom": "0" + }, + { + "account": "0x00000000000000000000000000000000000000c3", + "amount": "500000000000000000000", + "proof": [ + "0x987dfe898f09ee095a14c0ecd2ed4c57baa61cd8a9b4a7087dbe991e17be2f07", + "0x266a1ecb1de2422506a620ef109b0e3651ee9f287c433a3cce8447d9d3154aae", + "0xcfddf82fde9a123f4a6304e74b0498b0425d4a75cd711a2b8c2cc020ceac4fac" + ], + "validFrom": "0" + }, + { + "account": "0x00000000000000000000000000000000000000d4", + "amount": "600000000000000000000", + "proof": [ + "0x9a842f466032b4c0113ddb97fafd600e894bf38ff0b48287eb0095c88fe1151a", + "0x43a04549f5b08d1bf3be7517ba4fc5b738ff082cfaa209795ac5315c044002c8", + "0xea2422f772538edc01973b75e4b4ca33cb5a4263569a4ad1bb3ebc112d6b48a3" + ], + "validFrom": "0" + }, + { + "account": "0x00000000000000000000000000000000000000e5", + "amount": "700000000000000000000", + "proof": [ + "0x9f54213bf196538a6687f572962aa2aa28296481bf6fabbabe38a17d939da3ee", + "0x43a04549f5b08d1bf3be7517ba4fc5b738ff082cfaa209795ac5315c044002c8", + "0xea2422f772538edc01973b75e4b4ca33cb5a4263569a4ad1bb3ebc112d6b48a3" + ], + "validFrom": "0" + }, + { + "account": "0x00000000000000000000000000000000000000f6", + "amount": "800000000000000000000", + "proof": [ + "0x109c9584f6e0b7b07c114251e372b2740f1010385fbf4b4dbd9d31bd53f70efd", + "0x9e7a53fb112a76f949cd345775870d18f9091df04b8f6f97ac55fb2405d09213", + "0xcfddf82fde9a123f4a6304e74b0498b0425d4a75cd711a2b8c2cc020ceac4fac" + ], + "validFrom": "0" + } + ], + "root": "0x05a130477c9b4bdbd1f927669bf6d5fe86b7be70e9b512304835ec3588929ebf" +} \ No newline at end of file