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);
     
…