Skip to content

fix(docs,test): clarify Memo ABI and lock eth_estimateGas regression#198

Open
daveynfts wants to merge 1 commit into
circlefin:mainfrom
daveynfts:fix/memo-estimate-gas-abi-docs-189
Open

fix(docs,test): clarify Memo ABI and lock eth_estimateGas regression#198
daveynfts wants to merge 1 commit into
circlefin:mainfrom
daveynfts:fix/memo-estimate-gas-abi-docs-189

Conversation

@daveynfts

Copy link
Copy Markdown

Summary

Closes #189.

Root cause

The original report used a non-existent entry point:

callWithMemo(address,bytes,bytes32,string)

The deployed Memo contract only implements:

function memo(address target, bytes calldata data, bytes32 memoId, bytes calldata memoData) external;

A wrong 4-byte selector reverts with empty return data. That makes eth_estimateGas / eth_call fail with a generic execution reverted, which is easy to misread as a CallFrom or node simulation bug. With the correct ABI and an EOA as from, estimation and eth_call already succeed (confirmed on Arc Testnet by maintainers and covered by this PR's localdev tests).

This is not a protocol change - it documents the canonical ABI, hardens NatSpec, and adds regression tests so the pitfall cannot regress silently.

Changes

Area Change
docs/transaction-memos.md New developer guide: ABI, estimation, pitfalls, cast/viem examples
README.md / contracts/README.md Links to the guide
IMemo.sol / Memo.sol NatSpec: no callWithMemo; estimation notes
tests/localdev/subcall.test.ts Regression: estimateGas, eth_call, wrong selector, estimated gas sufficient on-chain
contracts/test/memo/Memo.t.sol Pure selector mismatch guard

Test plan

# Unit (Foundry) - no node required
forge test --match-contract MemoTest -vv

# Localdev (requires running local stack)
npx hardhat test ./tests/localdev/subcall.test.ts --network localdev

Expected:

  • eth_estimateGas succeeds for memo(address,bytes,bytes32,bytes)
  • eth_call succeeds for the same calldata
  • Outdated callWithMemo(...string) estimate/call is rejected
  • Estimated gas is enough to land a memo USDC transfer on-chain
  • callWithMemo selector != memo selector (Foundry)

Out of scope

Broader eth_estimateGas flakiness on unrelated USDC/Uniswap paths (if still present) is separate from this Memo ABI pitfall.

@osr21

osr21 commented Jul 9, 2026

Copy link
Copy Markdown

Good fix — the wrong-selector → empty-revert → misread-as-node-bug chain is exactly the kind of thing that eats hours before the ABI mismatch becomes obvious. The docs and regression tests are a clean solution.

A couple of Arc-specific notes from running this pattern in production on Arc Testnet (where USDC is the native gas token at 0x3600000000000000000000000000000000000000):

1. Dual balance deduction when target is USDC

On Arc, the native gas token and USDC ERC-20 share the same address and the same balance. A call like:

memo(0x3600…, transfer(recipient, amount), memoId, memoData)

deducts from balanceOf(sender) twice: once as ERC-20 transfer amount, and again as the gas fee (paid in native USDC). On Ethereum or Base this can't happen (ETH and USDC are separate), but on Arc the sender must hold amount + gas_cost in USDC and both draws come from the same slot. Worth a note somewhere in the "Example" section for Arc-specific integrators, since gas estimation will return a value that assumes the ERC-20 balance is not depleted by the gas deduction — but with a tight balance the on-chain tx can still fail even when estimation succeeds.

2. Decoding MemoFailed(bytes) in viem when the inner USDC op fails

If the USDC transfer() reverts (e.g. insufficient balance after accounting for gas), the outer tx reverts with MemoFailed(bytes returnData). In viem 2.x, decoding the inner reason requires two passes:

import { decodeErrorResult } from 'viem'

try {
  await walletClient.writeContract({ /* memo(...) */ })
} catch (err) {
  // outer: MemoFailed(bytes)
  const outer = decodeErrorResult({ abi: memoAbi, data: err.data })
  // outer.args[0] is the raw returnData from the inner USDC call
  const inner = decodeErrorResult({ abi: erc20Abi, data: outer.args[0] })
  console.log('Inner revert:', inner)
}

Without that second decode step the error surfaces as an opaque hex blob, which looks like a precompile issue rather than an ERC-20 revert. Came up building an invoice-repayment flow where the debtor's USDC balance was tight and the gas cost pushed them under the required amount.

3. Minor — viem example: toBytes vs toHex for memoData

toBytes('Invoice INV-2026-001') returns a Uint8Array, which viem's writeContract encodes correctly as bytes. When constructing raw calldata with encodeFunctionData (e.g. to feed to estimateGas or eth_call manually), use toHex('Invoice INV-2026-001') instead — Uint8Array is not accepted by encodeFunctionData's args. Both are fine inside writeContract/sendTransaction but it's a common trip-up when switching between the two APIs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

callWithMemo reverts during gas estimation (eth_call) but succeeds with direct signed transaction

2 participants