Skip to main content

Permit3 Developer Guide: Install to First Approval

Permit3 developer guide: install contracts, sign EIP-712 cross-chain permits, submit Merkle proofs, add ERC-7702 and witness functionality end-to-end.

Written by Eco
Updated today

Permit3 is an open-source token approval protocol that enables multichain token permission with a single signature and full gas abstraction. It eliminates the need for redundant approvals and native gas tokens, enabling developers to simplify cross-chain token operations using a single user signature. This guide walks through installation, signing, and submitting your first cross-chain permit end-to-end, using the official Solidity contracts and the patterns documented in the Permit3 repository.

By the end, you will have a working spender contract that accepts Permit3 signatures, an EIP-712 signing flow in your client code, and a cross-chain Merkle proof submitted to authorize a token transfer. You will also know where to go next in the Eco docs and the GitHub repo for deeper topics like multi-token permits and ERC-7702 integration. If you need the conceptual overview first, read our full Permit3 guide.

Prerequisites

  • A Solidity toolchain. This guide uses Foundry; Hardhat works the same way with minor syntax shifts.

  • A JavaScript or TypeScript client environment. Examples use viem.

  • Access to RPC endpoints for at least two EVM chains for the cross-chain flow. Any chain where Permit3 is deployed works.

  • Baseline familiarity with EIP-2612 permits or Permit2 helps but is not required.

Install the Contracts

The Permit3 contracts are distributed as an npm package and as a Foundry library. Pick whichever matches your toolchain.

npm / Hardhat:

npm install @permit3/contracts

Foundry:

forge install eco/permit3

Both give you access to the core interfaces and the deployed address constants. The canonical Permit3 contract lives at 0xec00030c0000245e27d1521cc2ee88f071c2ae34 on every supported chain. This address is deterministic via the ERC-2470 Singleton Factory, so you do not need chain-specific address maps in your code.

Your First Single-Chain Permit

The cross-chain flow is built on top of a single-chain flow that is mechanically similar to Permit2. Getting the single-chain path working first is the fastest way to verify your integration.

The spender contract

// SPDX-License-Identifier: MITpragma solidity ^0.8.20;import {IPermit3} from "@permit3/contracts/interfaces/IPermit3.sol";contract MySpender {    IPermit3 public constant PERMIT3 =        IPermit3(0xec00030c0000245e27d1521cc2ee88f071c2ae34);    function pullTokens(        address owner,        address token,        uint160 amount,        uint48 deadline,        bytes calldata signature    ) external {        // Permit2-compatible path: same interface, new contract.        PERMIT3.permit(owner, /* permitSingle */, signature);        PERMIT3.transferFrom(owner, msg.sender, amount, token);    }}

The permit and transferFrom functions have the same signatures as Permit2, so spender contracts that already integrate Permit2 can migrate by swapping the address constant. Nothing else in your contract needs to change for the baseline case.

Signing the permit client-side

The client builds an EIP-712 typed-data object and asks the user's wallet to sign it. In viem:

import { createWalletClient, http } from 'viem'import { mainnet } from 'viem/chains'const PERMIT3 = '0xec00030c0000245e27d1521cc2ee88f071c2ae34'const domain = {  name: 'Permit3',  version: '1',  chainId: 1,          // ALWAYS 1 regardless of execution chain  verifyingContract: PERMIT3,}const types = {  PermitSingle: [    { name: 'details', type: 'PermitDetails' },    { name: 'spender', type: 'address' },    { name: 'sigDeadline', type: 'uint256' },  ],  PermitDetails: [    { name: 'token', type: 'address' },    { name: 'amount', type: 'uint160' },    { name: 'expiration', type: 'uint48' },    { name: 'nonce', type: 'uint48' },  ],}const message = {  details: {    token: USDC_ADDRESS,    amount: 1_000_000n,        // 1 USDC, 6 decimals    expiration: Math.floor(Date.now() / 1000) + 3600,    nonce: 0,  },  spender: SPENDER_ADDRESS,  sigDeadline: Math.floor(Date.now() / 1000) + 600,}const signature = await wallet.signTypedData({  account: userAddress,  domain,  types,  primaryType: 'PermitSingle',  message,})

The critical detail here: the EIP-712 domain uses chainId: 1 unconditionally. This is the Permit3 cross-chain compatibility hack. By fixing the chain id in the domain, the signature is valid for Permit3 execution on any chain, not just Ethereum. The per-chain scoping happens inside the Merkle structure rather than in the domain separator.

Submitting the permit

await wallet.writeContract({  address: SPENDER_ADDRESS,  abi: spenderAbi,  functionName: 'pullTokens',  args: [userAddress, USDC_ADDRESS, amount, deadline, signature],})

That is the full single-chain loop. If your spender contract works with a single permit, you have the baseline integration. Now for the cross-chain path.

Your First Cross-Chain Permit

The cross-chain flow builds an Unbalanced Merkle Tree whose leaves are per-chain permissions. The user signs the Merkle root once. The spender on each chain receives the signature plus a Merkle proof for the relevant leaf.

Build the permission set

import { buildMerkleTree } from '@permit3/sdk'const leaves = [  {    chainId: 1,          // Ethereum    permitSingle: {      details: { token: USDC_ETH, amount: 1_000_000n, expiration: ..., nonce: 0 },      spender: ETH_SPENDER,      sigDeadline: ...,    },  },  {    chainId: 8453,       // Base    permitSingle: {      details: { token: USDC_BASE, amount: 1_000_000n, expiration: ..., nonce: 0 },      spender: BASE_SPENDER,      sigDeadline: ...,    },  },  {    chainId: 10,         // Optimism    permitSingle: {      details: { token: USDC_OP, amount: 1_000_000n, expiration: ..., nonce: 0 },      spender: OP_SPENDER,      sigDeadline: ...,    },  },]const tree = buildMerkleTree(leaves)const root = tree.root

Sign the root

Now the user signs the Merkle root wrapped in the cross-chain EIP-712 type:

const domain = { name: 'Permit3', version: '1', chainId: 1, verifyingContract: PERMIT3 }const types = {  CrossChainPermit: [    { name: 'owner', type: 'address' },    { name: 'salt', type: 'bytes32' },    { name: 'deadline', type: 'uint256' },    { name: 'merkleRoot', type: 'bytes32' },  ],}const signature = await wallet.signTypedData({  account: userAddress,  domain,  types,  primaryType: 'CrossChainPermit',  message: {    owner: userAddress,    salt: crypto.randomBytes(32),    deadline: ...,    merkleRoot: root,  },})

Execute on each chain

For every chain where you want to execute, pull the leaf's Merkle proof from the tree and submit it along with the signature to the Permit3 contract:

async function executeOnChain(chainClient, leaf) {  const proof = tree.getProof(leaf)  await chainClient.writeContract({    address: PERMIT3,    abi: permit3Abi,    functionName: 'permit',    args: [      userAddress,      salt,      deadline,      timestamp,      [leaf.permitSingle],   // the AllowanceOrTransfer array      proof,      signature,    ],  })}await Promise.all([  executeOnChain(ethereumClient, leaves[0]),  executeOnChain(baseClient, leaves[1]),  executeOnChain(optimismClient, leaves[2]),])

Each call submits to the Permit3 contract at the canonical address on its respective chain, verifies the Merkle proof against the signed root, and executes the permit. The user paid zero native gas on any chain. The relayer or solver submitting the transaction paid the gas on each chain.

Multi-Token and Multi-Permit Batches

Permit3 bundles multiple permits in the same signature. The AllowanceOrTransfer array passed to permit can contain any mix of permit modes and token types. A single cross-chain signature can, for example:

  • Grant a DEX allowance on Ethereum for 1,000 USDC.

  • Transfer 500 USDC directly on Base to a payment processor.

  • Lock an existing allowance on Optimism.

  • Grant an NFT marketplace authority on Arbitrum to move a specific ERC-721.

The signing domain, the Merkle tree construction, and the verification path are the same as the single-token cross-chain flow. The only difference is that each leaf's AllowanceOrTransfer array carries richer payload.

ERC-7702 Integration

For teams that want to combine permit and execution in a single transaction, Permit3 ships native EIP-7702 support. A 7702-delegated EOA can invoke Permit3 as part of its delegated code, granting permission and executing the downstream transfer in one transaction with no upfront smart-account deployment.

The pattern looks like:

// 7702 delegation authorizes the Permit3 helper contract// to act on behalf of the EOA for this transaction.const tx = {  to: USER_EOA,  data: encodeFunctionData({    abi: permit3HelperAbi,    functionName: 'permitAndExecute',    args: [permits, signature, executionCalldata],  }),}

Full examples live in the permit3 repo under docs/examples/erc7702-example.md. If you are building account-abstraction flows more broadly, our account abstraction guide covers the context.

Witness Functionality

Witness data lets the signer attach arbitrary context to a permit that the spender contract can verify onchain. A common pattern: the witness encodes the specific trade parameters a DEX agreed to, and the spender verifies those parameters match the incoming call before executing.

const witness = {  orderId: keccak256(...),  limitPrice: ...,  recipient: ...,}const signature = await signPermit3WithWitness(permits, witness)// In the spender:function executeWithWitness(permit, witness, signature) {  require(witness.recipient == msg.sender);  require(currentPrice <= witness.limitPrice);  PERMIT3.permitWitnessTransferFrom(..., witness, signature);}

Witness adds a layer of onchain verification that prevents a permit from being used outside its intended context. Especially useful for intent-based systems where the permit authorizes only a specific solver quote.

Testing Your Integration

A solid test harness for Permit3 integrations covers five cases:

  1. Single-chain Permit2-compatible permit: confirms your spender accepts the baseline interface.

  2. Cross-chain permit with valid Merkle proof: confirms the cross-chain verification path works.

  3. Cross-chain permit with invalid proof: confirms the contract rejects bad proofs.

  4. Expired permit: confirms deadline enforcement works on both the permit's expiration and the outer sigDeadline.

  5. Replay attempt: confirms a second submission with the same nonce and salt is rejected.

Foundry fuzzing works well for the replay and boundary tests. The Permit3 repo includes its own test suite as a reference.

Deployment and Production Concerns

A few operational notes for teams taking Permit3 to production:

  • Security audit: Permit3 itself is audited by Cantina; the report is in the GitHub repo. Your spender contract is a separate audit target and should be reviewed independently.

  • Indexer requirements: if you need to track permits granted to your spender, index the Permit events emitted by the Permit3 contract. They are emitted on every chain.

  • Signer management: agentic systems minting permits at volume need a signer architecture that avoids nonce collisions. The salt-based Permit3 nonce removes the single-threaded constraint from Permit2, but you still want to track consumed (salt, nonce) pairs.

  • Monitoring: expired permits do not throw until execution, so monitor your relayer queue for permits approaching their sigDeadline and flush them before expiry.

What Permit3 Pairs Well With

Permit3 is a permission primitive. For a complete cross-chain stablecoin flow, it pairs naturally with execution and settlement primitives:

For a side-by-side with Permit2 or to understand the multichain upgrade rationale, see Permit3 vs Permit2 and Permit3 gas abstraction.

FAQ

Do I need to deploy anything to use Permit3?

No. The Permit3 contract is already deployed at 0xec00030c0000245e27d1521cc2ee88f071c2ae34 on every supported chain. You only deploy your own spender contract, which references Permit3 as a constant.

What is the chainId in the EIP-712 domain?

Always 1. Permit3 fixes the domain chainId to 1 so that a single signature is valid for execution on any chain. The per-chain scoping lives inside the Merkle tree, not in the domain separator.

How do I avoid nonce collisions when minting permits in parallel?

Use a unique salt per signing call. Permit3's nonce is keyed on the (signer, salt) pair, so as long as every salt is fresh, parallel permits do not collide. A random 32-byte salt is the standard pattern.

Can I combine Permit3 with ERC-4337?

Yes. An ERC-4337 smart account can sign a Permit3 message through its signing key, and the resulting UserOperation can include the Permit3 execution. A paymaster can sponsor the gas separately. The two standards are complementary.

What SDK packages should I use?

The contracts come from @permit3/contracts. For client-side signing helpers and Merkle tree construction, check the permit3 repository for the latest SDK modules; JS/TS wrappers are published alongside the contracts.

Did this answer your question?