4/5
## Preparing Your Foundry and Anvil Environment Before diving into digital signatures for smart contract interactions, specifically for a Merkle Airdrop, it's crucial to establish a correctly configured local development environment. This lesson utilizes Foundry and its local testnet node, Anvil. First, ensure you are using the standard "vanilla" version of Foundry. If you've been working with specialized versions (e.g., ZK-specific forks), revert to the standard nightly or specified version using `foundryup`: ```bash foundryup ``` This command updates your Foundry toolchain, ensuring compatibility with the demonstrated commands and contract compilation. Next, you'll need a local Ethereum node. Anvil, part of the Foundry suite, provides an excellent solution for this. Start Anvil by executing: ```bash anvil ``` Upon starting, Anvil will typically listen on `http://localhost:8545`. The terminal output will display a list of default Ethereum accounts, along with their corresponding private keys. These are invaluable for local testing and development, as they come pre-funded with test ETH. ## Deploying Your Airdrop Smart Contracts With Anvil running, the next step is to deploy the necessary smart contracts. This lesson focuses on a `MerkleAirdrop` contract, which interacts with a `BeagleToken` (an ERC20 token). The deployment process is streamlined using a `Makefile`. To deploy the contracts, execute the `make deploy` command: ```bash make deploy ``` This command typically invokes a Forge script, such as `script/DeployMerkleAirdrop.s.sol`, to handle the deployment logic. The `Makefile` entry for this might look like: ```makefile deploy: @forge script script/DeployMerkleAirdrop.s.sol:DeployMerkleAirdrop $(NETWORK_ARGS) ``` Here, `NETWORK_ARGS` would contain parameters like the RPC URL (pointing to your local Anvil instance) and the private key of the deploying account. Upon successful execution, the deployment script will output the addresses of the newly deployed contracts. This output is critical for subsequent interactions: ``` == Return == 0: contract MerkleAirdrop 0x71725E7734CE288F83E7E1B143E90b3F0512 1: contract BagelToken 0xF0d82315b78aFECb367f032F642F641B00aa3 ``` Take note of these addresses, particularly the `MerkleAirdrop` contract address, as you'll need it for generating and verifying signatures. ## Crafting the Data: Generating the Message Hash A digital signature is created for a specific piece of data. To ensure security and efficiency, we don't sign the raw data directly but rather its cryptographic hash. The `MerkleAirdrop.sol` contract includes a helper function, `getMessageHash`, designed for this purpose, often adhering to the EIP-712 standard for typed data hashing. The `getMessageHash` function in `MerkleAirdrop.sol` typically looks like this: ```solidity // src/MerkleAirdrop.sol function getMessageHash(address account, uint256 amount) public view returns (bytes32) { return _hashTypedDataV4( keccak256(abi.encode(MESSAGE_TYPEHASH, AirdropClaim({account: account, amount: amount}))) ); } ``` This function constructs a unique, fixed-size hash based on the `account` eligible for the airdrop and the `amount` of tokens they can claim. The `_hashTypedDataV4` function implies an EIP-712 compliant structure, which provides context and domain separation for signatures, preventing replay attacks across different contracts or applications. To obtain this message hash, you can use Foundry's `cast call` command to invoke `getMessageHash` on your deployed `MerkleAirdrop` contract. The command requires: 1. The `MerkleAirdrop` contract address (from the deployment step). 2. The function signature: `"getMessageHash(address,uint256)"`. 3. The arguments for the function: the claimant's address and the claimable amount (in wei). 4. The RPC URL of your Anvil node. For example, to get the message hash for `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` claiming `25` tokens (assuming 18 decimals, so `25000000000000000000` wei): ```bash cast call 0xe7f1725E7734CE288F83E7E1B143E90b3F0512 "getMessageHash(address,uint256)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 25000000000000000000 --rpc-url http://localhost:8545 ``` This command will return the `bytes32` message hash, for instance: `0x184e30c19f5e304a09352421dc58346dad61e12f9155b910e73fd856dc72` This hash is the precise data that needs to be signed. ## Signing the Message Hash: Authorizing the Claim Once you have the message hash, the next step is to sign it using a private key. This signature serves as cryptographic proof that the owner of the private key authorizes the action associated with the message hash (in this case, claiming tokens). Foundry's `cast wallet sign` command facilitates this. The command requires the following: 1. The message hash obtained in the previous step. 2. The `--private-key` flag followed by the private key of the signing account. For this Merkle Airdrop scenario, this would be the private key of an account authorized to approve claims (e.g., an admin or the deployer, or for testing, one of Anvil's default accounts). 3. The `--no-hash` flag: This is **critically important**. Since the `getMessageHash` function already computed the cryptographic hash of the typed data, `cast wallet sign` must be instructed *not* to hash its input again. If `--no-hash` is omitted, `cast wallet sign` would hash the already-hashed input, leading to an incorrect signature that the smart contract will reject. Using the example message hash from before and the first default Anvil private key (e.g., `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`, which Anvil prints on startup): ```bash cast wallet sign --no-hash 0x184e30c19f5e304a09352421dc58346dad61e12f9155b910e73fd856dc72 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 ``` This command will output the digital signature as a hexadecimal string, for example: `0xfbd277f062f5b1f52b40dfce9de460171bb0c4238b5c4d75b0d384ed3b6c46ceaeaa570afeecb671d4c11c` It's worth noting that if you were operating on a testnet or mainnet and your private key was managed in an encrypted keystore file, you would use the `--account <ACCOUNT_ALIAS_OR_ADDRESS>` flag instead of `--private-key`. `cast` would then prompt for your keystore password. ## Deconstructing the Signature: Understanding v, r, and s The signature generated by `cast wallet sign` is a single, concatenated hexadecimal string (typically 65 bytes long). However, many Ethereum smart contract functions, particularly those involving signature verification like OpenZeppelin's `ECDSA.recover`, expect the signature to be broken down into its three core components: `v`, `r`, and `s`. * `r`: The first 32 bytes of the signature. * `s`: The next 32 bytes of the signature. * `v`: The final 1 byte of the signature (the recovery identifier). The `claim` function within the `MerkleAirdrop.sol` contract is likely structured to accept these components individually: ```solidity // src/MerkleAirdrop.sol function claim( address account, // The beneficiary of the claim uint256 amount, bytes32[] calldata merkleProof, // Merkle proof for this specific airdrop scenario uint8 v, // Signature component v bytes32 r, // Signature component r bytes32 s // Signature component s ) external { // Internal logic to verify the signature and Merkle proof before transferring tokens // bytes32 messageDigest = getMessageHash(account, amount); // if (!isValidSignature(SIGNER_ADDRESS, messageDigest, v, r, s)) { // SIGNER_ADDRESS is the address of the key that signed // revert MerkleAirdrop_InvalidSignature(); // } // ... } ``` The `isValidSignature` helper function, or similar logic within `claim`, would use these components to recover the signer's address and verify it against an authorized address: ```solidity // src/MerkleAirdrop.sol import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // ... contract MerkleAirdrop ... function isValidSignature(address expectedSigner, bytes32 digest, uint8 v, bytes32 r, bytes32 s) internal view returns (bool) { address actualSigner = ECDSA.tryRecover(digest, v, r, s); return actualSigner != address(0) && actualSigner == expectedSigner; } ``` Therefore, the concatenated signature output from `cast wallet sign` needs to be manually or programmatically split. If your signature string is `0x[r_hex][s_hex][v_hex]`, `r` is `0x[r_hex]`, `s` is `0x[s_hex]`, and `v` is `0x[v_hex]`. Remember to handle the `0x` prefix appropriately when converting these parts for smart contract input. Alternatively, some smart contract designs simplify off-chain handling by accepting the entire `bytes memory signature` and performing the splitting on-chain. OpenZeppelin's `ECDSA.sol` library offers a version of `tryRecover` that does exactly this using inline assembly: ```solidity // From openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address, RecoverError, bytes32) { if (signature.length == 65) { // Standard length for r, s, v bytes32 r; bytes32 s; uint8 v; // Solidity assembly to extract r, s, and v from the concatenated signature /// @solidity memory-safe-assembly assembly { r := mload(add(signature, 0x20)) // Load first 32 bytes (r) s := mload(add(signature, 0x40)) // Load next 32 bytes (s) v := byte(0, mload(add(signature, 0x60))) // Load last byte (v) } return tryRecover(hash, v, r, s); // Call the version with split v, r, s } else { return (address(0), RecoverError.InvalidSignatureLength, bytes32(signature.length)); } } ``` If your contract uses this version of `tryRecover`, you can pass the signature string directly from `cast wallet sign` (after appropriate `bytes` conversion if needed by your scripting layer). However, for this lesson, we assume the contract expects `v, r, s` separately. ## Utilizing the Signature in a Smart Contract Call The final step in this process is to use the generated signature (split into `v`, `r`, and `s`) to make an authorized call to the `MerkleAirdrop` contract's `claim` function. This is typically done via a script, such as a Forge script (`.s.sol` file), or through a frontend application interacting with a user's wallet. Consider an `interact.s.sol` script designed to execute the claim: ```solidity // script/interact.s.sol import "forge-std/Script.sol"; import "../src/MerkleAirdrop.sol"; // Assuming MerkleAirdrop.sol is in src contract ClaimAirdrop is Script { // These values would be populated, perhaps from environment variables or a config file address MERKLE_AIRDROP_CONTRACT = 0x71725E7734CE288F83E7E1B143E90b3F0512; // Example address address CLAIMING_ADDRESS = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; uint256 CLAIMING_AMOUNT = 25 * 1e18; // 25 tokens bytes32[] proof; // Merkle proof elements would be populated here // Signature components - these would be the values derived from cast wallet sign // Example: // uint8 v = 28; // Or 0x1c // bytes32 r = 0xfbd277f062f5b1f52b40dfce9de460171bb0c4238b5c4d75b0d384ed3b6c46ce; // bytes32 s = 0xaeaa570afeecb671d4c11c...; // The rest of s function run(uint8 v_sig, bytes32 r_sig, bytes32 s_sig) external { // Populate proof array if necessary // Example proof: // proof = new bytes32[](2); // proof[0] = <proof_from_input_json_or_elsewhere>; // proof[1] = <proof_from_input_json_or_elsewhere>; vm.startBroadcast(); MerkleAirdrop(MERKLE_AIRDROP_CONTRACT).claim( CLAIMING_ADDRESS, CLAIMING_AMOUNT, proof, // Pass the Merkle proof v_sig, // Pass the 'v' component of the signature r_sig, // Pass the 'r' component of the signature s_sig // Pass the 's' component of the signature ); vm.stopBroadcast(); } } ``` To execute this script, you would populate `v_sig`, `r_sig`, and `s_sig` (and the `proof` array) with the actual values derived from your signature generation process and Merkle tree construction, then run it using `forge script`. ## Core Principles: Digital Signatures in Web3 This lesson demonstrates a practical application of digital signatures in the Web3 space, highlighting several core principles: * **Digital Signatures for Authorization:** Signatures provide a tamper-proof way to verify that an action was authorized by the holder of a specific private key, without exposing the key itself. * **Message Hashing (EIP-712):** Hashing data before signing is crucial for efficiency and security. EIP-712 provides a standard for hashing structured typed data, making signatures more user-friendly and less prone to phishing by clearly defining what is being signed. * **ECDSA Signature Components (v, r, s):** Elliptic Curve Digital Signature Algorithm (ECDSA) signatures consist of `r` and `s` values, with `v` as a recovery identifier. Understanding these components is essential for interacting with smart contracts that require them. * **Foundry Tooling (`anvil`, `cast`, `forge script`):** Foundry offers a comprehensive suite for Ethereum development, enabling local testing, contract interaction via CLI (`cast`), and scripted interactions (`forge script`). * **The `--no-hash` Imperative:** When using tools like `cast wallet sign` with a message that has *already been hashed* (e.g., by an EIP-712 compliant function like `getMessageHash`), the `--no-hash` flag is mandatory to prevent double-hashing and ensure signature validity. * **Signature Splitting and Handling:** Signatures are often generated as a single byte string but may need to be split into `v, r, s` components for smart contract consumption. This can be handled off-chain before calling the contract or, in some cases, on-chain within the contract itself. By mastering these steps and concepts, developers can implement robust and secure off-chain authorization mechanisms for their smart contract interactions.
A step-by-step walkthrough to Foundry & EIP-712: Signing for Secure Airdrops - Configure your Foundry and Anvil environment, create EIP-712 message hashes for Merkle Airdrop claims, and correctly sign them using `cast wallet sign --no-hash`. You'll understand signature components (v, r, s) and use them to authorize contract interactions.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025