Skip to content

docs: add CCTP receiveMessage fee guidance#184

Open
westkite1201 wants to merge 3 commits into
circlefin:mainfrom
westkite1201:docs/153-cctp-receive-message-fees
Open

docs: add CCTP receiveMessage fee guidance#184
westkite1201 wants to merge 3 commits into
circlefin:mainfrom
westkite1201:docs/153-cctp-receive-message-fees

Conversation

@westkite1201

Copy link
Copy Markdown

Summary

Adds a CCTP v2 troubleshooting note for MessageTransmitterV2.receiveMessage() relays from Arc Testnet to destination chains.

The doc explains why viem/MetaMask submissions can fail with:

RPC submit: max fee per gas less than block base fee

and shows how to estimate fees on the destination chain immediately before receiveMessage(), then add maxFeePerGas headroom before calling walletClient.writeContract().

Fixes #153.

Changes

  • Adds docs/cctp-v2-receive-message.md
  • Documents the stale EIP-1559 fee-estimate failure mode
  • Adds a viem helper example for destination-chain fee estimation and maxFeePerGas padding
  • Includes an integration checklist for relayers

Duplicate check

Testing

npx prettier --check docs/cctp-v2-receive-message.md
All matched files use Prettier code style.

git diff --check origin/main...HEAD
passed

@osr21

osr21 commented Jun 21, 2026

Copy link
Copy Markdown

Thanks for documenting this — the fee-race failure mode on receiveMessage() is a genuine pain point and a 130%-bumped estimate-then-submit pattern is the right call.

A few technical points worth addressing before merge:


1. maxFeePerGas ?? fees.gasPrice assigns the wrong type on legacy chains

const maxFeePerGas = fees.maxFeePerGas ?? fees.gasPrice;
// then always returns: { maxFeePerGas: bumpFee(maxFeePerGas) }

On a legacy-gas chain, estimateFeesPerGas() returns { gasPrice: ... } with maxFeePerGas == null. The fallback stores the gasPrice value in a variable named maxFeePerGas, then spreads it as the EIP-1559 field. Many nodes reject transactions that carry maxFeePerGas on a chain that hasn't enabled EIP-1559 — so the bump actually makes things worse on those chains. Consider branching:

if (fees.maxFeePerGas != null) {
  return {
    maxFeePerGas: bumpFee(fees.maxFeePerGas),
    ...(fees.maxPriorityFeePerGas != null
      ? { maxPriorityFeePerGas: fees.maxPriorityFeePerGas }
      : {}),
  };
} else if (fees.gasPrice != null) {
  return { gasPrice: bumpFee(fees.gasPrice) };  // legacy chain
} else {
  return {};
}

2. Arc Testnet as destination is a gap in the guide

The doc covers the case where Arc is the source (burns land on Sepolia/Base/Fuji). But the reverse direction — Sepolia → Arc Testnet — hits a different failure mode: Arc uses a legacy gas model, eth_gasPrice is documented to return stale values (see arc-node#87), and Arc nodes reject transactions that carry maxFeePerGas/maxPriorityFeePerGas fields. The mitigation is the gasPrice branch above plus the same 130% bump. Worth adding a brief note specifically for Arc-as-destination since it's the most common cross-direction for this testnet.


3. maxPriorityFeePerGas is not bumped

The tip is forwarded as-is from the estimate. On low-tip chains this is harmless, but if the priority fee market spikes concurrently with the base fee, a stale tip can also cause the tx to sit unconfirmed. A modest bump (e.g. 110-120%) on the priority fee as well would be more robust and is consistent with the "add headroom" philosophy of the rest of the guide.


Minor note: the ceiling-division in bumpFee ((value * bps + BASIS_POINTS - 1n) / BASIS_POINTS) is correct and the right choice for a fee floor — nice.

@osr21

osr21 commented Jun 21, 2026

Copy link
Copy Markdown

The update addresses all three points from the previous review — the EIP-1559/legacy branching is now correct, the Arc-as-destination section is a solid addition, and the priority-fee bump is a good defensive change. A few remaining observations on the updated code:


1. return {} is a silent no-op — the protection disappears without the caller knowing

When neither fees.maxFeePerGas nor fees.gasPrice is set the helper returns {}. Spreading that into writeContract leaves it with no fee override, so viem runs its own internal fee estimation — the exact stale-fee path the helper was supposed to prevent. A caller has no way to detect this; the relay appears to use bumped fees but actually uses none.

Consider throwing instead of returning silently:

// instead of: return {};
throw new Error(
  `estimateFeesPerGas returned no usable fee fields on ${destinationChain.name} — cannot construct safe fee overrides`
);

This forces the caller to handle the exceptional case explicitly (e.g., retry after a short delay) rather than silently regressing to the unsafe default path.


2. Arc gasPrice staleness is not limited to rapid transaction sequences

The new section reads:

Arc Testnet gas estimates can also be stale during rapid transaction sequences

Issue #87 affects individual transactions too — the Arc RPC's gasPrice response can lag even when no prior transactions have been submitted recently. receiveMessage is typically a single standalone call, so the "rapid sequence" framing undersells the risk for this specific use case. Suggest:

Arc Testnet gas estimates can be stale even for a single transaction; re-estimate gasPrice immediately before submission and apply the same 130% headroom.


3. estimateFeesPerGas() is not wrapped — throws propagate to the caller

If the destination RPC is temporarily unreachable, estimateFeesPerGas() throws and the relay fails hard. For a checklist note or a small addition to the helper:

// at the top of estimateReceiveMessageFees:
let fees: Awaited<ReturnType<typeof destinationClient.estimateFeesPerGas>>;
try {
  fees = await destinationClient.estimateFeesPerGas();
} catch (err) {
  throw new Error(`fee estimation failed on ${destinationChain.name}: ${err}`);
}

Wrapping it with a contextual message makes the failure origin obvious in relay logs, especially when a relayer monitors multiple destination chains.


On the math: bumping maxFeePerGas by 130% and maxPriorityFeePerGas by 115% always preserves the EIP-1559 invariant (maxFeePerGas ≥ baseFeePerGas + maxPriorityFeePerGas) — verified. The ceiling division in bumpFee is still correct.

@osr21

osr21 commented Jun 22, 2026

Copy link
Copy Markdown

All three points from the previous follow-up are addressed:

  • return {} replaced with a named throw — callers now get an explicit signal when fee estimation produces no usable fields, rather than silently receiving no overrides
  • estimateFeesPerGas() wrapped in try/catch with the chain name in the message — failure origin is now unambiguous in relay logs across multiple chains
  • Arc staleness wording corrected — "stale even for a single transaction" is accurate and no longer undersells the risk

Doc looks solid. No further issues from this review thread.

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.

bug: CCTP v2 receiveMessage() on destination chains fails with 'max fee per gas less than block base fee' when using default viem gas estimation

2 participants