Skip to main content

Gasless ERC-20 Transfer with Permit2

Scenario: You want to move ERC-20 tokens on behalf of a user without requiring them to sign a separate approve() transaction first. Using Uniswap's Permit2, the user's MPC key signs a permit message authorizing the exact transfer. A relayer then executes the transfer on-chain — no prior approval required, and no ETH needed from the user.

What you will learn:

  • The problem with traditional ERC-20 approvals and how Permit2 solves it
  • What EIP-712 typed data signing is and why it's used here
  • How to sign arbitrary hashes with an MPC key using SIGN_WITH_KEY_SHARE
  • How to relay a Permit2 transfer so users pay no gas

Prerequisites: Basic understanding of ERC-20 tokens and TypeScript.


Background: Why Permit2?

Traditional ERC-20 approval flow (two transactions):

Transaction 1: User calls approve(spender, amount) ← user pays gas
Transaction 2: Spender calls transferFrom(user, recipient, amount) ← spender or user pays gas

Permit2 flow (one signed message + one transaction):

Off-chain: User signs a Permit2 message ← free (no gas, no transaction)
Transaction: Relayer calls permitTransferFrom(permit, details, owner, signature) ← relayer pays gas

Permit2 validates the signature on-chain, confirms the user authorized this exact transfer, then calls transferFrom itself. The user never sends a transaction — they only sign a message.

The one-time setup: Before using Permit2, the user's address must approve the Permit2 contract once with an unlimited allowance. After that single approval, every subsequent transfer uses a signed permit — no more approval transactions needed per transfer.


Section 1 — Project setup

import { WorkspaceClient, ComponentModule } from 'caller-sdk';
import { keccak256, encodeAbiParameters, encodePacked, toBytes } from 'viem';

const workspace = new WorkspaceClient({ apiKey: process.env.WR_API_KEY! });

const RPC_URL = 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY';
const KEY_ID = process.env.WR_KEY_ID!;

// Permit2 is deployed at the same address on every EVM chain
const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const USDC_DECIMALS = 6n;

Key concept — Permit2 canonical address: Uniswap deployed Permit2 using CREATE2 so it lands at the same address on every EVM chain. You don't need to configure a different address per chain.

Key concept — viem: viem is a TypeScript Ethereum library. We use it here only for hashing (pure computation, no RPC calls). This is how we build the EIP-712 hash that the MPC key will sign.


Section 2 — Deriving an EVM address from an MPC key

This helper turns a BIP-44 address index into an EVM address. It's used both to get the user's address (token holder) and the relayer's address (gas payer).

async function resolveEvmAddress(addressIndex: number) {
// 1. Build the BIP-44 derivation path (e.g. m/44'/60'/0'/0/N)
const { derivationPath } = await workspace
.call(ComponentModule.GET_EVM_DERIVATION_PATH, { addressIndex })
.promise();

// 2. Ask the MPC network for the public key at this path
const { publicKey } = await workspace
.call(ComponentModule.COMPUTE_PUBLIC_KEY, { keyId: KEY_ID, derivationPath })
.promise();

// 3. Hash the public key to get the EVM address
const { address } = await workspace
.call(ComponentModule.COMPUTE_EVM_ADDRESS, { publicKey })
.promise();

return { address, derivationPath };
}

Key concept — One root key, many addresses: A single MPC key generates a different address for every addressIndex. In your system, you might store { userId → addressIndex } so that user #7 always maps to index 7, the relayer is always index 0, and so on.

Key concept — No private key exposure: COMPUTE_PUBLIC_KEY talks to the MPC nodes, which each hold a share of the private key. The shares are combined mathematically to produce the public key — but the full private key is never reconstructed or transmitted anywhere. The same is true for signing later.


Section 3 — Building the EIP-712 hash for Permit2

Permit2 uses EIP-712 "typed structured data" signing. This is the Ethereum standard for signing human-readable structured messages (as opposed to signing a raw hash). The user signs a message that says: "I authorize Permit2 to transfer exactly X of token Y to spender Z, valid until deadline D."

This hash is computed entirely off-chain — no RPC call, no gas.

// These are constants defined in the Permit2 source code
const TOKEN_PERMISSIONS_TYPEHASH = keccak256(
toBytes('TokenPermissions(address token,uint256 amount)'),
);

const PERMIT_TRANSFER_FROM_TYPEHASH = keccak256(
toBytes(
'PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)' +
'TokenPermissions(address token,uint256 amount)',
),
);

function buildPermit2Hash({
chainId,
token,
amount,
spender,
nonce,
deadline,
}: {
chainId: number;
token: string;
amount: bigint;
spender: string;
nonce: bigint;
deadline: bigint;
}): string {
// Step A: EIP-712 domain separator — identifies the Permit2 contract on this chain
const domainSeparator = keccak256(
encodeAbiParameters(
[
{ type: 'bytes32' }, // type hash of the domain struct
{ type: 'bytes32' }, // hash of the name string "Permit2"
{ type: 'uint256' }, // chain ID (prevents this signature working on other chains)
{ type: 'address' }, // Permit2 contract address
],
[
keccak256(toBytes('EIP712Domain(string name,uint256 chainId,address verifyingContract)')),
keccak256(toBytes('Permit2')),
BigInt(chainId),
PERMIT2 as `0x${string}`,
],
),
);

// Step B: hash the inner TokenPermissions struct { token, amount }
const tokenPermissionsHash = keccak256(
encodeAbiParameters(
[{ type: 'bytes32' }, { type: 'address' }, { type: 'uint256' }],
[TOKEN_PERMISSIONS_TYPEHASH, token as `0x${string}`, amount],
),
);

// Step C: hash the outer PermitTransferFrom struct
const structHash = keccak256(
encodeAbiParameters(
[
{ type: 'bytes32' }, // type hash
{ type: 'bytes32' }, // tokenPermissionsHash (nested struct)
{ type: 'address' }, // spender (who will call permitTransferFrom)
{ type: 'uint256' }, // nonce (prevents this permit being used twice)
{ type: 'uint256' }, // deadline (permit expires after this timestamp)
],
[
PERMIT_TRANSFER_FROM_TYPEHASH,
tokenPermissionsHash,
spender as `0x${string}`,
nonce,
deadline,
],
),
);

// Step D: final EIP-712 hash — "\x19\x01" prefix + domain + struct
// The "\x19\x01" prefix marks this as an EIP-712 message (not a raw transaction)
return keccak256(
encodePacked(
['bytes2', 'bytes32', 'bytes32'],
['0x1901', domainSeparator, structHash],
),
);
}

Key concept — EIP-712 typed data: Before EIP-712, wallets showed raw hex hashes to users — unreadable and dangerous. EIP-712 defines a structured format so wallets can display "You are authorizing Permit2 to transfer 100 USDC on your behalf until 5pm" instead of a hex blob. The hash encodes all the details so an on-chain contract can verify the signature matches the exact parameters.

Key concept — Domain separator: The domain separator ties the signature to a specific contract on a specific chain. If you take this signature and replay it on Polygon, it won't work — the chain ID in the domain doesn't match.

Key concept — Nonce: Permit2 nonces aren't sequential — they're stored in a bitmap, so any unspent value works. Using Date.now() as a bigint gives a unique nonce for each permit. For production, track used nonces per user to guarantee no collisions under high concurrency.

Key concept — Deadline: The deadline is a Unix timestamp (seconds). The permit is invalid if submitted after this time. A 1-hour window is typical for flows where the user signs in your app and a relayer submits shortly after.


Section 4 — Signing the Permit2 hash with MPC

SIGN_WITH_KEY_SHARE accepts any 32-byte hash. It doesn't care whether the hash came from a transaction, an EIP-712 message, or something else — it just signs whatever you pass in.

async function signPermit2({
ownerPath, // derivation path of the token holder (user's key)
chainId,
token,
amount,
spender,
}: {
ownerPath: number[];
chainId: number;
token: string;
amount: bigint;
spender: string;
}) {
// Permit valid for 1 hour from now
const nonce = BigInt(Date.now());
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);

// Compute the EIP-712 hash locally (pure computation, no RPC)
const messageHash = buildPermit2Hash({ chainId, token, amount, spender, nonce, deadline });

// Sign with the user's MPC key — private key never leaves MPC nodes
const { signature } = await workspace
.call(ComponentModule.SIGN_WITH_KEY_SHARE, {
keyId: KEY_ID,
derivationPath: ownerPath,
messageHash, // the 32-byte EIP-712 hash we just computed
})
.promise();

console.log('Permit signed — private key never left MPC nodes');

return { signature, nonce, deadline };
}

Key concept — SIGN_WITH_KEY_SHARE accepts any hash: This is a powerful design. The component doesn't know or care if it's signing a transaction, a permit, or anything else. As long as you give it a 32-byte hash, it will produce a valid ECDSA signature. This lets you use MPC keys for any signing primitive that Ethereum supports.

Key concept — Separation of concerns: Notice there are two separate keys involved in this whole flow:

  • The user's key (their addressIndex) signs the Permit2 message — what they authorize
  • The relayer's key (index 0) signs the EVM transaction — who pays the gas

These are separate signing operations with separate keys.


Section 5 — Building and broadcasting the transfer

With the signature in hand, the relayer encodes the permitTransferFrom call and submits it.

const PERMIT_TRANSFER_FROM_ABI = [
{
type: 'function',
name: 'permitTransferFrom',
inputs: [
{
name: 'permit',
type: 'tuple',
components: [
{
name: 'permitted',
type: 'tuple',
components: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
},
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
{
name: 'transferDetails',
type: 'tuple',
components: [
{ name: 'to', type: 'address' },
{ name: 'requestedAmount', type: 'uint256' },
],
},
{ name: 'owner', type: 'address' },
{ name: 'signature', type: 'bytes' },
],
outputs: [],
stateMutability: 'nonpayable',
},
];

async function relayPermit2Transfer({
relayer, // { address, derivationPath }
ownerAddress, // user's address (token holder)
recipient, // where tokens go
token,
amount,
nonce,
deadline,
permitSignature,
}: {
relayer: { address: string; derivationPath: number[] };
ownerAddress: string;
recipient: string;
token: string;
amount: bigint;
nonce: bigint;
deadline: bigint;
permitSignature: string;
}) {
// Encode the permitTransferFrom(...) call
const { calldata } = await workspace
.call(ComponentModule.BUILD_EVM_CALLDATA, {
abi: PERMIT_TRANSFER_FROM_ABI,
args: [
JSON.stringify({
permitted: { token, amount: amount.toString() },
nonce: nonce.toString(),
deadline: deadline.toString(),
}),
JSON.stringify({ to: recipient, requestedAmount: amount.toString() }),
ownerAddress,
permitSignature,
],
})
.promise();

// Relayer builds the EVM transaction (from = relayer, to = Permit2 contract)
const { unsignedTransaction, serializedHash } = await workspace
.call(ComponentModule.BUILD_EVM_TRANSACTION, {
jsonRpcUrl: RPC_URL,
from: relayer.address, // relayer pays the gas
to: PERMIT2, // the Permit2 contract processes the transfer
value: '0',
calldata,
})
.promise();

// Relayer signs the EVM transaction with their own key
const { signature: relayerSignature } = await workspace
.call(ComponentModule.SIGN_WITH_KEY_SHARE, {
keyId: KEY_ID,
derivationPath: relayer.derivationPath,
messageHash: serializedHash,
})
.promise();

const { signedTransaction } = await workspace
.call(ComponentModule.SIGN_EVM_TRANSACTION, {
unsignedTransaction,
signature: relayerSignature,
})
.promise();

const { transactionHash } = await workspace
.call(ComponentModule.BROADCAST_EVM_TRANSACTION, {
jsonRpcUrl: RPC_URL,
signedTransaction,
})
.promise();

console.log(`Transfer submitted: ${transactionHash}`);

const receipt = await workspace
.call(ComponentModule.WAIT_FOR_EVM_TRANSACTION, {
jsonRpcUrl: RPC_URL,
transactionHash,
})
.promise();

console.log(`Confirmed in block ${receipt.blockNo}`);
return transactionHash;
}

Key concept — Permit2 is the middleman: When permitTransferFrom runs on-chain, Permit2:

  1. Verifies the EIP-712 signature matches the owner's address
  2. Checks the permit hasn't expired (deadline)
  3. Checks the nonce hasn't been used before
  4. Calls token.transferFrom(owner, transferDetails.to, requestedAmount) internally

The tokens move from owner to recipient, authenticated by the permit signature — not by the relayer.

Key concept — BUILD_EVM_CALLDATA with tuple arguments: Permit2's permitTransferFrom takes tuple structs. We pass them as JSON strings so BUILD_EVM_CALLDATA can encode them. The component handles the ABI encoding of nested structs automatically.


Section 6 — One-time Permit2 approval setup

Before using Permit2, the user's address must approve the Permit2 contract once. This is a standard approve() that gives Permit2 the ability to call transferFrom on their behalf. After this, all transfers use signed permits — no more approvals needed.

async function setupPermit2Approval(
userAddress: string,
userDerivationPath: number[],
token: string,
) {
const ERC20_APPROVE_ABI = [
{
type: 'function',
name: 'approve',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
stateMutability: 'nonpayable',
},
];

// Max uint256 — approve Permit2 to spend any amount of this token forever
const MAX_UINT256 = '115792089237316195423570985008687907853269984665640564039457584007913129639935';

const { calldata } = await workspace
.call(ComponentModule.BUILD_EVM_CALLDATA, {
abi: ERC20_APPROVE_ABI,
args: [PERMIT2, MAX_UINT256],
})
.promise();

const { unsignedTransaction, serializedHash } = await workspace
.call(ComponentModule.BUILD_EVM_TRANSACTION, {
jsonRpcUrl: RPC_URL,
from: userAddress, // the user's address sends this approval
to: token, // the token contract receives the approve() call
value: '0',
calldata,
})
.promise();

const { signature } = await workspace
.call(ComponentModule.SIGN_WITH_KEY_SHARE, {
keyId: KEY_ID,
derivationPath: userDerivationPath,
messageHash: serializedHash,
})
.promise();

const { signedTransaction } = await workspace
.call(ComponentModule.SIGN_EVM_TRANSACTION, { unsignedTransaction, signature })
.promise();

const { transactionHash } = await workspace
.call(ComponentModule.BROADCAST_EVM_TRANSACTION, {
jsonRpcUrl: RPC_URL,
signedTransaction,
})
.promise();

await workspace
.call(ComponentModule.WAIT_FOR_EVM_TRANSACTION, {
jsonRpcUrl: RPC_URL,
transactionHash,
})
.promise();

console.log(`Permit2 approved for ${token}: ${transactionHash}`);
}

Key concept — Why unlimited approval? The standard Permit2 pattern is to approve the Permit2 contract for type(uint256).max — the maximum possible value. This is intentional: Permit2 is the trusted middleman. Every individual transfer is still gated by a signed permit with an exact amount and deadline, so unlimited approval to Permit2 doesn't mean unlimited access to your tokens.

When to run this: Call setupPermit2Approval once per (user, token) pair. Store a flag in your database so you don't call it again. You can also check the on-chain allowance with allowance(userAddress, PERMIT2) before deciding to run it.


Final section — Putting it all together

Here's the complete orchestrator that ties every section together:

async function permit2Transfer({
userId, // user's BIP-44 address index (maps to their token-holding address)
recipient, // destination address for the tokens
amountUsdc, // human-readable amount, e.g. 50 for 50 USDC
}: {
userId: number;
recipient: string;
amountUsdc: number;
}) {
const amount = BigInt(amountUsdc) * 10n ** USDC_DECIMALS; // convert to 6-decimal units

// 1. Resolve user address and relayer address in parallel (saves time)
const [user, relayer, { chainId }] = await Promise.all([
resolveEvmAddress(userId), // the token holder — signs the permit
resolveEvmAddress(0), // the relayer at index 0 — pays gas
workspace.call(ComponentModule.GET_EVM_CHAIN_ID, { jsonRpcUrl: RPC_URL }).promise(),
]);

console.log(`User (token holder): ${user.address}`);
console.log(`Relayer (gas payer): ${relayer.address}`);
console.log(`Chain ID: ${chainId}`);

// 2. Sign the Permit2 message with the user's MPC key
// This is an off-chain operation — no transaction, no gas
const { signature: permitSignature, nonce, deadline } = await signPermit2({
ownerPath: user.derivationPath,
chainId,
token: USDC,
amount,
spender: relayer.address, // the relayer will call permitTransferFrom
});

// 3. Relayer submits the transfer on-chain using the signed permit
return relayPermit2Transfer({
relayer,
ownerAddress: user.address,
recipient,
token: USDC,
amount,
nonce,
deadline,
permitSignature,
});
}

// ─── Usage ───────────────────────────────────────────────────────────────────

// One-time setup: approve Permit2 for the user's USDC (run once per user per token)
const user = await resolveEvmAddress(7);
await setupPermit2Approval(user.address, user.derivationPath, USDC);

// Transfer 50 USDC from user #7 to a recipient
const txHash = await permit2Transfer({
userId: 7,
recipient: '0xRecipientAddress...',
amountUsdc: 50,
});

console.log(`Done! Transaction: ${txHash}`);

How everything connects:

  1. resolveEvmAddress(userId) → user's address (token holder, signs the permit)
  2. resolveEvmAddress(0) → relayer's address (pays gas, calls permitTransferFrom)
  3. signPermit2(...) → user's MPC key signs the EIP-712 hash (free, off-chain)
  4. relayPermit2Transfer(...) → relayer encodes + submits the on-chain transaction

Full flow diagram

permit2Transfer(userId, recipient, amountUsdc)

├── resolveEvmAddress(userId) ── GET_EVM_DERIVATION_PATH
│ [user address + path] COMPUTE_PUBLIC_KEY
│ COMPUTE_EVM_ADDRESS

├── resolveEvmAddress(0) ── GET_EVM_DERIVATION_PATH
│ [relayer address + path] COMPUTE_PUBLIC_KEY
│ COMPUTE_EVM_ADDRESS

├── GET_EVM_CHAIN_ID ── reads chain ID from RPC

├── signPermit2(...)
│ │ ── buildPermit2Hash(...) [pure, no RPC]
│ └── SIGN_WITH_KEY_SHARE ── user's key signs EIP-712 hash
│ [permitSignature]

└── relayPermit2Transfer(...)
├── BUILD_EVM_CALLDATA ── encode permitTransferFrom(...)
├── BUILD_EVM_TRANSACTION ── relayer is the sender (pays gas)
├── SIGN_WITH_KEY_SHARE ── relayer's key signs the EVM tx
├── SIGN_EVM_TRANSACTION
├── BROADCAST_EVM_TRANSACTION
└── WAIT_FOR_EVM_TRANSACTION

Common questions

What if the permit expires before the relayer submits? The Permit2 contract will revert with an expired deadline. Set a generous deadline (e.g. 1 hour) and submit promptly after signing. If the relayer is down, the user can sign a new permit with a fresh nonce.

Can the same permit be used twice? No. Once permitTransferFrom succeeds, the nonce is marked as used in Permit2's bitmap. Replaying the same permit reverts.

Does the user need ETH? No. The user only signs a message (free). The relayer submits the transaction and pays gas. The user only needs to hold the ERC-20 token being transferred.

Which chains does Permit2 support? Permit2 is deployed on Ethereum, Polygon, Arbitrum, Optimism, Base, Avalanche, and many others — always at 0x000000000022D473030F116dDEE9F6B43aC78BA3.

Batch transfers

Permit2 supports permitBatchTransferFrom — one signature authorizes transfers of multiple tokens at once. Useful for batch settlements or multi-token swaps.

Relayer needs ETH

The relayer EOA at index 0 pays gas. Monitor its ETH balance and top it up before it runs low. An empty relayer blocks all pending transfers.