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
keccak256first - 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:
- Each nested struct needs its own type hash
- Hash nested structs separately, then use their hash in the parent
- 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
keccak256before 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
-
Empty Arrays: Always hash to
keccak256("") -
Non-Empty Static Type Arrays: Hash the entire
abi.encodePackedarray -
Non-Empty Dynamic Type Arrays: Hash each element individually, concatenate with
abi.encodePacked, then hash the result - Dynamic Types in Arrays: String and bytes fields within array elements must be hashed before encoding
- Order Matters: Array element order affects the final hash
- 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
-
Forgetting Empty Array Handling: Always check for empty arrays and return
keccak256("") -
Wrong Concatenation Method: Use
abi.encodePackedfor concatenating hashes, notabi.encode - Type String Mismatches: Array notation in type strings must exactly match struct definitions
- Dynamic Type Confusion: Remember that arrays themselves are always dynamic, even arrays of static types
- 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
- Dynamic Type Encoding: Always hash strings, bytes, and arrays before encoding
- Field Order: Must match exactly between type definition and encoding
- Type String Format: No spaces except after commas, exact type names
- Nested Struct Definitions: Include all referenced structs in alphabetical order
- 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:
- Poor User Experience: Project teams must send two separate transactions per payout
- Gas Inefficiency: Each approval costs gas, even if never used
- Security Risks: Unlimited approvals create attack vectors
- No Expiration: Approvals persist indefinitely
- 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
- Deadline Management: Always set reasonable deadlines (1-24 hours for signatures)
- Amount Validation: Verify amounts match your bounty scales
- Nonce Strategies: Use sequential nonces for predictable workflows, bitmap nonces for flexibility
- Batch Operations: Group related transfers to minimize gas costs
- Emergency Procedures: Implement lockdown mechanisms for compromised researchers
- 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:
- Starts with witness type name followed by “ witness)”
- Complete witness struct definition
- ALL referenced structs in alphabetical order
- TokenPermissions must always be included (it’s part of the permit2 structure)
- No extra spaces, exact field names and types
- 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
- Type String Validation: Always validate witness type strings match your structs exactly
- Data Integrity: Ensure witness data corresponds to actual transfer parameters
- Replay Protection: Witness hashes don’t provide additional replay protection beyond nonces (EIP-712 protection still applies)
- 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
- Consistent Formatting: Use constants for type strings to avoid errors
- Comprehensive Testing: Test all witness type combinations thoroughly
- Gas Optimization: Pre-compute type hashes where possible
- Error Handling: Provide clear error messages for witness validation failures
- 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:
- Start Simple: Begin with basic EIP-712 structs for your core data types
- Add Permit2: Integrate SignatureTransfer for one-time operations first
- Implement Witnesses: Add witness transfers when you need custom logic
- Scale Up: Use AllowanceTransfer if required and/or batch operations for efficiency
- Test Everything: Comprehensive testing is crucial for signature-based systems
Resources for Continued Learning
- Official Specifications: EIP-712 and Permit2 Documentation
- Development and Testing Framework: Foundry for comprehensive smart contract development and testing
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.