This guide will help you quickly integrate Permit3 into your application, enabling cross-chain token approvals and transfers with witness functionality.
Prerequisites
- An Ethereum development environment (Hardhat, Foundry, etc.)
- Basic understanding of EIP-712 signatures
- Familiarity with ERC20 tokens
- Access to the Permit3 contract address on your target chain(s)
Installation
Using npm
npm install @permit3/contracts
Using Foundry
forge install username/permit3
Basic Integration
Initialize Permit3 Interface
// Existing contracts integrated with Permit2 can work with Permit3 without any changes
IPermit permit = IPermit(PERMIT3_ADDRESS);
permit.transferFrom(from, to, amount, token);
// For Permit3 features
IPermit3 permit3 = IPermit3(PERMIT3_ADDRESS);
User Setup
Users need to approve Permit3 to spend their tokens (once per token):
// User approves Permit3 contract
ERC20(token).approve(PERMIT3_ADDRESS, type(uint256).max);
Creating and Signing Permits
Simple Token Transfer
The chainId in the domain object must ALWAYS be set to 1, regardless of which chain the permit is for.
This is because Permit3 uses a universal signature scheme where permits are signed once with chainId: 1
and can then be used across multiple chains.The actual chain where the permit will be executed is specified
separately in the permit data itself (e.g., in chainPermits.chainId), NOT in the EIP-712 domain.
Changing the domain chainId from 1 will cause signature verification to fail.
// JavaScript (ethers.js)
const domain = {
name: 'Permit3',
version: '1',
chainId: 1, // ALWAYS 1 (CROSS_CHAIN_ID) for cross-chain compatibility
verifyingContract: permit3Address
};
const permit = {
modeOrExpiration: 0, // Transfer mode
token: tokenAddress,
account: recipientAddress,
amountDelta: ethers.utils.parseUnits('10', 18) // 10 tokens
};
const chainPermits = {
chainId: 1, // chainId for network where this permit will be executed (e.g., 1 for Ethereum, 137 for Polygon)
permits: [permit]
};
const permitData = {
owner: userAddress,
salt: ethers.utils.randomBytes(32),
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour
timestamp: Math.floor(Date.now() / 1000),
chain: chainPermits
};
const types = {
Permit3: [
{ name: 'owner', type: 'address' },
{ name: 'salt', type: 'bytes32' },
{ name: 'deadline', type: 'uint48' },
{ name: 'timestamp', type: 'uint48' },
{ name: 'merkleRoot', type: 'bytes32' }
],
ChainPermits: [
{ name: 'chainId', type: 'uint64' },
{ name: 'permits', type: 'AllowanceOrTransfer[]' }
],
AllowanceOrTransfer: [
{ name: 'modeOrExpiration', type: 'uint48' },
{ name: 'token', type: 'address' },
{ name: 'account', type: 'address' },
{ name: 'amountDelta', type: 'uint160' }
]
};
// Calculate the permits hash
const permitsHash = ethers.utils.keccak256(/* hash calculation logic */);
const value = {
owner: permitData.owner,
salt: permitData.salt,
deadline: permitData.deadline,
timestamp: permitData.timestamp,
merkleRoot: permitsHash
};
const signature = await signer._signTypedData(domain, types, value);
4. Executing Permits
// In your contract
function executePermit(
address owner,
bytes32 salt,
uint48 deadline,
uint48 timestamp,
IPermit3.ChainPermits calldata chainPermits,
bytes calldata signature
) external {
permit3.permit(
owner,
salt,
deadline,
timestamp,
chainPermits,
signature
);
// Now you can use the transferred tokens or the allowance
}
Using Witness Functionality
Witness functionality allows you to include arbitrary data in your permits for enhanced verification.
1. Define Your Witness Data
// Example: Order data as witness
const orderData = {
orderId: 12345,
price: ethers.utils.parseUnits('2000', 18),
expiration: Math.floor(Date.now() / 1000) + 3600
};
// Hash the order data to create witness
const witness = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
['uint256', 'uint256', 'uint256'],
[orderData.orderId, orderData.price, orderData.expiration]
)
);
// Define witness type string
const witnessTypeString = "OrderData data)OrderData(uint256 orderId,uint256 price,uint256 expiration)";
2. Sign Witness Permit
// Add witness types
const types = {
// ... previous types
PermitWitness: [
{ name: 'permitted', type: 'ChainPermits' },
{ name: 'spender', type: 'address' },
{ name: 'salt', type: 'bytes32' },
{ name: 'deadline', type: 'uint48' },
{ name: 'timestamp', type: 'uint48' },
{ name: 'data', type: 'OrderData' }
],
OrderData: [
{ name: 'orderId', type: 'uint256' },
{ name: 'price', type: 'uint256' },
{ name: 'expiration', type: 'uint256' }
]
};
// Create and sign witness permit
const witnessValue = {
permitted: chainPermits,
spender: userAddress,
salt: salt,
deadline: deadline,
timestamp: timestamp,
data: orderData
};
const witnessSignature = await signer._signTypedData(domain, types, witnessValue);
3. Execute Witness Permit
function executeWitnessPermit(
address owner,
bytes32 salt,
uint48 deadline,
uint48 timestamp,
IPermit3.ChainPermits calldata chainPermits,
bytes32 witness,
string calldata witnessTypeString,
bytes calldata signature,
OrderData calldata orderData // Your application's data structure
) external {
// Verify witness matches the order data
bytes32 expectedWitness = keccak256(abi.encode(
orderData.orderId,
orderData.price,
orderData.expiration
));
require(witness == expectedWitness, "Invalid witness data");
// Execute permit with witness
permit3.permitWitness(
owner,
salt,
deadline,
timestamp,
chainPermits,
witness,
witnessTypeString,
signature
);
// Continue with application logic, using the transferred tokens
// and the validated order data
}
Cross-Chain Operations
Permit3 supports cross-chain operations with a single signature.
1. Create Permits for Multiple Chains
// Ethereum permits
const ethPermits = {
chainId: 1, // Ethereum
permits: [/* permits for Ethereum */]
};
// Arbitrum permits
const arbPermits = {
chainId: 42161, // Arbitrum
permits: [/* permits for Arbitrum */]
};
// Optimism permits
const optPermits = {
chainId: 10, // Optimism
permits: [/* permits for Optimism */]
};
2. Generate Merkle Tree
// Generate leaf hash for each chain's permits
const ethLeaf = permit3.hashChainPermits(ethPermits);
const arbLeaf = permit3.hashChainPermits(arbPermits);
const optLeaf = permit3.hashChainPermits(optPermits);
// Build merkle tree from all leaves
const leaves = [ethLeaf, arbLeaf, optLeaf];
// Simple merkle root calculation (use a library in production)
function buildMerkleRoot(leaves) {
if (leaves.length === 1) return leaves[0];
const pairs = [];
for (let i = 0; i < leaves.length; i += 2) {
const left = leaves[i];
const right = leaves[i + 1] || leaves[i];
const [first, second] = left < right ? [left, right] : [right, left];
pairs.push(keccak256(encode(['bytes32', 'bytes32'], [first, second])));
}
return buildMerkleRoot(pairs);
}
const merkleRoot = buildMerkleRoot(leaves);
3. Sign and Execute on Each Chain
// Sign the merkle root
const signature = signPermit3(owner, salt, deadline, timestamp, merkleRoot);
// Generate merkle proofs (use a library in production)
function generateMerkleProof(leaves, targetIndex) {
// Returns array of sibling hashes
// In this example with 3 leaves, proofs would be:
// ethProof: [arbLeaf, hash(optLeaf, optLeaf)]
// arbProof: [ethLeaf, hash(optLeaf, optLeaf)]
// optProof: [optLeaf, hash(ethLeaf, arbLeaf)]
}
// On Ethereum
const ethProof = {
permits: ethPermits,
proof: generateMerkleProof(leaves, 0) // Direct array
};
permit3.permit(owner, salt, deadline, timestamp, ethProof, signature);
// On Arbitrum
const arbProof = {
permits: arbPermits,
proof: generateMerkleProof(leaves, 1) // Direct array
};
permit3.permit(owner, salt, deadline, timestamp, arbProof, signature);
// On Optimism
const optProof = {
permits: optPermits,
proof: generateMerkleProof(leaves, 2) // Direct array
};
permit3.permit(owner, salt, deadline, timestamp, optProof, signature);
Common Operations
Setting an Allowance
const permitData = {
modeOrExpiration: Math.floor(Date.now() / 1000) + 86400, // 24 hours expiration
token: tokenAddress,
account: spenderAddress,
amountDelta: ethers.utils.parseUnits('100', 18) // 100 tokens
};
Decreasing an Allowance
const permitData = {
modeOrExpiration: 1, // Decrease mode
token: tokenAddress,
account: spenderAddress,
amountDelta: ethers.utils.parseUnits('50', 18) // Decrease by 50 tokens
};
Locking an Account
const permitData = {
modeOrExpiration: 2, // Lock mode
token: tokenAddress,
account: address(0), // Not used for locking
amountDelta: 0 // Not used for locking
};
Unlocking an Account
const permitData = {
modeOrExpiration: 3, // Unlock mode
token: tokenAddress,
account: address(0), // Not used for unlocking
amountDelta: 0 // Not used for unlocking
};
Best Practices
- Use Unique Salts: Generate cryptographically secure random values for salts
- Set Reasonable Deadlines: Keep signature validity periods as short as practical
- Validate Chain IDs: Always verify chain IDs match when processing cross-chain permits
- Handle Expiration: Check for expired signatures before attempting to process them
- Validate Witness Data: Verify witness data matches expected values before taking action
- Monitor Allowances: Track allowance changes to prevent unexpected behavior
- Test Thoroughly: Test all permit scenarios, including error cases
- Gas Optimization: Batch related operations when possible
Next Steps
Troubleshooting
Signature Verification Fails
- Ensure domain parameters (name, version, chainId, verifyingContract) are correct
- Verify the signer is the token owner
- Check salt hasn’t been used before
- Ensure deadline is in the future
- Verify chainId matches the current chain
Cross-Chain Issues
- Ensure hash chaining is correct (order matters)
- Verify each chain’s proof contains the correct hashes
- Check chainId matches for each chain
- Ensure the same salt and deadline are used across chains
Witness Verification Problems
- Verify witness type string is properly formatted (must end with ’)’)
- Ensure witness data matches expected values
- Check EIP-712 type definitions are consistent across frontend and contracts