Advanced Standards
An Ethereum signature over a 32-byte hash is (r, s, v).
31.1 Signatures, ecrecover and EIP-191
An Ethereum signature over a 32-byte hash is (r, s, v). Plain message signing (personal_sign, EIP-191 version 0x45) hashes "\x19Ethereum Signed Message:\n32" ++ hash.
Raw ecrecover returns address(0) for invalid signatures and accepts malleable signatures — always use OpenZeppelin ECDSA (or check s is in the lower half and the result is non-zero). For smart-contract wallets, verify with ERC-1271 (isValidSignature) — OZ SignatureChecker handles both.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
contract SignatureCheck {
using ECDSA for bytes32;
using MessageHashUtils for bytes32;
function recover(bytes32 dataHash, bytes calldata sig) external pure returns (address) {
return dataHash.toEthSignedMessageHash().recover(sig); // rejects malleable high-s signatures
}
}31.2 EIP-712: typed structured data
EIP-712 lets wallets display human-readable structured data and binds signatures to a domain (name, version, chainId, verifying contract), preventing cross-contract and cross-chain replay.
Signing off-chain with ethers v6: await wallet.signTypedData(domain, { Claim: [...] }, value); with viem: walletClient.signTypedData({ domain, types, primaryType: "Claim", message }).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract Voucher is EIP712 {
bytes32 public constant CLAIM_TYPEHASH =
keccak256("Claim(address account,uint256 amount,uint256 nonce,uint256 deadline)");
address public immutable signer;
mapping(address => uint256) public nonces;
mapping(address => uint256) public claimed;
error Expired();
error BadSignature();
constructor(address _signer) EIP712("Voucher", "1") { signer = _signer; }
function claim(uint256 amount, uint256 deadline, bytes calldata sig) external {
if (block.timestamp > deadline) revert Expired();
bytes32 structHash = keccak256(
abi.encode(CLAIM_TYPEHASH, msg.sender, amount, nonces[msg.sender]++, deadline)
);
bytes32 digest = _hashTypedDataV4(structHash); // keccak256("\x19\x01" ++ domainSeparator ++ structHash)
if (ECDSA.recover(digest, sig) != signer) revert BadSignature();
claimed[msg.sender] += amount;
}
}31.3 ERC-2612: permit
permit(owner, spender, value, deadline, v, r, s) sets an ERC-20 allowance using an EIP-712 signature — gasless approvals, and approve + action in one transaction.
Permit2 (Uniswap) generalizes signature approvals to any ERC-20 via a shared contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract PermitToken is ERC20, ERC20Permit {
constructor() ERC20("Permit Token", "PMT") ERC20Permit("Permit Token") {
_mint(msg.sender, 1_000_000e18);
}
}
contract DepositWithPermit {
mapping(address => uint256) public deposits;
function depositWithPermit(address token, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external
{
// try/catch: permit may have been front-run (already used) — still proceed if allowance suffices
try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {} catch {}
IERC20(token).transferFrom(msg.sender, address(this), amount); // use SafeERC20 for arbitrary tokens
deposits[msg.sender] += amount;
}
}31.4 ERC-4626: tokenized vaults
A standard interface for yield-bearing vaults: users deposit an underlying asset and receive shares (themselves ERC-20).
assets managed by the vault deposit(assets, receiver) / mint(shares, receiver)
withdraw(assets, receiver, owner) / redeem(shares, receiver, owner) convertToShares, convertToAssets
previewDeposit/Mint/Withdraw/Redeem, max* simulations and limits
Inflation (donation) attack: the first depositor donates assets to inflate the share price so later depositors' shares round down to zero. Mitigations: virtual shares (OZ), dead shares minted at deployment, minimum deposit.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleVault is ERC4626 {
constructor(IERC20 asset_) ERC20("Vault Share", "vSHR") ERC4626(asset_) {}
// Yield accrues by assets being sent to the vault, raising totalAssets() per share.
// Virtual shares/assets offset (OZ default _decimalsOffset) mitigates the inflation attack.
function _decimalsOffset() internal pure override returns (uint8) { return 3; }
}31.5 ERC-4337: account abstraction
ERC-4337 enables smart-contract wallets without protocol changes:
UserOperation (PackedUserOperation in EntryPoint v0.7+) pseudo-transaction: sender, nonce, callData, gas limits, paymaster data, signature off-chain service collecting UserOps into a normal transaction singleton contract that validates and executes UserOps implements validateUserOp(userOp, userOpHash, missingAccountFunds) optionally sponsors gas (gasless UX) or accepts ERC-20 for gas deploys accounts via CREATE2 on first use (counterfactual address)
Related: EIP-7702 (Pectra) lets EOAs delegate to such account code; modular account standards ERC-7579 and ERC-6900; production accounts include Safe, Kernel, Coinbase Smart Wallet, and Alchemy Light Account.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
// Simplified shape of the v0.7 structures (import the real ones from eth-infinitism/account-abstraction)
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
contract MinimalAccount {
address public immutable owner;
address public immutable entryPoint;
constructor(address _owner, address _entryPoint) { owner = _owner; entryPoint = _entryPoint; }
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external returns (uint256 validationData)
{
require(msg.sender == entryPoint, "not entrypoint");
// real accounts: recover signer of the EIP-191-wrapped userOpHash (or EIP-712 in v0.8) and compare
validationData = _isValid(userOpHash, userOp.signature) ? 0 : 1; // 1 = SIG_VALIDATION_FAILED
if (missingAccountFunds > 0) {
(bool ok, ) = payable(msg.sender).call{value: missingAccountFunds}("");
(ok);
…