Summary
While building gasless cross-asset payments on Arc Testnet (Arc Swap), we discovered a critical pitfall with Multicall3From that will affect anyone trying to do batched ERC-20 transferFrom from a relayer wallet.
The Multicall3From msg.sender Trap
Multicall3From address: 0x522fAf9A91c41c443c66765030741e4AaCe147D0
The initial design batched three calls — permit(), transferFrom(), and transfer() — into a single Multicall3From call. Every payment reverted.
Root cause:
Inside a Multicall3From subcall, msg.sender is the Multicall3From contract address, not the original caller (EOA or relayer wallet).
ERC-20's transferFrom checks:
require(allowance[owner][msg.sender] >= amount);
The EIP-2612 permit had set allowance[owner][relayerWallet] = amount.
Inside the Multicall3From subcall, msg.sender = 0x522fAf9A91c41c443c66765030741e4AaCe147D0.
So the check reads allowance[owner][Multicall3From] = 0 → revert.
This is not Arc-specific — it's correct EVM behaviour (CALL sets msg.sender to the caller), but it's easy to miss when Multicall3From is described as letting you "call as" a specific address.
Fix: submit three sequential direct transactions from the relayer wallet. Each presents msg.sender = relayerWallet and the permit allowance matches.
Working Gasless Cross-Asset Transfer Pattern
The full pattern for a relayer-pays-gas, user-signs-only cross-asset payment on Arc Testnet:
Step 1 — Frontend: collect permit signature (zero gas for user)
// Read current nonce
const nonce = await publicClient.readContract({
address: USDC_ADDRESS,
abi: erc20PermitAbi,
functionName: "nonces",
args: [userAddress],
});
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);
// EIP-2612 typed data — Arc Testnet domain
const signature = await walletClient.signTypedData({
domain: {
name: "USDC", // must match token's EIP-712 name
version: "2", // "2" for both USDC and EURC on Arc Testnet
chainId: 5042002n,
verifyingContract: USDC_ADDRESS,
},
types: {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
message: { owner: userAddress, spender: RELAY_WALLET, value: amountIn, nonce, deadline },
});
// Send { deadline, v, r, s } to backend — user is done
Step 2 — Backend: three sequential direct transactions (relayer pays all gas)
// Always pass explicit gas — eth_estimateGas is unreliable on Arc Testnet
const GAS = {
permit: 100_000n,
transferFrom: 100_000n,
transfer: 100_000n,
memo: 100_000n,
};
// 1. Permit — sets allowance[user][relayerWallet] = amountIn
const permitHash = await walletClient.writeContract({
address: tokenIn.address,
abi: erc20PermitAbi,
functionName: "permit",
args: [userAddress, RELAY_WALLET, amountIn, deadline, v, r, s],
gas: GAS.permit,
});
const permitReceipt = await publicClient.waitForTransactionReceipt({ hash: permitHash });
if (permitReceipt.status === "reverted") throw new Error("permit reverted");
// ↑ Arc Testnet receipts do NOT auto-throw on revert — always check status manually
// 2. TransferFrom — pulls tokenIn from user to relayer
const tfHash = await walletClient.writeContract({
address: tokenIn.address,
abi: erc20Abi,
functionName: "transferFrom",
args: [userAddress, RELAY_WALLET, amountIn],
gas: GAS.transferFrom,
});
const tfReceipt = await publicClient.waitForTransactionReceipt({ hash: tfHash });
if (tfReceipt.status === "reverted") throw new Error("transferFrom reverted");
// 3. Transfer — sends tokenOut from relayer to recipient
const amountOut = computeAmountOut(amountIn, tokenIn, tokenOut); // apply rate + fee
const transferHash = await walletClient.writeContract({
address: tokenOut.address,
abi: erc20Abi,
functionName: "transfer",
args: [recipientAddress, amountOut],
gas: GAS.transfer,
});
const transferReceipt = await publicClient.waitForTransactionReceipt({ hash: transferHash });
// Refund if outbound fails after inbound succeeded
if (transferReceipt.status === "reverted") {
await walletClient.writeContract({
address: tokenIn.address,
abi: erc20Abi,
functionName: "transfer",
args: [userAddress, amountIn],
gas: GAS.transfer,
});
throw new Error("outbound transfer reverted — user refunded");
}
// 4. Optional: on-chain memo via Arc v0.7.2 Memo precompile (fire-and-forget)
// Precompile address: 0x5294E9927c3306DcBaDb03fe70b92e01cCede505
// Activated on Arc Testnet 2026-06-18
if (memo) {
walletClient.writeContract({
address: "0x5294E9927c3306DcBaDb03fe70b92e01cCede505",
abi: [{ name: "memo", type: "function", inputs: [{ name: "data", type: "bytes" }], outputs: [] }],
functionName: "memo",
args: [toHex(Buffer.from(memo, "utf8"))],
gas: GAS.memo,
}).catch(err => logger.error({ err }, "memo precompile failed (non-fatal)"));
}
Rate Calculation (Circle Kit swap unusable — see #88)
Circle Kit's estimateSwap() returns incorrect rates on Arc Testnet (~17 EURC/USDC vs ~0.91 real rate) and swap() fails with ONCHAIN_SIMULATION_FAILED in both directions. We use real market rates instead:
const effectiveAmountIn = amountIn * (1 - FEE_RATE); // 0.3% platform fee
const amountOut = effectiveAmountIn * marketRate;
Arc Testnet EVM Quirks (Summary)
| Quirk |
Workaround |
eth_estimateGas unreliable |
Always pass explicit gas values on all writeContract calls |
| Receipts don't auto-throw on revert |
Always check receipt.status === "reverted" manually |
Multicall3From subcalls use contract msg.sender |
Use sequential direct transactions from relayer wallet instead |
UniswapV2Factory.createPair() needs ~5M gas |
Pass gas: 5_000_000n explicitly; standard 500k will revert |
Reference Implementation
Full working implementation: osr21/arc-swap
- Backend route:
artifacts/api-server/src/routes/pay.ts
- Frontend hook:
artifacts/arc-swap/src/hooks/use-kit-pay.ts
- Architecture doc:
docs/send-architecture.md
Happy to answer questions.
Summary
While building gasless cross-asset payments on Arc Testnet (Arc Swap), we discovered a critical pitfall with Multicall3From that will affect anyone trying to do batched ERC-20
transferFromfrom a relayer wallet.The Multicall3From
msg.senderTrapMulticall3From address:
0x522fAf9A91c41c443c66765030741e4AaCe147D0The initial design batched three calls —
permit(),transferFrom(), andtransfer()— into a single Multicall3From call. Every payment reverted.Root cause:
Inside a Multicall3From subcall,
msg.senderis the Multicall3From contract address, not the original caller (EOA or relayer wallet).ERC-20's
transferFromchecks:The EIP-2612 permit had set
allowance[owner][relayerWallet] = amount.Inside the Multicall3From subcall,
msg.sender = 0x522fAf9A91c41c443c66765030741e4AaCe147D0.So the check reads
allowance[owner][Multicall3From] = 0→ revert.This is not Arc-specific — it's correct EVM behaviour (CALL sets msg.sender to the caller), but it's easy to miss when Multicall3From is described as letting you "call as" a specific address.
Fix: submit three sequential direct transactions from the relayer wallet. Each presents
msg.sender = relayerWalletand the permit allowance matches.Working Gasless Cross-Asset Transfer Pattern
The full pattern for a relayer-pays-gas, user-signs-only cross-asset payment on Arc Testnet:
Step 1 — Frontend: collect permit signature (zero gas for user)
Step 2 — Backend: three sequential direct transactions (relayer pays all gas)
Rate Calculation (Circle Kit swap unusable — see #88)
Circle Kit's
estimateSwap()returns incorrect rates on Arc Testnet (~17 EURC/USDC vs ~0.91 real rate) andswap()fails withONCHAIN_SIMULATION_FAILEDin both directions. We use real market rates instead:Arc Testnet EVM Quirks (Summary)
eth_estimateGasunreliablegasvalues on allwriteContractcallsreceipt.status === "reverted"manuallymsg.senderUniswapV2Factory.createPair()needs ~5M gasgas: 5_000_000nexplicitly; standard 500k will revertReference Implementation
Full working implementation: osr21/arc-swap
artifacts/api-server/src/routes/pay.tsartifacts/arc-swap/src/hooks/use-kit-pay.tsdocs/send-architecture.mdHappy to answer questions.