From 1755b319ba34abd0100d87d69930d0b624f50453 Mon Sep 17 00:00:00 2001 From: SeoyeonKim Date: Sun, 21 Jun 2026 23:26:27 +0900 Subject: [PATCH 1/3] docs: add CCTP receiveMessage fee guidance --- docs/cctp-v2-receive-message.md | 104 ++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/cctp-v2-receive-message.md diff --git a/docs/cctp-v2-receive-message.md b/docs/cctp-v2-receive-message.md new file mode 100644 index 0000000..09a0637 --- /dev/null +++ b/docs/cctp-v2-receive-message.md @@ -0,0 +1,104 @@ +# CCTP v2 receiveMessage gas estimation + +This note covers a common integration failure when relaying CCTP v2 burns from +Arc Testnet to a destination chain with `MessageTransmitterV2.receiveMessage()`. + +## Symptom + +A viem or MetaMask-powered relay can fail immediately with an EIP-1559 fee +error similar to: + +```text +RPC submit: max fee per gas less than block base fee +``` + +This usually appears on the destination chain when calling +`receiveMessage(message, attestation)` after the CCTP attestation is available. + +## Why it happens + +`writeContract()` estimates EIP-1559 fees before the wallet signs the +transaction. Between fee estimation and mempool submission, the destination +chain base fee can move. If the signed transaction's `maxFeePerGas` is now below +the current `baseFeePerGas`, the destination node rejects it before execution. + +This is easier to hit on low-fee testnets, where a very small absolute fee move +can be a meaningful relative change. + +## Mitigation + +Estimate fees on the destination chain immediately before calling +`receiveMessage()`, then add headroom to `maxFeePerGas`. + +```ts +import { createPublicClient, http, type Chain } from "viem"; + +const FEE_BUMP_BASIS_POINTS = 13_000n; // 130% +const BASIS_POINTS = 10_000n; + +function bumpFee(value: bigint): bigint { + return (value * FEE_BUMP_BASIS_POINTS + BASIS_POINTS - 1n) / BASIS_POINTS; +} + +async function estimateReceiveMessageFees({ + destinationChain, + destinationRpcUrl, +}: { + destinationChain: Chain; + destinationRpcUrl: string; +}) { + const destinationClient = createPublicClient({ + chain: destinationChain, + transport: http(destinationRpcUrl), + }); + + const fees = await destinationClient.estimateFeesPerGas(); + const maxFeePerGas = fees.maxFeePerGas ?? fees.gasPrice; + + if (maxFeePerGas == null) { + return {}; + } + + return { + maxFeePerGas: bumpFee(maxFeePerGas), + ...(fees.maxPriorityFeePerGas == null + ? {} + : { maxPriorityFeePerGas: fees.maxPriorityFeePerGas }), + }; +} +``` + +Then pass the padded fee fields into the destination-chain write: + +```ts +const feeOverrides = await estimateReceiveMessageFees({ + destinationChain, + destinationRpcUrl, +}); + +const hash = await walletClient.writeContract({ + account, + chain: destinationChain, + address: messageTransmitterV2Address, + abi: messageTransmitterV2Abi, + functionName: "receiveMessage", + args: [message, attestation], + ...feeOverrides, +}); +``` + +## Integration checklist + +- Estimate fees against the destination chain, not Arc Testnet. +- Estimate immediately before `receiveMessage()`; do not reuse fees captured + before waiting for attestation. +- Add enough `maxFeePerGas` headroom for the destination chain's fee volatility. + The example above uses 130%; more conservative relayers may choose a higher + multiplier. +- Keep `maxPriorityFeePerGas` from the destination chain estimate unless your + wallet or relayer has a chain-specific priority-fee policy. +- Retry by re-estimating fees rather than resubmitting the same stale signed + transaction. + +For more on Arc's own base-fee model, see +[ADR-0004: Base Fee Parameter Validation](./adr/0004-base-fee-validation.md). From 15a19a96e97f700e6140220660eff3679ede0532 Mon Sep 17 00:00:00 2001 From: SeoyeonKim Date: Mon, 22 Jun 2026 00:07:55 +0900 Subject: [PATCH 2/3] docs: address CCTP receiveMessage fee feedback --- docs/cctp-v2-receive-message.md | 55 ++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/docs/cctp-v2-receive-message.md b/docs/cctp-v2-receive-message.md index 09a0637..4351883 100644 --- a/docs/cctp-v2-receive-message.md +++ b/docs/cctp-v2-receive-message.md @@ -28,16 +28,19 @@ can be a meaningful relative change. ## Mitigation Estimate fees on the destination chain immediately before calling -`receiveMessage()`, then add headroom to `maxFeePerGas`. +`receiveMessage()`, then add headroom to the fee fields that chain actually +accepts. EIP-1559 chains should receive `maxFeePerGas` and, when available, +`maxPriorityFeePerGas`. Legacy-gas chains should receive `gasPrice` instead. ```ts import { createPublicClient, http, type Chain } from "viem"; -const FEE_BUMP_BASIS_POINTS = 13_000n; // 130% +const MAX_FEE_BUMP_BASIS_POINTS = 13_000n; // 130% +const PRIORITY_FEE_BUMP_BASIS_POINTS = 11_500n; // 115% const BASIS_POINTS = 10_000n; -function bumpFee(value: bigint): bigint { - return (value * FEE_BUMP_BASIS_POINTS + BASIS_POINTS - 1n) / BASIS_POINTS; +function bumpFee(value: bigint, basisPoints: bigint): bigint { + return (value * basisPoints + BASIS_POINTS - 1n) / BASIS_POINTS; } async function estimateReceiveMessageFees({ @@ -53,18 +56,28 @@ async function estimateReceiveMessageFees({ }); const fees = await destinationClient.estimateFeesPerGas(); - const maxFeePerGas = fees.maxFeePerGas ?? fees.gasPrice; - if (maxFeePerGas == null) { - return {}; + if (fees.maxFeePerGas != null) { + return { + maxFeePerGas: bumpFee(fees.maxFeePerGas, MAX_FEE_BUMP_BASIS_POINTS), + ...(fees.maxPriorityFeePerGas == null + ? {} + : { + maxPriorityFeePerGas: bumpFee( + fees.maxPriorityFeePerGas, + PRIORITY_FEE_BUMP_BASIS_POINTS, + ), + }), + }; } - return { - maxFeePerGas: bumpFee(maxFeePerGas), - ...(fees.maxPriorityFeePerGas == null - ? {} - : { maxPriorityFeePerGas: fees.maxPriorityFeePerGas }), - }; + if (fees.gasPrice != null) { + return { + gasPrice: bumpFee(fees.gasPrice, MAX_FEE_BUMP_BASIS_POINTS), + }; + } + + return {}; } ``` @@ -95,10 +108,22 @@ const hash = await walletClient.writeContract({ - Add enough `maxFeePerGas` headroom for the destination chain's fee volatility. The example above uses 130%; more conservative relayers may choose a higher multiplier. -- Keep `maxPriorityFeePerGas` from the destination chain estimate unless your - wallet or relayer has a chain-specific priority-fee policy. +- Add modest `maxPriorityFeePerGas` headroom too when the destination chain uses + EIP-1559. The example above uses 115%. +- On legacy-gas destination chains, pass a bumped `gasPrice` and do not include + EIP-1559 fee fields. - Retry by re-estimating fees rather than resubmitting the same stale signed transaction. +## Arc Testnet as destination + +For reverse CCTP flows where Arc Testnet is the destination, use the legacy +`gasPrice` branch above. Arc Testnet transactions should not include +`maxFeePerGas` or `maxPriorityFeePerGas` overrides. Arc Testnet gas estimates +can also be stale during rapid transaction sequences, so keep the same 130% +headroom when re-estimating `gasPrice` immediately before submission. See +[#87](https://github.com/circlefin/arc-node/issues/87) for the Arc Testnet +`gasPrice` workaround. + For more on Arc's own base-fee model, see [ADR-0004: Base Fee Parameter Validation](./adr/0004-base-fee-validation.md). From cd497191ab85ee0485ef1434d56b38ee21851388 Mon Sep 17 00:00:00 2001 From: SeoyeonKim Date: Mon, 22 Jun 2026 07:25:39 +0900 Subject: [PATCH 3/3] docs: address receiveMessage fee follow-up --- docs/cctp-v2-receive-message.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/cctp-v2-receive-message.md b/docs/cctp-v2-receive-message.md index 4351883..94e7894 100644 --- a/docs/cctp-v2-receive-message.md +++ b/docs/cctp-v2-receive-message.md @@ -55,7 +55,12 @@ async function estimateReceiveMessageFees({ transport: http(destinationRpcUrl), }); - const fees = await destinationClient.estimateFeesPerGas(); + let fees: Awaited>; + try { + fees = await destinationClient.estimateFeesPerGas(); + } catch (err) { + throw new Error(`fee estimation failed on ${destinationChain.name}: ${err}`); + } if (fees.maxFeePerGas != null) { return { @@ -77,7 +82,9 @@ async function estimateReceiveMessageFees({ }; } - return {}; + throw new Error( + `estimateFeesPerGas returned no usable fee fields on ${destinationChain.name}; cannot construct safe fee overrides`, + ); } ``` @@ -120,8 +127,8 @@ const hash = await walletClient.writeContract({ For reverse CCTP flows where Arc Testnet is the destination, use the legacy `gasPrice` branch above. Arc Testnet transactions should not include `maxFeePerGas` or `maxPriorityFeePerGas` overrides. Arc Testnet gas estimates -can also be stale during rapid transaction sequences, so keep the same 130% -headroom when re-estimating `gasPrice` immediately before submission. See +can be stale even for a single transaction, so re-estimate `gasPrice` +immediately before submission and apply the same 130% headroom. See [#87](https://github.com/circlefin/arc-node/issues/87) for the Arc Testnet `gasPrice` workaround.