Skip to content

Instantly share code, notes, and snippets.

@galekseev
Last active November 23, 2025 19:58
Show Gist options
  • Select an option

  • Save galekseev/e32686e709eecd6bfb205287a4b50811 to your computer and use it in GitHub Desktop.

Select an option

Save galekseev/e32686e709eecd6bfb205287a4b50811 to your computer and use it in GitHub Desktop.
hardhat v3 tests Invariant violation
import { defineConfig } from "hardhat/config";
import hardhatEthers from "@nomicfoundation/hardhat-ethers";
import hardhatToolboxMochaEthers from "@nomicfoundation/hardhat-toolbox-mocha-ethers";
import hardhatEthersChaiMatchers from "@nomicfoundation/hardhat-ethers-chai-matchers";
import hardhatNetworkHelpers from "@nomicfoundation/hardhat-network-helpers";
export default defineConfig({
plugins: [
hardhatToolboxMochaEthers,
hardhatEthersChaiMatchers,
hardhatNetworkHelpers,
hardhatEthers,
],
paths: {
sources: "./contracts",
tests: "./test/contracts",
cache: "./cache",
artifacts: "./artifacts",
},
solidity: {
version: "0.8.23",
settings: {
optimizer: {
enabled: true,
runs: 1000000,
},
// evmVersion: (networks[getNetwork()] as { hardfork?: string })?.hardfork || 'shanghai',
},
},
});
import { expect } from 'chai';
import { network } from 'hardhat';
import keccak256 from 'keccak256';
import { personalSign } from '@metamask/eth-sig-util';
import type { Signer, Contract } from 'ethers';
import { MerkleTree } from 'merkletreejs';
const { ethers, networkHelpers } = await network.connect();
const { loadFixture } = networkHelpers;
interface AccountWithDropValue {
account: Signer;
amount: number;
}
function keccak128 (input: Buffer | string): Buffer {
return keccak256(input).slice(0, 16);
}
describe('Hardhat3 test', function () {
async function deployContractsFixture () {
const [owner, alice, bob, carol, dan] = await ethers.getSigners();
const token = await ethers.deployContract('TokenMock', ['1INCH Token', '1INCH']) as unknown as Contract;
await Promise.all([alice, bob, carol, dan].map(w => token.mint(w, 1n)));
const accountWithDropValues: AccountWithDropValue[] = [
{ account: owner, amount: 1, },
{ account: alice, amount: 1, },
];
const elements = await Promise.all(accountWithDropValues.map(async (w) => {
const address = await w.account.getAddress();
return '0x' + address.slice(2) + BigInt(w.amount).toString(16).padStart(64, '0');
}));
const hashedElements = elements.map((elem) => MerkleTree.bufferToHex(keccak128(elem)));
const tree = new MerkleTree(elements, keccak128, { hashLeaves: true, sort: true });
const root = tree.getHexRoot();
const leaves = tree.getHexLeaves();
const proofs = leaves
.map(tree.getHexProof, tree)
.map(proof => '0x' + proof.map(p => p.slice(2)).join(''));
const SignatureMerkleDrop128Factory = await ethers.getContractFactory('SignatureMerkleDrop128');
const drop = await SignatureMerkleDrop128Factory.deploy(await token.getAddress(), root, tree.getDepth());
await token.mint(await drop.getAddress(), accountWithDropValues.map(w => w.amount).reduce((a, b) => a + b, 0));
const data = MerkleTree.bufferToHex(keccak256(await alice.getAddress()));
const signature = personalSign({
privateKey: Buffer.from('ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', 'hex'),
data
});
return {
accounts: { owner, alice },
contracts: { token, drop },
others: { hashedElements, leaves, proofs, signature },
};
}
it('Should transfer money to another wallet with extra value', async function () {
const { accounts: { alice }, contracts: { drop }, others: { hashedElements, leaves, proofs, signature } } = await loadFixture(deployContractsFixture);
const txn = await drop.claim(alice, 1, proofs[leaves.indexOf(hashedElements[0])], signature, { value: 10 });
expect(txn).to.changeEtherBalance(ethers, alice, 10);
});
it('Should disallow invalid proof', async function () {
const { accounts: { alice }, contracts: { drop }, others: { signature } } = await loadFixture(deployContractsFixture);
await expect(
drop.claim(alice, 1, '0x', signature),
).to.be.revertedWithCustomError(drop, 'InvalidProof');
});
});
/*
Running Mocha tests
Hardhat3 test
✔ Should transfer money to another wallet with extra value (69ms)
✔ Should disallow invalid proof
2 passing (85ms)
Unhandled promise rejection:
HardhatError: HHE100: An internal invariant was violated: The block doesn't exist
at assertHardhatInvariant (/Users/glebalekseev/Documents/git/merkle-distribution/node_modules/@nomicfoundation/hardhat-errors/src/errors.ts:237:11)
at getBalanceChange (/Users/glebalekseev/Documents/git/merkle-distribution/node_modules/@nomicfoundation/hardhat-ethers-chai-matchers/src/internal/matchers/changeEtherBalance.ts:100:3)
at async Promise.all (index 0)
error Command failed with exit code 1.
*/
// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { Address } from "@openzeppelin/contracts/utils/Address.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { SafeERC20, IERC20 } from "@1inch/solidity-utils/contracts/libraries/SafeERC20.sol";
import { ECDSA } from "@1inch/solidity-utils/contracts/libraries/ECDSA.sol";
import { ISignatureMerkleDrop128 } from "./interfaces/ISignatureMerkleDrop128.sol";
/**
* @title SignatureMerkleDrop128
* @author 1inch Network
* @notice A gas-optimized contract for distributing tokens via 128-bit Merkle tree proofs with signature verification
* @dev This contract uses 128-bit (16 bytes) Merkle tree nodes for gas optimization and requires
* signature verification for claims. Each claim can only be made once, tracked via a bitmap for gas efficiency.
*/
contract SignatureMerkleDrop128 is ISignatureMerkleDrop128, Ownable {
using Address for address payable;
using SafeERC20 for IERC20;
/* solhint-disable immutable-vars-naming */
/// @notice The ERC20 token being distributed
address public immutable override token;
/// @notice The 128-bit Merkle root for the distribution
bytes16 public immutable override merkleRoot;
/// @notice The depth of the Merkle tree
uint256 public immutable override depth;
/* solhint-enable immutable-vars-naming */
/// @notice Bitmap tracking claimed indices (packed for gas efficiency)
// This is a packed array of booleans.
mapping(uint256 => uint256) private _claimedBitMap;
/// @notice Estimated gas cost for claim operation used in cashback calculation
uint256 private constant _CLAIM_GAS_COST = 60000;
/**
* @notice Allows contract to receive ETH for gas cashback functionality
*/
receive() external payable {} // solhint-disable-line no-empty-blocks
/**
* @notice Constructs the SignatureMerkleDrop128 contract
* @param token_ The address of the ERC20 token to be distributed
* @param merkleRoot_ The 128-bit Merkle root of the distribution
* @param depth_ The depth of the Merkle tree
*/
constructor(address token_, bytes16 merkleRoot_, uint256 depth_) Ownable(msg.sender) {
token = token_;
merkleRoot = merkleRoot_;
depth = depth_;
}
/**
* @notice Claims tokens for a receiver using a Merkle proof and signature
* @dev The signature must be from the account that is part of the Merkle tree.
* Includes gas cashback functionality if ETH is sent with the transaction.
* @param receiver The address that will receive the tokens
* @param amount The amount of tokens to claim
* @param merkleProof The Merkle proof verifying the claim (must be a multiple of 16 bytes)
* @param signature The signature from the account authorized in the Merkle tree
*/
function claim(address receiver, uint256 amount, bytes calldata merkleProof, bytes calldata signature) external payable {
bytes32 signedHash = ECDSA.toEthSignedMessageHash(keccak256(abi.encodePacked(receiver)));
address account = ECDSA.recover(signedHash, signature);
// Verify the merkle proof.
bytes16 node = bytes16(keccak256(abi.encodePacked(account, amount)));
(bool valid, uint256 index) = _verifyAsm(merkleProof, merkleRoot, node);
if (!valid) revert InvalidProof();
_invalidate(index);
IERC20(token).safeTransfer(receiver, amount);
if (msg.value > 0) {
payable(receiver).sendValue(msg.value);
}
_cashback();
}
/**
* @notice Verifies a Merkle proof against a specified root
* @param proof The Merkle proof to verify (must be a multiple of 16 bytes)
* @param root The 128-bit Merkle root to verify against
* @param leaf The 128-bit leaf node to verify
* @return valid True if the proof is valid, false otherwise
* @return index The index of the leaf in the Merkle tree
*/
function verify(bytes calldata proof, bytes16 root, bytes16 leaf) external view returns (bool valid, uint256 index) {
return _verifyAsm(proof, root, leaf);
}
/**
* @notice Verifies a Merkle proof against the contract's merkleRoot
* @param proof The Merkle proof to verify (must be a multiple of 16 bytes)
* @param leaf The 128-bit leaf node to verify
* @return valid True if the proof is valid, false otherwise
* @return index The index of the leaf in the Merkle tree
*/
function verify(bytes calldata proof, bytes16 leaf) external view returns (bool valid, uint256 index) {
return _verifyAsm(proof, merkleRoot, leaf);
}
/**
* @notice Checks if a claim at a specific index has already been made
* @param index The index in the Merkle tree to check
* @return True if the claim has been made, false otherwise
*/
function isClaimed(uint256 index) external view returns (bool) {
uint256 claimedWordIndex = index / 256;
uint256 claimedBitIndex = index % 256;
uint256 claimedWord = _claimedBitMap[claimedWordIndex];
uint256 mask = (1 << claimedBitIndex);
return claimedWord & mask == mask;
}
/**
* @notice Provides gas cashback to the transaction originator
* @dev Sends ETH back to tx.origin to compensate for gas costs, capped at basefee * _CLAIM_GAS_COST
*/
function _cashback() private {
uint256 balance = address(this).balance;
if (balance > 0) {
// solhint-disable-next-line avoid-tx-origin
payable(tx.origin).sendValue(Math.min(block.basefee * _CLAIM_GAS_COST, balance));
}
}
/**
* @notice Marks a claim index as used in the bitmap
* @dev Reverts if the index has already been claimed
* @param index The index to mark as claimed
*/
function _invalidate(uint256 index) private {
uint256 claimedWordIndex = index >> 8;
uint256 claimedBitIndex = index & 0xff;
uint256 claimedWord = _claimedBitMap[claimedWordIndex];
uint256 newClaimedWord = claimedWord | (1 << claimedBitIndex);
if (claimedWord == newClaimedWord) revert DropAlreadyClaimed();
_claimedBitMap[claimedWordIndex] = newClaimedWord;
}
/**
* @notice Verifies a 128-bit Merkle proof using assembly for gas optimization
* @dev Uses sorted pairs when hashing and calculates the leaf index during verification
* @param proof The Merkle proof to verify (must be a multiple of 16 bytes)
* @param root The 128-bit Merkle root to verify against
* @param leaf The 128-bit leaf node to verify
* @return valid True if the proof is valid, false otherwise
* @return index The calculated index of the leaf in the Merkle tree
*/
function _verifyAsm(bytes calldata proof, bytes16 root, bytes16 leaf) private view returns (bool valid, uint256 index) {
/// @solidity memory-safe-assembly
assembly { // solhint-disable-line no-inline-assembly
let ptr := proof.offset
let mask := 1
for { let end := add(ptr, proof.length) } lt(ptr, end) { ptr := add(ptr, 0x10) } {
let node := calldataload(ptr)
switch lt(leaf, node)
case 1 {
mstore(0x00, leaf)
mstore(0x10, node)
}
default {
mstore(0x00, node)
mstore(0x10, leaf)
index := or(mask, index)
}
leaf := keccak256(0x00, 0x20)
mask := shl(1, mask)
}
valid := iszero(shr(128, xor(root, leaf)))
}
unchecked {
index <<= depth - proof.length / 16;
}
}
/**
* @notice Allows owner to rescue stuck funds (ETH or ERC20 tokens)
* @dev Only callable by the contract owner
* @param token_ The token address to rescue (use address(0) for ETH)
* @param amount The amount to rescue
*/
function rescueFunds(address token_, uint256 amount) external onlyOwner {
if (token_ == address(0)) {
payable(msg.sender).sendValue(amount);
} else {
IERC20(token_).safeTransfer(msg.sender, amount);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment