Build gasless, secure token transfers with typed signatures

Author: Zodomo X Warpcast GitHub Blog

I needed to utilize permit2’s witness functionality in Omni SolverNet to add gasless orders. Some of the data structures I had to use were extremely complex. When researching EIP-712 and permit2, I found that documentation for both was rather lacking. EIP-712 is easier to use on flat structs, and many projects use it on such. However, ERC-7683’s ResolvedCrossChainOrder struct (used in SolverNet) is quite complex, has nested struct arrays, and dynamic values such as byte arrays.

I could find no good examples for how to properly build out my typehashes, let alone how to properly sign the orders in Solidity within Foundry tests. However, with the release of Claude 4, I was able to have it comprehensively teach me everything I needed to know about EIP-712 and permit2, especially its witness logic.

So, I decided to take what I learned and produce a document to help guide new devs through fully learning how to use these tools, while understanding their importance. As I pretty much solely work in Foundry, I also walk through signing these structs in Solidity, in order to assist with testing such integrations. Throughout this document, I introduce these concepts through the scope of building an onchain bug bounty system.

Other resources are available elsewhere to showcase how to produce these signatures offchain with software such as ethers, viem, or wagmi.

Understanding EIP-712: Making Signatures Human-Readable

The Problem: Users Signing Blind

Imagine you’re building a decentralized bug bounty platform where security researchers get paid in tokens for finding vulnerabilities. Before EIP-712, if a project team wanted to approve a bounty, they’d see something like this in their wallet:

Sign this message: 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8

That’s completely meaningless to humans. The team has no idea if they’re signing a claim for a legitimate $1,000 bounty or accidentally signing away their entire wallet. This experience creates massive security risks and terrible user experiences.

EIP-712: The Solution

EIP-712 (Ethereum Improvement Proposal 712) solves this by letting users sign structured, meaningful data that wallets can display clearly. Instead of mysterious hex, project teams see exactly what they’re signing:

{
  "Bounty Payment": {
    "recipient": "0x742d35cc6634c0532925a3b8d428c1e21c2a2e59",
    "amount": "10000 USDC", 
    "bugId": "SOL-2025-003",
    "severity": "Critical",
    "deadline": "2025-01-15 14:30:00 UTC"
  }
}

Bringing clarity to the information being signed makes crypto more secure and user-friendly.

How EIP-712 Works Under the Hood

When you sign structured data with EIP-712, the process combines three essential components into a final hash that gets signed. Think of it like creating a tamper-proof package with multiple security seals:

Final Hash = keccak256("\x19\x01" ‖ Domain Separator ‖ Struct Hash)

The \x19\x01 prefix is a magic value that prevents this signature from ever being confused with a regular Ethereum transaction. Let’s break down the other components:

1. Domain Separator: Your App’s Unique Fingerprint

The domain separator is like a unique seal that ensures signatures can only be used in your specific application. It prevents replay attacks across different protocols. Here’s what it contains:

struct EIP712Domain {
    string name;               // "HackerBounty"
    string version;            // "1" 
    uint256 chainId;           // 1 (Ethereum mainnet)
    address verifyingContract; // 0x1234...abcd
}

Each field serves a critical purpose:

  • name: Your protocol’s name.
  • version: Allows you to upgrade your protocol while invalidating old signatures
  • chainId: Prevents signatures from Ethereum being used on Arbitrum or other chains
  • verifyingContract: Ties signatures to your specific deployed contract address

The domain separator is computed once and can be stored:

bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256(bytes("HackerBounty")),
    keccak256(bytes("1")),
    block.chainid,
    address(this)
));

This unique set of variables ensures that every app can avoid signature reuse between other applications, or even incompatible versions of itself.

2. Type Hash: The Structure Definition

Every data structure you want to sign needs a type hash. Think of it as a schema definition that ensures both the signer and verifier agree on exactly what fields exist and their types. For our bug bounty payment:

struct BountyPayment {
    address recipient;
    uint256 amount;
    string bugId;
    uint8 severity;
    uint256 deadline;
}

// The type hash is the keccak256 of the struct definition string
bytes32 PAYMENT_TYPEHASH = keccak256(
    "BountyPayment(address recipient,uint256 amount,string bugId,uint8 severity,uint256 deadline)"
);

This type hash becomes part of the struct hash (explained next), ensuring that even if an attacker tries to add or remove fields, the signature becomes invalid.

3. Struct Hash: Encoding Your Actual Data

This is where your actual bounty payment data gets encoded and hashed. The encoding follows specific rules based on the data types:

function hashPayment(BountyPayment memory payment) pure returns (bytes32) {
    return keccak256(abi.encode(
        PAYMENT_TYPEHASH,                // Always first - identifies the struct type
        payment.recipient,               // address: encoded as-is (static type)
        payment.amount,                  // uint256: encoded as-is (static type)
        keccak256(bytes(payment.bugId)), // string: must be hashed first (dynamic type)
        payment.severity,                // uint8: encoded as-is (static type)
        payment.deadline                 // uint256: encoded as-is (static type)
    ));
}

Critical Encoding Rules:

  • Static types (address, uint, bool, bytes1-bytes32) are encoded directly with abi.encode
  • Dynamic types (string, bytes, arrays) must be hashed with keccak256 first
  • The type hash always comes first in the encoding
  • Field order must match the type definition exactly

Putting It All Together

Here’s the complete flow for creating an EIP-712 signature:

// 1. Define your struct and create an instance
BountyPayment memory payment = BountyPayment({
    recipient: 0x742d35cc6634c0532925a3b8d428c1e21c2a2e59,
    amount: 10000 * 10**6, // 10000 USDC (6 decimals)
    bugId: "SOL-2025-003",
    severity: 4,           // Critical
    deadline: block.timestamp + 24 hours
});

// 2. Hash the struct
bytes32 structHash = hashPayment(payment);

// 3. Create the final digest
bytes32 digest = keccak256(abi.encodePacked(
    "\x19\x01",
    DOMAIN_SEPARATOR,
    structHash
));

// 4. Sign the digest (in practice, this happens in the project team's wallet, but this is how foundry does it)
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);

Verifying EIP-712 Signatures in Your Contract

Once a project team submits their signed payment authorization, your contract needs to verify it:

contract HackerBounty {
    bytes32 public constant DOMAIN_SEPARATOR = /* computed at deployment */;
    
    mapping(address => uint256) public nonces;
    mapping(string => bool) public processedBugs;
    mapping(address => bool) public authorizedProjects;
    
    function processBountyPayment(
        BountyPayment calldata payment,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        // Prevent double-processing
        require(!processedBugs[payment.bugId], "Bug already processed");
        require(payment.deadline >= block.timestamp, "Payment expired");
        
        // Recreate the digest
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            hashPayment(payment)
        ));
        
        // Recover the signer (should be the project team)
        address projectTeam = ecrecover(digest, v, r, s);
        require(authorizedProjects[projectTeam], "Unauthorized project");
        
        // Mark as processed and pay out
        processedBugs[payment.bugId] = true;
        IERC20(USDC).transferFrom(projectTeam, payment.recipient, payment.amount);
        
        emit BountyPaid(payment.recipient, payment.bugId, payment.amount);
    }
}

Advanced EIP-712: Nested Structs

As your bug bounty platform grows, you might need more complex data structures. EIP-712 supports nested structs, but they require special handling:

struct PaymentDetails {
    string bugId;
    uint8 severity;
    string description;
}

struct AdvancedPayment {
    address recipient;
    uint256 amount;
    PaymentDetails details; // Nested struct
    uint256 deadline;
}

When working with nested structs:

  1. Each nested struct needs its own type hash
  2. Hash nested structs separately, then use their hash in the parent
  3. Type definitions must include all referenced structs
bytes32 constant DETAILS_TYPEHASH = keccak256(
    "PaymentDetails(string bugId,uint8 severity,string description)"
);

bytes32 constant ADVANCED_PAYMENT_TYPEHASH = keccak256(
    "AdvancedPayment(address recipient,uint256 amount,PaymentDetails details,uint256 deadline)PaymentDetails(string bugId,uint8 severity,string description)"
);

function hashAdvancedPayment(AdvancedPayment memory payment) pure returns (bytes32) {
    // First hash the nested struct
    bytes32 detailsHash = keccak256(abi.encode(
        DETAILS_TYPEHASH,
        keccak256(bytes(payment.details.bugId)),      // dynamic type
        payment.details.severity,
        keccak256(bytes(payment.details.description)) // dynamic type
    ));
    
    // Then hash the parent struct using the nested struct's hash
    return keccak256(abi.encode(
        ADVANCED_PAYMENT_TYPEHASH,
        payment.recipient,
        payment.amount,
        detailsHash, // Use the hash, not the struct itself
        payment.deadline
    ));
}

Advanced EIP-712: Array Types

Real-world bug bounty platforms often need to handle multiple actions or data points in a single transaction. Perhaps a project team wants to pay multiple researchers for different vulnerabilities, or your platform needs to process batched bounty payouts. EIP-712 supports arrays, but they follow specific hashing rules that are crucial to understand.

Basic Array Types

Let’s start with simple arrays of basic types:

struct BatchPayout {
    address[] recipients; // Array of researcher addresses
    uint256[] amounts;    // Array of payment amounts  
    string[] bugIds;      // Array of bug IDs
    uint256 deadline;
}

bytes32 constant BATCH_PAYOUT_TYPEHASH = keccak256(
    "BatchPayout(address[] recipients,uint256[] amounts,string[] bugIds,uint256 deadline)"
);

Key rules for basic arrays:

  • Array types are denoted with [] in the type definition
  • Arrays are always treated as dynamic types, even if they contain static elements
  • Arrays must be hashed using keccak256 before encoding in the parent struct
function hashBatchPayout(BatchPayout memory payout) pure returns (bytes32) {
    return keccak256(abi.encode(
        BATCH_PAYOUT_TYPEHASH,
        keccak256(abi.encodePacked(payout.recipients)), // Hash the packed array
        keccak256(abi.encodePacked(payout.amounts)),    // Hash the packed array
        keccak256(abi.encodePacked(                     // String arrays need special handling
            keccak256(bytes(payout.bugIds[0])),         // Hash each string element
            keccak256(bytes(payout.bugIds[1])),         // then pack and hash the result
            // ... for each element
        )),
        payout.deadline
    ));
}

Arrays of Structs: The Complex Case

The most sophisticated scenario involves arrays of custom structs. Imagine your bug bounty platform needs to handle multiple vulnerability payouts in a single transaction:

struct VulnerabilityPayout {
    string bugId;
    uint8 severity;
    string description;
    address recipient;
}

struct MultiPayoutAuthorization {
    address projectTeam;
    VulnerabilityPayout[] payouts; // Array of structs
    uint256 totalAmount;
    uint256 deadline;
}

This requires careful handling of the type definitions and hashing:

// Individual struct type hash
bytes32 constant VULNERABILITY_PAYOUT_TYPEHASH = keccak256(
    "VulnerabilityPayout(string bugId,uint8 severity,string description,address recipient)"
);

// Main struct type hash, all referenced structs are included alphabetically
bytes32 constant MULTI_PAYOUT_TYPEHASH = keccak256(
    "MultiPayoutAuthorization(address projectTeam,VulnerabilityPayout[] payouts,uint256 totalAmount,uint256 deadline)VulnerabilityPayout(string bugId,uint8 severity,string description,address recipient)"
);

The Array Hashing Algorithm

Arrays of structs follow a specific concatenation and hashing pattern defined by EIP-712:

function _hashPayoutArray(VulnerabilityPayout[] memory payouts) internal pure returns (bytes32) {
    // Handle empty arrays
    if (payouts.length == 0) {
        return keccak256("");
    }
    
    // For non-empty arrays: hash each element, concatenate, then hash the result
    bytes memory encodedPayouts = "";
    
    for (uint256 i = 0; i < payouts.length; i++) {
        // Hash each struct individually using its type hash
        bytes32 payoutHash = keccak256(abi.encode(
            VULNERABILITY_PAYOUT_TYPEHASH,
            keccak256(bytes(payouts[i].bugId)),       // Dynamic type: hash first
            payouts[i].severity,                      // Static type: encode directly
            keccak256(bytes(payouts[i].description)), // Dynamic type: hash first
            payouts[i].recipient                      // Static type: encode directly
        ));
        
        // Concatenate this hash to our running bytes
        encodedPayouts = abi.encodePacked(encodedPayouts, payoutHash);
    }
    
    // Hash the concatenated result
    return keccak256(encodedPayouts);
}

function hashMultiPayoutAuthorization(MultiPayoutAuthorization memory auth) pure returns (bytes32) {
    return keccak256(abi.encode(
        MULTI_PAYOUT_TYPEHASH,
        auth.projectTeam,
        _hashPayoutArray(auth.payouts), // Use the hash of the array
        auth.totalAmount,
        auth.deadline
    ));
}

Empty Arrays and Edge Cases

Empty arrays have special handling that’s important to understand:

function demonstrateEmptyArrays() pure returns (bytes32) {
    VulnerabilityPayout[] memory emptyPayouts = new VulnerabilityPayout[](0);
    
    // Empty arrays hash to keccak256("")
    bytes32 emptyHash = _hashPayoutArray(emptyPayouts);
    assert(emptyHash == keccak256(""));
    
    return emptyHash;
}

This ensures that empty arrays have a consistent, deterministic hash while remaining distinct from other empty data.

Practical Example: Multi-Vulnerability Payout Authorization

Here’s how a complete multi-vulnerability payout authorization would work:

function authorizeMultiplePayouts() external {
    // Create multiple vulnerability payouts
    VulnerabilityPayout[] memory payouts = new VulnerabilityPayout[](2);
    
    payouts[0] = VulnerabilityPayout({
        bugId: "RE-2025-003",
        severity: 4, // Critical
        description: "Reentrancy can drain the contract in one transaction",
        recipient: 0x1234567890123456789012345678901234567890 // Researcher A
    });
    
    payouts[1] = VulnerabilityPayout({
        bugId: "DEP-2025-002", 
        severity: 2, // Medium
        description: "Bad order construction can lead to user deposits not being refunded",
        recipient: 0xABCDEF1234567890123456789012345678901234 // Researcher B
    });
    
    MultiPayoutAuthorization memory authorization = MultiPayoutAuthorization({
        projectTeam: msg.sender,
        payouts: payouts,
        totalAmount: 5000 * 10**6, // 5000 USDC total
        deadline: block.timestamp + 7 days
    });
    
    // The hash includes all payout data in a tamper-proof way
    bytes32 authHash = hashMultiPayoutAuthorization(authorization);
    
    // Project team signs this hash, knowing exactly what researchers they're paying
    // Wallet will display the structured data clearly

    // {Payment splitting and processing logic}
}

Critical Rules for Array Hashing

  1. Empty Arrays: Always hash to keccak256("")
  2. Non-Empty Static Type Arrays: Hash the entire abi.encodePacked array
  3. Non-Empty Dynamic Type Arrays: Hash each element individually, concatenate with abi.encodePacked, then hash the result
  4. Dynamic Types in Arrays: String and bytes fields within array elements must be hashed before encoding
  5. Order Matters: Array element order affects the final hash
  6. Type Definitions: Include all referenced struct types in alphabetical order

Array Type String Construction

When arrays are involved, your type strings become more complex but follow predictable patterns:

// For arrays of structs, all referenced types must be included
string constant MULTI_PAYOUT_TYPE_STRING = 
    "MultiPayoutAuthorization(address projectTeam,VulnerabilityPayout[] payouts,uint256 totalAmount,uint256 deadline)VulnerabilityPayout(string bugId,uint8 severity,string description,address recipient)";

// For simple arrays
string constant BATCH_PAYOUT_TYPE_STRING = 
    "BatchPayout(address[] recipients,uint256[] amounts,string[] bugIds,uint256 deadline)";

Common Array Pitfalls

  1. Forgetting Empty Array Handling: Always check for empty arrays and return keccak256("")
  2. Wrong Concatenation Method: Use abi.encodePacked for concatenating hashes, not abi.encode
  3. Type String Mismatches: Array notation in type strings must exactly match struct definitions
  4. Dynamic Type Confusion: Remember that arrays themselves are always dynamic, even arrays of static types
  5. Order Dependencies: Changing array element order changes the hash - ensure consistent ordering

Understanding array hashing is crucial for building sophisticated EIP-712 systems. The patterns you’ve learned here will handle the vast majority of real-world scenarios, from simple batch operations to complex multi-struct arrays.

General EIP-712 Pitfalls and Best Practices

  1. Dynamic Type Encoding: Always hash strings, bytes, and arrays before encoding
  2. Field Order: Must match exactly between type definition and encoding
  3. Type String Format: No spaces except after commas, exact type names
  4. Nested Struct Definitions: Include all referenced structs in alphabetical order
  5. Domain Separator Caching: Can be computed once at deployment if chainId won’t change

Now that you understand EIP-712’s structured signatures, let’s see how Permit2 builds on this foundation to create a universal token approval system in the next section.

Permit2: The Ultimate Token Permission System

The Traditional Approval Problem

Before diving into permit2’s solutions, let’s understand the fundamental problem it solves. In traditional ERC-20 token systems, if your bug bounty platform wants to process payments from project teams to security researchers, you face a cumbersome two-step process:

// Step 1: Project team approves your contract (requires gas)
IERC20(token).approve(bugBountyContract, amount);

// Step 2: Your contract transfers tokens to researchers (requires more gas)  
IERC20(token).transferFrom(projectTeam, researcher, amount);

This creates several problems for your bug bounty platform:

  1. Poor User Experience: Project teams must send two separate transactions per payout
  2. Gas Inefficiency: Each approval costs gas, even if never used
  3. Security Risks: Unlimited approvals create attack vectors
  4. No Expiration: Approvals persist indefinitely
  5. Inflexibility: Can’t bundle approvals with complex logic

What is Permit2?

Permit2 is Uniswap’s battle-tested signature-based token permission system deployed at 0x000000000022D473030F116dDEE9F6B43aC78BA3 on all major networks. It solves the approval problem by introducing:

  • Signature-based permissions instead of on-chain approvals
  • Time-bound and amount-limited token access
  • Batch operations for multiple tokens
  • Gasless approvals that don’t require upfront transactions
  • Witness data for custom protocol logic (covered in Section 3)

Your bug bounty platform benefits in multiple ways:

  • Project teams sign once instead of sending multiple transactions for each researcher payout
  • You can bundle token transfers with vulnerability verification logic
  • Time-limited permissions reduce long-term security risks
  • Batch operations enable complex multi-researcher bounty payouts

Permit2’s Dual Architecture

Permit2 provides two complementary systems, each optimized for different use cases:

interface IPermit2 is ISignatureTransfer, IAllowanceTransfer {
    // Unified interface providing maximum flexibility
}

System Comparison at a Glance

Feature AllowanceTransfer SignatureTransfer
Use Case Traditional approvals with signatures One-time direct transfers
Setup Sign once, transfer many times Sign per transfer
Nonce Management Ordered (sequential) Unordered (bitmap-based)
Best For Recurring payments, subscriptions Order fulfillment, atomic swaps
Gas Efficiency Optimal for multiple transfers Optimal for single transfers

AllowanceTransfer: Enhanced Traditional Approvals

AllowanceTransfer modernizes the traditional approval pattern with signature-based permissions. Think of it as “allowances with expiration dates and gasless setup.”

Core Data Structures

struct PermitDetails {
    address token;     // The ERC20 token address
    uint160 amount;    // Maximum spendable amount
    uint48 expiration; // Unix timestamp when permission expires
    uint48 nonce;      // Ordered nonce for replay protection
}

struct PermitSingle {
    PermitDetails details; // The permission details
    address spender;       // Who can spend the tokens
    uint256 sigDeadline;   // Signature validity deadline
}

struct PackedAllowance {
    uint160 amount;    // Current remaining allowance
    uint48 expiration; // When this allowance expires
    uint48 nonce;      // Current nonce for this owner/token/spender
}

How AllowanceTransfer Works

The AllowanceTransfer system maintains a three-dimensional mapping that tracks permissions:

mapping(address => mapping(address => mapping(address => PackedAllowance))) public allowance;
//      owner   =>          token  =>         spender => permission details

For your bug bounty platform, here’s how a project team would grant permission for researcher payouts:

// 1. Project team creates a permit for your contract to spend their tokens for bounties
PermitSingle memory permit = PermitSingle({
    details: PermitDetails({
        token: address(USDC),
        amount: 10000 * 10**6,                 // 10,000 USDC maximum for bounties
        expiration: block.timestamp + 30 days, // Valid for 30 days
        nonce: currentNonce                    // Current nonce for this project/token/spender
    }),
    spender: address(bugBountyContract),       // Your contract address
    sigDeadline: block.timestamp + 1 hours     // Signature must be used within 1 hour
});

// 2. Project team signs the permit (off-chain, no gas)
bytes memory signature = signPermit(permit, projectTeamPrivateKey);

// 3. Anyone can submit the signed permit to grant permission
permit2.permit(projectTeam, permit, signature);

AllowanceTransfer EIP-712 Implementation

AllowanceTransfer uses specific EIP-712 structures for signature verification:

// Type hashes used internally by permit2
bytes32 constant PERMIT_DETAILS_TYPEHASH = keccak256(
    "PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)"
);

bytes32 constant PERMIT_SINGLE_TYPEHASH = keccak256(
    "PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)"
);

// Your signature generation for AllowanceTransfer
function generateAllowanceSignature(
    PermitSingle memory permit,
    uint256 privateKey
) internal view returns (bytes memory) {
    // Hash the PermitDetails struct
    bytes32 detailsHash = keccak256(abi.encode(
        PERMIT_DETAILS_TYPEHASH,
        permit.details.token,
        permit.details.amount,
        permit.details.expiration,
        permit.details.nonce
    ));
    
    // Hash the main PermitSingle struct
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_SINGLE_TYPEHASH,
        detailsHash,
        permit.spender,
        permit.sigDeadline
    ));
    
    // Create final EIP-712 hash
    bytes32 hash = keccak256(abi.encodePacked(
        "\x19\x01",
        permit2.DOMAIN_SEPARATOR(),
        structHash
    ));
    
    // Sign and return
    (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, hash);
    return abi.encodePacked(r, s, v);
}

Using AllowanceTransfer in Your Bug Bounty Contract

Once permission is granted by a project team, your contract can transfer tokens to researchers using the familiar pattern:

contract BugBountyPlatform {
    IPermit2 public constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
    
    function processRecurringBountyPayment(
        address projectTeam,
        address researcher,
        address token,
        uint160 amount,
        string calldata bugId
    ) external onlyAdmin {
        // Verify this project has valid permissions and researcher is eligible
        require(authorizedProjects[projectTeam], "Project not authorized");
        require(verifiedResearchers[researcher], "Researcher not verified");
        require(!processedBugs[bugId], "Bug already processed");
        
        // Transfer tokens using existing AllowanceTransfer permission
        // This will deduct from the project team's granted allowance
        PERMIT2.transferFrom(projectTeam, researcher, amount, token);
        
        processedBugs[bugId] = true;
        emit BountyPaid(projectTeam, researcher, bugId, amount);
    }
    
    // Batch processing for multiple researcher payments
    function processBatchBountyPayments(
        IAllowanceTransfer.AllowanceTransferDetails[] calldata transfers
    ) external onlyAdmin {
        PERMIT2.transferFrom(transfers);
    }
}

Ordered Nonce Management in AllowanceTransfer

AllowanceTransfer uses ordered (sequential) nonces for each owner/token/spender combination:

// Nonces must be used in order: 0, 1, 2, 3, ...
// If nonce 2 is used, nonces 0 and 1 become invalid

function getCurrentNonce(address projectTeam, address token, address spender) 
    external view returns (uint48 nonce) {
    (, , nonce) = permit2.allowance(projectTeam, token, spender);
}

// Project teams can invalidate future nonces if their key is compromised
function invalidateFutureNonces(address token, address spender, uint48 newNonce) external {
    permit2.invalidateNonces(token, spender, newNonce);
}

SignatureTransfer: Direct One-Time Transfers

SignatureTransfer enables direct token transfers without pre-existing approvals. Each signature authorizes a specific transfer amount to specific recipients.

Core Data Structures

struct TokenPermissions {
    address token;  // The ERC20 token to transfer
    uint256 amount; // Exact amount to transfer
}

struct PermitTransferFrom {
    TokenPermissions permitted; // Token and amount details
    uint256 nonce;              // Unordered nonce for replay protection
    uint256 deadline;           // Transfer must occur before this time
}

struct SignatureTransferDetails {
    address to;              // Transfer recipient
    uint256 requestedAmount; // Amount to actually transfer (≤ permitted amount)
}

How SignatureTransfer Works

Unlike AllowanceTransfer, SignatureTransfer doesn’t store any state. Each signature is self-contained and authorizes a direct transfer:

// Project team signs permission for a specific bounty payment to a researcher
PermitTransferFrom memory permit = PermitTransferFrom({
    permitted: TokenPermissions({
        token: address(USDC),
        amount: 5000 * 10**6 // Exactly 5,000 USDC
    }),
    nonce: 12345678901234567890, // Unordered nonce
    deadline: block.timestamp + 1 hours
});

SignatureTransferDetails memory transferDetails = SignatureTransferDetails({
    to: researcher,                // Researcher receives the bounty
    requestedAmount: 5000 * 10**6  // Can be ≤ permitted amount
});

// Generate signature (covered in detail in Section 1)
bytes memory signature = generateSignatureTransferSignature(permit, projectTeamPrivateKey);

// Execute the transfer in one call, no pre-existing approvals needed
permit2.permitTransferFrom(permit, transferDetails, projectTeam, signature);

SignatureTransfer EIP-712 Implementation

SignatureTransfer has its own EIP-712 structures optimized for direct transfers:

// Type hashes used internally by permit2
bytes32 constant TOKEN_PERMISSIONS_TYPEHASH = keccak256(
    "TokenPermissions(address token,uint256 amount)"
);

bytes32 constant PERMIT_TRANSFER_FROM_TYPEHASH = keccak256(
    "PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)"
);

// Your signature generation for SignatureTransfer
function generateSignatureTransferSignature(
    PermitTransferFrom memory permit,
    address spender,
    uint256 privateKey
) internal view returns (bytes memory) {
    // Hash TokenPermissions
    bytes32 tokenPermissionsHash = keccak256(abi.encode(
        TOKEN_PERMISSIONS_TYPEHASH,
        permit.permitted.token,
        permit.permitted.amount
    ));
    
    // Hash PermitTransferFrom
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TRANSFER_FROM_TYPEHASH,
        tokenPermissionsHash,
        spender, // Your contract address
        permit.nonce,
        permit.deadline
    ));
    
    // Create final EIP-712 hash
    bytes32 hash = keccak256(abi.encodePacked(
        "\x19\x01",
        permit2.DOMAIN_SEPARATOR(),
        structHash
    ));
    
    // Sign and return
    (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, hash);
    return abi.encodePacked(r, s, v);
}

Unordered Nonce Management in SignatureTransfer

SignatureTransfer uses a sophisticated bitmap-based nonce system that allows signatures to be used in any order:

// Nonces are 256-bit values where:
// - First 248 bits = word position in the bitmap
// - Last 8 bits = bit position within that word

mapping(address => mapping(uint256 => uint256)) public nonceBitmap;

function bitmapPositions(uint256 nonce) private pure returns (uint256 wordPos, uint256 bitPos) {
    wordPos = uint248(nonce >> 8); // First 248 bits
    bitPos = uint8(nonce);         // Last 8 bits
}

// Project teams can invalidate specific nonces using a bitmask
function invalidateUnorderedNonces(uint256 wordPos, uint256 mask) external {
    nonceBitmap[msg.sender][wordPos] |= mask;
}

This system provides incredible flexibility - project teams can:

  • Use signatures in any order
  • Invalidate specific ranges of nonces
  • Generate nonces with custom strategies

Batch Operations: Maximum Efficiency

Both systems support batching to minimize gas costs and enable complex operations:

AllowanceTransfer Batch

// Project team grants permissions for multiple token bounty pools in one transaction
IAllowanceTransfer.PermitBatch memory batchPermit = IAllowanceTransfer.PermitBatch({
    details: [
        IAllowanceTransfer.PermitDetails({
            token: address(USDC),
            amount: 10000 * 10**6,
            expiration: block.timestamp + 30 days,
            nonce: getCurrentNonce(projectTeam, USDC, address(this))
        }),
        IAllowanceTransfer.PermitDetails({
            token: address(DAI),
            amount: 5000 * 10**18,
            expiration: block.timestamp + 30 days,
            nonce: getCurrentNonce(projectTeam, DAI, address(this))
        })
    ],
    spender: address(this),
    sigDeadline: block.timestamp + 1 hours
});

// Single signature grants permissions for multiple token bounty pools
permit2.permit(projectTeam, batchPermit, signature);

SignatureTransfer Batch

// Project team authorizes bounty payments to multiple researchers in one transaction
ISignatureTransfer.PermitBatchTransferFrom memory batchPermit = 
    ISignatureTransfer.PermitBatchTransferFrom({
        permitted: [
            ISignatureTransfer.TokenPermissions({token: address(USDC), amount: 5000 * 10**6}),
            ISignatureTransfer.TokenPermissions({token: address(DAI), amount: 3000 * 10**18})
        ],
        nonce: generateUnorderedNonce(),
        deadline: block.timestamp + 1 hours
    });

ISignatureTransfer.SignatureTransferDetails[] memory transferDetails = [
    ISignatureTransfer.SignatureTransferDetails({to: researcherA, requestedAmount: 5000 * 10**6}),
    ISignatureTransfer.SignatureTransferDetails({to: researcherB, requestedAmount: 3000 * 10**18})
];

// Single signature authorizes multiple bounty payments to different researchers
permit2.permitTransferFrom(batchPermit, transferDetails, projectTeam, signature);

Permit2’s EIP-712 Domain Setup

Permit2 uses a carefully designed EIP-712 domain that’s consistent across all chains:

contract EIP712 is IEIP712 {
    bytes32 private constant _HASHED_NAME = keccak256("Permit2");
    bytes32 private constant _TYPE_HASH = keccak256(
        "EIP712Domain(string name,uint256 chainId,address verifyingContract)"
    );
    
    // Domain separator is cached but recalculated if chain ID changes (for forks)
    function DOMAIN_SEPARATOR() public view returns (bytes32) {
        return block.chainid == _CACHED_CHAIN_ID
            ? _CACHED_DOMAIN_SEPARATOR
            : _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME);
    }
    
    function _buildDomainSeparator(bytes32 typeHash, bytes32 nameHash) private view returns (bytes32) {
        return keccak256(abi.encode(typeHash, nameHash, block.chainid, address(this)));
    }
}

This setup ensures:

  • Cross-chain consistency: Same permit2 address and domain on all chains
  • Fork protection: Domain separator updates automatically on chain forks
  • Version compatibility: Clear name and no version field for maximum compatibility

Security Considerations and Error Handling

Permit2 implements comprehensive security measures that your bug bounty platform should understand:

Time-based Protections

// All permits include deadline checks
if (block.timestamp > permit.deadline) revert SignatureExpired(permit.deadline);
if (block.timestamp > permitSingle.sigDeadline) revert SignatureExpired(permitSingle.sigDeadline);

// AllowanceTransfer permissions can expire
if (block.timestamp > allowed.expiration) revert AllowanceExpired(allowed.expiration);

Amount Validations

// SignatureTransfer enforces exact amount limits
if (requestedAmount > permit.permitted.amount) revert InvalidAmount(permit.permitted.amount);

// AllowanceTransfer tracks and decrements balances
if (amount > maxAmount) revert InsufficientAllowance(maxAmount);

Nonce-based Replay Protection

// AllowanceTransfer: ordered nonces
if (allowed.nonce != nonce) revert InvalidNonce();

// SignatureTransfer: bitmap-based unordered nonces
function _useUnorderedNonce(address from, uint256 nonce) internal {
    (uint256 wordPos, uint256 bitPos) = bitmapPositions(nonce);
    uint256 bit = 1 << bitPos;
    uint256 flipped = nonceBitmap[from][wordPos] ^= bit;
    
    if (flipped & bit == 0) revert InvalidNonce(); // Nonce already used
}

Emergency Features

Permit2 includes emergency functions for compromised accounts:

// AllowanceTransfer: Revoke all approvals immediately
IAllowanceTransfer.TokenSpenderPair[] memory revokeList = new IAllowanceTransfer.TokenSpenderPair[](2);
revokeList[0] = IAllowanceTransfer.TokenSpenderPair({token: address(USDC), spender: address(bugBountyContract)});
revokeList[1] = IAllowanceTransfer.TokenSpenderPair({token: address(DAI), spender: address(bugBountyContract)});

permit2.lockdown(revokeList); // Sets allowance amounts to 0 for all specified token/spender pairs

// SignatureTransfer: Invalidate ranges of nonces
permit2.invalidateUnorderedNonces(wordPos, mask); // Invalidates specific nonces using bitmap

Gas Efficiency Benefits

Permit2 provides significant gas savings for your bug bounty platform by reducing transaction overhead and enabling batch operations:

Traditional Approach vs Permit2

// Traditional: Two separate transactions required
// Transaction 1: Project team approves contract (gas cost varies by token)
USDC.approve(bugBountyContract, amount);

// Transaction 2: Contract processes bounty payment
bugBountyContract.claimBounty(bugId, amount);

// Total: 2 transactions + 2 transaction fees

Permit2 Approach

// Single transaction: Project team signs permit off-chain, anyone can execute
bugBountyContract.claimBountyWithPermit(permit, signature, bugId);

// Total: 1 transaction + 1 transaction fee

Batch Operations Provide Maximum Efficiency

// Process multiple bug bounty payments in a single transaction:
// Traditional: N separate approve + transfer transactions
// Permit2 batch: 1 transaction processing N transfers

// Significant gas savings scale with batch size
PERMIT2.permitTransferFrom(batchPermit, transferDetails, projectTeam, signature);

The gas savings become more pronounced with larger batch sizes and frequent operations, making permit2 ideal for platforms processing multiple payments regularly.

Integration Example: Complete Bug Bounty Flow

Here’s how your bug bounty platform would integrate both permit2 systems:

contract BugBountyPlatform {
    IPermit2 public constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
    
    // For ongoing projects with recurring researcher payments (use AllowanceTransfer)
    function setupProjectBountyPool(
        address projectTeam,
        IAllowanceTransfer.PermitSingle calldata permit,
        bytes calldata signature
    ) external {
        // Verify project eligibility
        require(verifiedProjects[projectTeam], "Project not verified");
        
        // Grant permission for the platform to distribute bounty payments
        PERMIT2.permit(projectTeam, permit, signature);
        
        projectBountyPools[projectTeam] = true;
        emit BountyPoolSetup(projectTeam, permit.details.token, permit.details.amount);
    }
    
    // For one-time vulnerability bounty payments (use SignatureTransfer)
    function processBountyWithSignature(
        ISignatureTransfer.PermitTransferFrom calldata permit,
        ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
        address projectTeam,
        bytes calldata signature,
        string calldata bugId,
        string calldata vulnerabilityDescription
    ) external {
        // Verify the vulnerability claim and project authorization
        require(!processedBugs[bugId], "Bug already processed");
        require(verifiedProjects[projectTeam], "Project not verified");
        require(verifiedResearchers[transferDetails.to], "Researcher not verified");
        require(validateVulnerability(bugId, vulnerabilityDescription), "Invalid vulnerability");
        
        // Execute the token transfer using SignatureTransfer from project to researcher
        PERMIT2.permitTransferFrom(permit, transferDetails, projectTeam, signature);
        
        // Record the bounty payment
        processedBugs[bugId] = true;
        bugReports[bugId] = BugReport({
            projectTeam: projectTeam,
            researcher: transferDetails.to,
            amount: transferDetails.requestedAmount,
            description: vulnerabilityDescription,
            timestamp: block.timestamp
        });
        
        emit BountyPaid(projectTeam, transferDetails.to, bugId, transferDetails.requestedAmount);
    }
}

Choosing Between AllowanceTransfer and SignatureTransfer

For your bug bounty platform, choose based on the use case:

Use AllowanceTransfer when:

  • Setting up ongoing bounty pools for verified projects
  • Processing recurring payments to multiple researchers
  • Projects need persistent permissions that don’t require re-signing for each payout
  • Gas efficiency for multiple researcher payments is crucial

Use SignatureTransfer when:

  • Processing one-time vulnerability bounty payments
  • Each payment requires fresh project authorization
  • You want to include additional context data (witness data - covered in Section 3)
  • Maximum security with no persistent state

Best Practices for Bug Bounty Platforms

  1. Deadline Management: Always set reasonable deadlines (1-24 hours for signatures)
  2. Amount Validation: Verify amounts match your bounty scales
  3. Nonce Strategies: Use sequential nonces for predictable workflows, bitmap nonces for flexibility
  4. Batch Operations: Group related transfers to minimize gas costs
  5. Emergency Procedures: Implement lockdown mechanisms for compromised researchers
  6. Signature Verification: Always validate signatures before processing claims

Permit2 provides the foundation for building sophisticated, gas-efficient, and user-friendly token permission systems. In the next section, we’ll explore witness transfers, which are permit2’s most powerful feature that allows you to bind custom logic directly to token transfers.

Permit2 Witness Transfers: Adding Custom Logic

What are Witness Transfers?

Witness transfers are permit2’s most powerful feature, allowing you to bind custom protocol logic directly to token transfers with a single signature. Instead of just transferring tokens, you can include additional structured data that gets verified as part of the signature, enabling sophisticated workflows that combine token movements with contract logic.

For your bug bounty platform, witness transfers unlock scenarios like:

  • Vulnerability Verification: Bind bounty payments to specific vulnerability details
  • Multi-step Payouts: Coordinate complex payout logic with token transfers
  • Automated Escrow: Release payments when conditions are met
  • Audit Trail: Include immutable metadata with every bounty payment

The Problem Witness Transfers Solve

Traditional permit2 transfers can move tokens efficiently, but they lack context. Consider this limitation:

// Basic SignatureTransfer: Moves 1000 USDC to researcher
// But doesn't verify WHAT the payment is for
permit2.permitTransferFrom(permit, transferDetails, projectTeam, signature);

With witness transfers, you can include rich context:

// Witness Transfer: Moves 1000 USDC AND verifies vulnerability details
permit2.permitWitnessTransferFrom(permit, transferDetails, projectTeam, vulnerabilityHash, witnessTypeString, signature);

This ensures that project teams can only authorize payments for legitimate, verified vulnerabilities, not arbitrary transfers.

Witness Transfer Fundamentals

Witness transfers extend the standard permit2 EIP-712 structure by adding a custom data hash:

Standard Permit: PermitTransferFrom(TokenPermissions, spender, nonce, deadline)
Witness Permit:  PermitWitnessTransferFrom(TokenPermissions, spender, nonce, deadline, WitnessHash)

The witness hash is computed from your custom struct using the same EIP-712 patterns from Section 1:

bytes32 witnessHash = keccak256(abi.encode(WITNESS_TYPEHASH, ...witnessData));

Basic Witness Implementation: Vulnerability Verification

Let’s start with a simple vulnerability verification system:

contract BugBountyPlatform {
    IPermit2 public constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
    
    struct VulnerabilityReport {
        address researcher;
        string bugId;
        uint8 severity;
        string description;
        uint256 bountyAmount;
    }
    
    bytes32 public constant VULNERABILITY_TYPEHASH = keccak256(
        "VulnerabilityReport(address researcher,string bugId,uint8 severity,string description,uint256 bountyAmount)"
    );
    
    mapping(string => bool) public processedVulnerabilities;
    
    function processBountyWithWitness(
        ISignatureTransfer.PermitTransferFrom calldata permit,
        ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
        address projectTeam,
        VulnerabilityReport calldata vulnerability,
        bytes calldata signature
    ) external {
        // Verify vulnerability hasn't been processed
        require(!processedVulnerabilities[vulnerability.bugId], "Bug already processed");
        require(vulnerability.researcher == transferDetails.to, "Researcher mismatch");
        require(vulnerability.bountyAmount == transferDetails.requestedAmount, "Amount mismatch");
        
        // Generate witness hash from vulnerability data
        bytes32 witnessHash = keccak256(abi.encode(
            VULNERABILITY_TYPEHASH,
            vulnerability.researcher,
            keccak256(bytes(vulnerability.bugId)),       // Dynamic type: hash first
            vulnerability.severity,
            keccak256(bytes(vulnerability.description)), // Dynamic type: hash first
            vulnerability.bountyAmount
        ));
        
        // Create witness type string (critical formatting!)
        string memory witnessTypeString = 
            "VulnerabilityReport witness)VulnerabilityReport(address researcher,string bugId,uint8 severity,string description,uint256 bountyAmount)TokenPermissions(address token,uint256 amount)";
        
        // Execute witness transfer, this verifies both token permission AND vulnerability data
        PERMIT2.permitWitnessTransferFrom(
            permit,
            transferDetails,
            projectTeam,
            witnessHash,
            witnessTypeString,
            signature
        );
        
        // Mark vulnerability as processed
        processedVulnerabilities[vulnerability.bugId] = true;
        
        emit BountyPaid(projectTeam, vulnerability.researcher, vulnerability.bugId, vulnerability.bountyAmount);
    }
}

Witness Type String: The Critical Component

The witness type string is the most error-prone part of witness transfers. It must follow exact formatting rules:

"{WitnessType} witness){WitnessType}({fields}){NestedType1}({fields}){NestedType2}({fields})TokenPermissions(address token,uint256 amount)"

Critical formatting rules:

  1. Starts with witness type name followed by “ witness)”
  2. Complete witness struct definition
  3. ALL referenced structs in alphabetical order
  4. TokenPermissions must always be included (it’s part of the permit2 structure)
  5. No extra spaces, exact field names and types
  6. Dynamic types (string, bytes) included in type definition but hashed in data

Advanced Witness: Nested Structs

Building on Section 1’s nested struct patterns, let’s create a more sophisticated vulnerability system:

struct VulnerabilityDetails {
    string bugId;
    uint8 severity;
    string description;
}

struct PaymentTerms {
    uint256 amount;
    uint256 deadline;
    bool requiresApproval;
}

struct ComplexVulnerabilityReport {
    address researcher;
    VulnerabilityDetails details;
    PaymentTerms terms;
    address projectTeam;
}

// Type hashes for each struct (alphabetical order matters!)
bytes32 public constant PAYMENT_TERMS_TYPEHASH = keccak256(
    "PaymentTerms(uint256 amount,uint256 deadline,bool requiresApproval)"
);

bytes32 public constant VULNERABILITY_DETAILS_TYPEHASH = keccak256(
    "VulnerabilityDetails(string bugId,uint8 severity,string description)"
);

bytes32 public constant COMPLEX_VULNERABILITY_TYPEHASH = keccak256(
    "ComplexVulnerabilityReport(address researcher,VulnerabilityDetails details,PaymentTerms terms,address projectTeam)PaymentTerms(uint256 amount,uint256 deadline,bool requiresApproval)VulnerabilityDetails(string bugId,uint8 severity,string description)"
);

function processComplexBounty(
    ISignatureTransfer.PermitTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
    address projectTeam,
    ComplexVulnerabilityReport calldata report,
    bytes calldata signature
) external {
    // Validation logic...
    
    // Hash nested structs first
    bytes32 detailsHash = keccak256(abi.encode(
        VULNERABILITY_DETAILS_TYPEHASH,
        keccak256(bytes(report.details.bugId)),
        report.details.severity,
        keccak256(bytes(report.details.description))
    ));
    
    bytes32 termsHash = keccak256(abi.encode(
        PAYMENT_TERMS_TYPEHASH,
        report.terms.amount,
        report.terms.deadline,
        report.terms.requiresApproval
    ));
    
    // Hash main struct using nested struct hashes
    bytes32 witnessHash = keccak256(abi.encode(
        COMPLEX_VULNERABILITY_TYPEHASH,
        report.researcher,
        detailsHash, // Use hash, not raw struct
        termsHash,   // Use hash, not raw struct
        report.projectTeam
    ));
    
    // Type string with all structs in alphabetical order
    string memory witnessTypeString = 
        "ComplexVulnerabilityReport witness)ComplexVulnerabilityReport(address researcher,VulnerabilityDetails details,PaymentTerms terms,address projectTeam)PaymentTerms(uint256 amount,uint256 deadline,bool requiresApproval)TokenPermissions(address token,uint256 amount)VulnerabilityDetails(string bugId,uint8 severity,string description)";
    
    PERMIT2.permitWitnessTransferFrom(
        permit,
        transferDetails,
        projectTeam,
        witnessHash,
        witnessTypeString,
        signature
    );
}

Dynamic Data Witness: Including Custom Instructions

Sometimes you need to include variable-length data like custom execution instructions:

struct BountyWithInstructions {
    address researcher;
    string bugId;
    uint256 amount;
    bytes executionData; // Dynamic bytes for custom logic
    uint256 deadline;
}

bytes32 public constant BOUNTY_INSTRUCTIONS_TYPEHASH = keccak256(
    "BountyWithInstructions(address researcher,string bugId,uint256 amount,bytes executionData,uint256 deadline)"
);

function processBountyWithInstructions(
    ISignatureTransfer.PermitTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
    address projectTeam,
    BountyWithInstructions calldata bounty,
    bytes calldata signature
) external {
    // Generate witness hash with dynamic bytes
    bytes32 witnessHash = keccak256(abi.encode(
        BOUNTY_INSTRUCTIONS_TYPEHASH,
        bounty.researcher,
        keccak256(bytes(bounty.bugId)),  // String: hash first
        bounty.amount,
        keccak256(bounty.executionData), // Bytes: hash first
        bounty.deadline
    ));
    
    string memory witnessTypeString = 
        "BountyWithInstructions witness)BountyWithInstructions(address researcher,string bugId,uint256 amount,bytes executionData,uint256 deadline)TokenPermissions(address token,uint256 amount)";
    
    PERMIT2.permitWitnessTransferFrom(
        permit,
        transferDetails,
        projectTeam,
        witnessHash,
        witnessTypeString,
        signature
    );
    
    // Execute custom logic based on executionData
    if (bounty.executionData.length > 0) {
        (bool success,) = address(this).call(bounty.executionData);
        require(success, "Execution failed");
    }
}

Array Witness: Batch Vulnerability Processing

The most complex witness scenario involves arrays of structs, enabling batch processing:

struct VulnerabilityPayout {
    string bugId;
    uint8 severity;
    address researcher;
    uint256 amount;
}

struct BatchVulnerabilityReport {
    address projectTeam;
    VulnerabilityPayout[] vulnerabilities;
    uint256 totalAmount;
    uint256 deadline;
}

bytes32 public constant VULNERABILITY_PAYOUT_TYPEHASH = keccak256(
    "VulnerabilityPayout(string bugId,uint8 severity,address researcher,uint256 amount)"
);

bytes32 public constant BATCH_VULNERABILITY_TYPEHASH = keccak256(
    "BatchVulnerabilityReport(address projectTeam,VulnerabilityPayout[] vulnerabilities,uint256 totalAmount,uint256 deadline)TokenPermissions(address token,uint256 amount)VulnerabilityPayout(string bugId,uint8 severity,address researcher,uint256 amount)"
);

function processBatchVulnerabilities(
    ISignatureTransfer.PermitBatchTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails[] calldata transferDetails,
    address projectTeam,
    BatchVulnerabilityReport calldata batchReport,
    bytes calldata signature
) external {
    require(batchReport.vulnerabilities.length == transferDetails.length, "Length mismatch");
    
    // Hash the array of vulnerabilities
    bytes32 vulnerabilitiesHash = _hashVulnerabilityArray(batchReport.vulnerabilities);
    
    // Hash the main batch struct
    bytes32 witnessHash = keccak256(abi.encode(
        BATCH_VULNERABILITY_TYPEHASH,
        batchReport.projectTeam,
        vulnerabilitiesHash, // Use array hash
        batchReport.totalAmount,
        batchReport.deadline
    ));
    
    string memory witnessTypeString = 
        "BatchVulnerabilityReport witness)BatchVulnerabilityReport(address projectTeam,VulnerabilityPayout[] vulnerabilities,uint256 totalAmount,uint256 deadline)TokenPermissions(address token,uint256 amount)VulnerabilityPayout(string bugId,uint8 severity,address researcher,uint256 amount)";
    
    PERMIT2.permitWitnessTransferFrom(
        permit,
        transferDetails,
        projectTeam,
        witnessHash,
        witnessTypeString,
        signature
    );
    
    // Process each vulnerability
    for (uint256 i = 0; i < batchReport.vulnerabilities.length; i++) {
        processedVulnerabilities[batchReport.vulnerabilities[i].bugId] = true;
    }
}

function _hashVulnerabilityArray(VulnerabilityPayout[] memory vulnerabilities) 
    internal 
    pure 
    returns (bytes32) 
{
    if (vulnerabilities.length == 0) {
        return keccak256("");
    }
    
    bytes memory encodedVulnerabilities = "";
    
    for (uint256 i = 0; i < vulnerabilities.length; i++) {
        bytes32 vulnHash = keccak256(abi.encode(
            VULNERABILITY_PAYOUT_TYPEHASH,
            keccak256(bytes(vulnerabilities[i].bugId)),  // String: hash first
            vulnerabilities[i].severity,
            vulnerabilities[i].researcher,
            vulnerabilities[i].amount
        ));
        
        encodedVulnerabilities = abi.encodePacked(encodedVulnerabilities, vulnHash);
    }
    
    return keccak256(encodedVulnerabilities);
}

Complete Signature Generation for Witnesses

Generating witness signatures requires careful attention to the EIP-712 structure:

function generateVulnerabilityWitnessSignature(
    ISignatureTransfer.PermitTransferFrom memory permit,
    VulnerabilityReport memory vulnerability,
    address spender,
    uint256 privateKey
) internal view returns (bytes memory) {
    // 1. Hash the witness data
    bytes32 witnessHash = keccak256(abi.encode(
        VULNERABILITY_TYPEHASH,
        vulnerability.researcher,
        keccak256(bytes(vulnerability.bugId)),
        vulnerability.severity,
        keccak256(bytes(vulnerability.description)),
        vulnerability.bountyAmount
    ));
    
    // 2. Create witness type string
    string memory witnessTypeString = 
        "VulnerabilityReport witness)VulnerabilityReport(address researcher,string bugId,uint8 severity,string description,uint256 bountyAmount)TokenPermissions(address token,uint256 amount)";
    
    // 3. Generate permit witness type hash
    bytes32 permitWitnessTypehash = keccak256(abi.encodePacked(
        "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,",
        witnessTypeString
    ));
    
    // 4. Hash TokenPermissions
    bytes32 tokenPermissionsHash = keccak256(abi.encode(
        keccak256("TokenPermissions(address token,uint256 amount)"),
        permit.permitted.token,
        permit.permitted.amount
    ));
    
    // 5. Create final struct hash
    bytes32 structHash = keccak256(abi.encode(
        permitWitnessTypehash,
        tokenPermissionsHash,
        spender,
        permit.nonce,
        permit.deadline,
        witnessHash  // Witness hash is included here
    ));
    
    // 6. Create EIP-712 hash
    bytes32 hash = keccak256(abi.encodePacked(
        "\x19\x01",
        PERMIT2.DOMAIN_SEPARATOR(),
        structHash
    ));
    
    // 7. Sign
    (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, hash);
    return abi.encodePacked(r, s, v);
}

Witness vs Non-Witness: When to Use Each

Use Standard Transfers when:

  • Simple token movements without additional context
  • Gas efficiency is paramount
  • No custom validation logic needed

Use Witness Transfers when:

  • Need to verify additional data alongside token transfers
  • Want to bind transfers to specific contract logic
  • Require immutable audit trails
  • Implementing complex multi-step workflows

Security Considerations for Witness Transfers

  1. Type String Validation: Always validate witness type strings match your structs exactly
  2. Data Integrity: Ensure witness data corresponds to actual transfer parameters
  3. Replay Protection: Witness hashes don’t provide additional replay protection beyond nonces (EIP-712 protection still applies)
  4. Gas Costs: Complex witness data increases signature verification costs

Advanced Patterns: Conditional Logic

Witness transfers can enable sophisticated conditional logic:

struct ConditionalBounty {
    address researcher;
    string bugId;
    uint256 baseAmount;
    uint256 bonusAmount;
    bool criticalSeverity;
    uint256 deadline;
}

function processConditionalBounty(
    ISignatureTransfer.PermitTransferFrom calldata permit,
    ISignatureTransfer.SignatureTransferDetails calldata transferDetails,
    address projectTeam,
    ConditionalBounty calldata bounty,
    bytes calldata signature
) external {
    // Calculate expected amount based on conditions
    uint256 expectedAmount = bounty.baseAmount;
    if (bounty.criticalSeverity) {
        expectedAmount += bounty.bonusAmount;
    }
    
    require(transferDetails.requestedAmount == expectedAmount, "Incorrect amount");
    
    // Generate witness hash
    bytes32 witnessHash = keccak256(abi.encode(
        CONDITIONAL_BOUNTY_TYPEHASH,
        bounty.researcher,
        keccak256(bytes(bounty.bugId)),
        bounty.baseAmount,
        bounty.bonusAmount,
        bounty.criticalSeverity,
        bounty.deadline
    ));
    
    // Process with witness verification
    PERMIT2.permitWitnessTransferFrom(
        permit,
        transferDetails,
        projectTeam,
        witnessHash,
        conditionalWitnessTypeString,
        signature
    );
}

Best Practices for Witness Transfers

  1. Consistent Formatting: Use constants for type strings to avoid errors
  2. Comprehensive Testing: Test all witness type combinations thoroughly
  3. Gas Optimization: Pre-compute type hashes where possible
  4. Error Handling: Provide clear error messages for witness validation failures
  5. Documentation: Document witness struct formats for frontend integration

Witness transfers represent the culmination of EIP-712 and permit2’s capabilities, enabling you to build sophisticated, verifiable workflows that combine token movements with rich contract logic. Your bug bounty platform can now ensure that every payment is not just authorized by the right project team, but is also tied to verified vulnerability data, creating an immutable audit trail of security research activities.


Conclusion

This guide has taken you through the complete journey of implementing EIP-712 and permit2 systems, from basic structured signatures to sophisticated witness-enabled token transfers. The combination of these technologies provides an incredibly powerful foundation for building next-generation DeFi protocols that are both secure and user-friendly.

What You’ve Learned

Section 1: EIP-712 Mastery

  • How to create human-readable, structured signatures
  • Advanced patterns for nested structs and arrays
  • Type hashing and encoding rules for all data types
  • Common pitfalls and best practices for production implementations

Section 2: Permit2 Foundation

  • Both AllowanceTransfer and SignatureTransfer systems
  • When to use each approach for different use cases
  • Nonce management strategies (ordered vs unordered)
  • Gas optimization techniques and security considerations

Section 3: Witness Transfer Excellence

  • Binding custom logic directly to token transfers
  • Complex data structures with dynamic content
  • Batch processing with arrays of structs
  • Advanced patterns for conditional logic and automation

The Bug Bounty Platform: A Complete Example

Throughout this guide, we’ve built a comprehensive bug bounty platform that demonstrates how these technologies work together in practice:

  • Project teams use EIP-712 to authorize structured bounty payments
  • Permit2 provides efficient, secure token permission management
  • Witness transfers bind payments to verified vulnerability data
  • Complex logic handles everything from simple payouts to batch processing

This pattern applies to countless other DeFi use cases: AMMs with slippage protection, lending protocols with collateral verification, DAO governance with proposal binding, and much more.

Key Architectural Decisions

When implementing these systems in production, remember the critical decision points:

For EIP-712:

  • Static vs dynamic types affect gas costs and complexity
  • Nested structs provide structure but increase verification costs
  • Arrays enable powerful batch operations but require careful gas management

For Permit2:

  • AllowanceTransfer for ongoing relationships and recurring transactions
  • SignatureTransfer for one-time operations and maximum security
  • Witness transfers when you need to verify additional data

For Security:

  • Always validate amounts, deadlines, and nonces
  • Implement signature-based emergency functions for compromised accounts that can be submitted on the user’s behalf
  • Use consistent type strings to avoid verification errors

Getting Started

Ready to implement these patterns in your own protocol? Here’s your action plan:

  1. Start Simple: Begin with basic EIP-712 structs for your core data types
  2. Add Permit2: Integrate SignatureTransfer for one-time operations first
  3. Implement Witnesses: Add witness transfers when you need custom logic
  4. Scale Up: Use AllowanceTransfer if required and/or batch operations for efficiency
  5. Test Everything: Comprehensive testing is crucial for signature-based systems

Resources for Continued Learning

Final Thoughts

The future of DeFi lies in protocols that respect user agency while providing powerful functionality. EIP-712 and permit2 give users clear visibility into what they’re signing while enabling developers to build sophisticated, efficient systems.

By mastering these technologies, you’re not just learning current best practices, you’re preparing for the next generation of DeFi where structured signatures and context-aware permissions become the standard. The patterns and principles you’ve learned here will serve you well as the ecosystem continues to evolve toward greater security, usability, and sophistication.

Welcome to the new era of structured, secure, and user-friendly DeFi. Now go build something amazing.