Skip to content

fix(cli): handle zero-baseFee chains and raise default gasLimit#860

Open
ryan-kinsey wants to merge 1 commit into
wormhole-foundation:mainfrom
dexatlantis:fix/cli-evm-signer-gas-bsc
Open

fix(cli): handle zero-baseFee chains and raise default gasLimit#860
ryan-kinsey wants to merge 1 commit into
wormhole-foundation:mainfrom
dexatlantis:fix/cli-evm-signer-gas-bsc

Conversation

@ryan-kinsey

@ryan-kinsey ryan-kinsey commented Apr 17, 2026

Copy link
Copy Markdown

Summary

Two latent bugs in cli/src/evm/signer.ts (EvmNativeSigner.sign) reproduced during a Mainnet NTT v2.0.0 deployment on Monad ↔ BSC.

  1. Default catch-all gasLimit was too small for NttWithExecutor.transfer. The flow bundles token escrow + Wormhole message publish + Executor relay instructions in a single transaction and realistically uses ~1.2–1.5M gas on mainnet EVM chains. The previous 500_000 default caused silent out-of-gas reverts on every token transfer.
  2. EIP-1559 fee derivation is broken on chains where baseFeePerGas = 0 (BSC post-Lorentz, April 2025). Ethers' getFeeData() returns maxFeePerGas = 1 wei and the BSC private-tx-service rejects with:
    [private transaction service] require GasPrice=50000000, Provide=1
    

Fix

Default gasLimit: 500_000n → 3_000_000n

Catch-all branch only. The Mantle (2.6T) and ArbitrumSepolia (4M) special cases are preserved. The opts.maxGasLimit override is preserved; callers passing an explicit value continue to get exactly that value.

Content-based legacy-tx fallback (not chain-name)

Instead of hard-coding chain === "Bsc", the broken EIP-1559 shape is detected by content:

if (feeData.maxFeePerGas < EIP1559_FEE_SANITY_FLOOR /* 50_000_000n = 0.05 gwei */) {
  // legacy (type 0) tx with gasPrice floor of 1 gwei
}

This auto-covers any future chain (or buggy provider response) exhibiting the same shape — fixed-fee chains, Linea-style quirks, future zero-baseFee L2s, etc. The 1 gwei floor is 20× BSC's 0.05 gwei minimum and remains inexpensive everywhere it applies.

Other changes in this diff

  • Hoist DEFAULT_GAS_LIMIT, LEGACY_FALLBACK_GAS_PRICE, EIP1559_FEE_SANITY_FLOOR to module scope (matches the existing convention used elsewhere in the CLI).
  • Extract a small buildGasOpts(gasLimit, feeData): Partial<TransactionRequest> helper — typed (the previous gasOpts was implicitly any) and unit-testable in isolation.
  • Wrap getFeeData() in try/catch. A transient RPC failure no longer fails the whole sign() call; it falls back to the literal defaults already defined in the function.
  • Remove the stale // TODO: DIFF STARTS HERE / DIFF ENDS HERE markers (they wrapped the previous local divergence and no longer match the current shape).
  • Fix bigint underscore inconsistency (1000_000_000n1_000_000_000n).

Repro (without this PR)

ntt token-transfer --network Mainnet \
  --source-chain Monad --destination-chain Bsc \
  --amount 1 --destination-address 0xRECIPIENT \
  --deployment-path ./deployment.json

Symptoms:

  • Source-chain tx reverts with gasUsed == 500000, status == 0, no logs (out-of-gas).
  • BSC-side ops (ntt push limit application, set-outbound-limit, transfer-ownership, etc.) fail at submission with the BSC private-tx-service error above.

Tx hashes from the live deployment available on request — happy to attach.

Test plan

New file: cli/src/__tests__/evm-signer.test.ts (10 cases, bun:test).

buildGasOpts (pure):

  • EIP-1559 path when maxFeePerGas ≥ sanity floor — preserves all three fee fields, type undefined.
  • Legacy fallback when maxFeePerGas < 50_000_000ntype: 0, maxFeePerGas/maxPriorityFeePerGas absent.
  • Legacy path clamps low provider gasPrice up to 1_000_000_000n.
  • Legacy path preserves provider gasPrice when above the floor.
  • Threshold is exclusive (maxFeePerGas == floor stays on EIP-1559 path).

EvmNativeSigner.sign (mocked end-to-end):

  • Default Ethereum-class chain → gasLimit = 3_000_000n, EIP-1559 fields, no type.
  • opts.maxGasLimit = 42n → captured gasLimit === 42n.
  • Mantle and ArbitrumSepolia keep their specialized gasLimit regardless of override.
  • BSC-shape feeData (maxFeePerGas: 1n) → type: 0, gasPrice: 1_000_000_000n, no 1559 fields.
  • Celo skips getFeeData() and uses fallback defaults.
  • getFeeData() throwing falls back to literal defaults instead of propagating.

Local validation:

  • bun run typecheck:cli → exit 0
  • cd cli && bun test src/ __tests__/ → 166 pass, 2 skip, 0 fail across 15 files
  • prettier --check src/evm/signer.ts src/__tests__/evm-signer.test.ts → clean

Alternatives considered

  • estimateGas + 20% buffer — arguably the root-cause fix. The block is already written and commented out in signer.ts (lines ~218–229) since 2024-06-27. Reviving it would remove the need for a static gasLimit default entirely. Out of scope here because of the larger blast radius; left as a follow-up.
  • NttWithExecutor's own gasLimitOverrides map (evm/ts/src/nttWithExecutor.ts:80) — those values describe the relayer's quoted gas (paid to the executor), which is orthogonal to the source-chain tx's gasLimit. Not applicable.
  • Hard-coded chain === "Bsc" check — initial draft. Rejected because chain-name policy in a generic signer doesn't generalize; any other zero-baseFee chain (or buggy provider response) silently regresses. Content-based detection is one fewer code path per future chain.
  • Retry-on-underprice loop — would catch the BSC error at submission and bump fees. Rejected because BSC's floor is deterministic post-Lorentz; an upfront floor is simpler and avoids an extra round-trip.
  • Env-var override (NTT_DEFAULT_GAS_LIMIT) — added complexity for a knob no current caller exercises (cli/src/signers/getSigner.ts:96 passes only { debug: true }). The existing opts.maxGasLimit plumbing is the right place; threading it through belongs in a follow-up.

Backwards compatibility

  • The CLI is published with bin: ntt only — no main/exports in cli/package.json, so EvmNativeSigner / getEvmSigner are not part of any external API surface.
  • Callers passing an explicit opts.maxGasLimit continue to get exactly that value.
  • BSC receipts now have type: 0 instead of type: 2. Anything outside this repo parsing effectiveGasPrice / maxFeePerGas from BSC receipts produced by the CLI would see legacy-shaped receipts; no such consumer exists in cli/.

Follow-ups (intentionally out of scope)

  • Re-enable estimateGas + 20% buffer (cli/src/evm/signer.ts:218); the static 3M default is symptom suppression and the underlying issue is that the signer doesn't estimate.
  • Plumb opts.maxGasLimit through cli/src/signers/getSigner.ts:96 (it currently passes only { debug: true }, so the override has no CLI surface). Renaming to defaultGasLimit or making it a true upper-bound ceiling would also be useful.
  • Add a per-chain MAX_GAS_PRICE ceiling so a malicious or misconfigured RPC returning an absurd gasPrice cannot cause silent operator overpayment.
  • Per the long-standing TODO at cli/src/evm/signer.ts:9, this whole local copy of the SDK signer can likely be replaced by upstream @wormhole-foundation/sdk-evm after wormhole-sdk-ts PR CLI: Installation of ntt cli fails #583. Worth a separate cleanup pass.

Files in this PR

  • cli/src/evm/signer.ts — refactor + fix.
  • cli/src/__tests__/evm-signer.test.ts — new, 10 cases.

Diff stat: 2 files changed, 270 insertions(+), 20 deletions(-).

…flows

Two latent bugs in cli/src/evm/signer.ts surfaced during a Mainnet
NTT v2.0.0 deployment on Monad <-> BSC.

1. Default catch-all gasLimit was 500_000n. NttWithExecutor.transfer
   bundles escrow + Wormhole publish + Executor relay instructions in
   a single tx, using ~1.2-1.5M gas on mainnet EVM chains, so token
   transfers reverted out-of-gas. Raised default to 3_000_000n;
   opts.maxGasLimit override is preserved.

2. Chains with baseFeePerGas = 0 (BSC post-Lorentz, April 2025) cause
   ethers' getFeeData() to return maxFeePerGas = 1 wei. The BSC
   private-tx-service then rejects with:
     [private transaction service] require GasPrice=50000000, Provide=1
   The fix detects this shape by content (maxFeePerGas <
   EIP1559_FEE_SANITY_FLOOR), not by chain name, and falls back to a
   legacy (type 0) tx with a 1 gwei gasPrice floor (20x BSC's 0.05
   gwei minimum). Forward-compatible with any future chain exhibiting
   the same shape.

Refactor:
- Hoist DEFAULT_GAS_LIMIT, LEGACY_FALLBACK_GAS_PRICE, and
  EIP1559_FEE_SANITY_FLOOR to module scope.
- Extract buildGasOpts() helper, typed Partial<TransactionRequest>
  (replaces a previous gasOpts: any).
- Wrap getFeeData() in try/catch so a transient RPC failure no longer
  fails the whole sign() call -- falls back to literal defaults.
- Remove stale "// TODO: DIFF STARTS/ENDS HERE" markers.

Tests: 10 new bun:test cases in cli/src/__tests__/evm-signer.test.ts
cover buildGasOpts (pure) and EvmNativeSigner.sign mocked end-to-end:
default 3M, opts.maxGasLimit override, Mantle/ArbitrumSepolia
branches, BSC legacy fallback with both floor-clamp and above-floor
preservation, Celo skipping getFeeData(), and getFeeData() throwing.

Follow-ups (out of scope for this PR):
- Re-enable the disabled estimateGas + buffer block (signer.ts:218)
  -- the static 3M default is symptom suppression for the underlying
  issue: the signer doesn't estimate.
- Plumb opts.maxGasLimit through cli/src/signers/getSigner.ts:96 (it
  currently passes only { debug: true }, so the override is dead from
  the CLI surface).
- Delete this whole wrapper in favour of upstream
  @wormhole-foundation/sdk-evm signer post wormhole-sdk-ts PR wormhole-foundation#583;
  the file header has carried this TODO for 22 months.
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.

1 participant