Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions