Skip to content

feature/docs: gasless cross-asset transfer on Arc Testnet — EIP-2612 permit + sequential relayer txs + Multicall3From msg.sender trap #164

@osr21

Description

@osr21

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] = 0revert.

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:

Pair API Cache
USD/EUR Frankfurter 5 min
BTC/USD CoinGecko 5 min
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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions