Skip to content

precompile: price dynamic-gas calldata decoding by supplied gas#3737

Open
codchen wants to merge 6 commits into
mainfrom
claude/unruffled-neumann-bba586
Open

precompile: price dynamic-gas calldata decoding by supplied gas#3737
codchen wants to merge 6 commits into
mainfrom
claude/unruffled-neumann-bba586

Conversation

@codchen

@codchen codchen commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Dynamic-gas precompiles decoded their ABI argument payload in Prepare()
before the supplied EVM gas was turned into a Cosmos gas meter, so the cost of
parsing/allocating the calldata was not accounted for. The static precompile
path charges RequiredGas up front in vm.RunPrecompiledContract; that step is
skipped on the dynamic-gas path.

This prices the decode and charges it before Unpack, inside
DynamicGasPrecompile.RunAndCalculateGas:

  • Resolve the target method from the 4-byte selector only (resolveMethod),
    without decoding the argument payload.
  • Compute a decode cost and consume it from the supplied-gas meter before
    decoding, so a call that cannot afford the decode is rejected before the
    parse/allocation work runs.
  • Only then install the meter for execution and unpack the arguments.

Cost model

The Go ABI decoder's cost is dominated by copying string payloads: it
materializes each string via string(output[begin:end]), and because one
string can be referenced by many array/tuple slots, the copied volume can grow
faster than len(input). bytes values are excluded because the decoder
reslices them without copying.

The charge is ReadCostFlat + ReadCostPerByte * (len(input) + stringCopyVolume).
stringCopyVolume is computed by decodeStringCopyBytes, a structural pre-scan
that mirrors go-ethereum's unpack traversal but only follows offsets and reads
length prefixes — it never copies payloads, so it stays linear in len(input)
even when the decode it prices is not. A defensive op-cap and a conservative
fallback cover structurally-inconsistent or unusually-nested input.

Because this lives in the shared framework, it covers every current dynamic-gas
precompile (distribution, staking, bank, wasmd, oracle, ibc, pointer, addr,
solo).

Compatibility

Only the current precompile version is affected. Historical replay (e.g. from
tracer endpoints) runs against the frozen legacy/vNNN precompile snapshots,
which are unchanged, so no upgrade-height guard is required here.

Tests

  • decode_cost_test.go: unit tests for the cost estimator across string, bytes
    (zero-cost), string[], tuple, a repeated-reference encoding, and malformed
    input — the repeated-reference case is cross-checked against the real decoder.
  • TestDynamicGasPrecompileGasGate: a call that cannot afford the decode is
    rejected before the executor runs.
  • Updated the precompile gas/fee/balance expectations that shift under the new
    charge (distribution, bank, ibc, pointer).

All precompiles/... package tests pass; gofmt / goimports clean.

🤖 Generated with Claude Code

Dynamic-gas precompiles decoded the ABI argument payload in Prepare()
before converting the supplied EVM gas into a Cosmos gas meter. The
static precompile path checks that the supplied gas covers the work up
front (vm.RunPrecompiledContract), but that check does not run on the
dynamic-gas path.

Align the two paths in RunAndCalculateGas: resolve the target method from
the 4-byte selector, derive the Cosmos gas limit from the supplied gas,
and require it to cover the calldata read cost before unpacking the
arguments. Gas accounting for adequately funded calls is unchanged, so
existing gas results for normal usage stay the same.

Add a regression test and update the existing dynamic-gas precompile test
to supply gas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 15, 2026, 3:15 AM

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 60.10929% with 73 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.53%. Comparing base (65d3f00) to head (a31cb2b).
⚠️ Report is 24 commits behind head on main.

Files with missing lines Patch % Lines
precompiles/common/decode_cost.go 55.48% 46 Missing and 23 partials ⚠️
precompiles/common/precompiles.go 85.71% 2 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3737      +/-   ##
==========================================
- Coverage   59.47%   58.53%   -0.94%     
==========================================
  Files        2275     2189      -86     
  Lines      188818   179173    -9645     
==========================================
- Hits       112296   104878    -7418     
+ Misses      66441    64991    -1450     
+ Partials    10081     9304     -777     
Flag Coverage Δ
sei-chain-pr 64.55% <60.10%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
precompiles/common/precompiles.go 85.54% <85.71%> (+1.98%) ⬆️
precompiles/common/decode_cost.go 55.48% <55.48%> (ø)

... and 87 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Charge for ABI-decoding a dynamic-gas precompile call before it runs. The
decoder's cost is dominated by copying string payloads, and because one
string can be referenced by many array/tuple slots the copied volume can
grow faster than the input length. Charge ReadCostFlat +
ReadCostPerByte*(len(input) + string-copy volume), where the string-copy
volume is computed by a structural pre-scan that follows offsets and reads
length prefixes without copying, so it stays linear in len(input). The
charge is consumed from the supplied-gas meter before Unpack, so a call that
cannot afford the decode is rejected before the parse/allocation work runs.

Only the current precompile version is affected; historical replay uses the
frozen legacy snapshots.

Add unit tests for the cost estimator and update the affected precompile gas
expectations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codchen codchen changed the title precompile: check supplied gas before decoding dynamic-gas calldata precompile: price dynamic-gas calldata decoding by supplied gas Jul 14, 2026
…gas fixtures

Annotate the bounds-checked int/uint64 conversions in decode_cost.go with
//nolint:gosec (each value is range-checked immediately before the conversion).

Refresh the evmrpc/tests replay gas fixtures that shift under the decode charge.
The mock replay harness has no upgrade fork history, so precompile version
selection falls back to the latest (current) version; these traces therefore
exercise the current precompile and reflect its new gas. Production tracer
replay resolves to the frozen legacy snapshots by upgrade height and is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codchen codchen marked this pull request as ready for review July 14, 2026 08:31
@cursor

cursor Bot commented Jul 14, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches core EVM precompile execution and gas accounting for all dynamic-gas precompiles; incorrect pricing or gating could break contracts or leave DoS vectors, though legacy replay paths are isolated.

Overview
Fixes a DoS in the shared dynamic-gas precompile path where ABI Unpack ran in Prepare before caller gas was metered, so cheap EVM calls could force validators to decode large calldata for free.

DynamicGasPrecompile.RunAndCalculateGas now resolves the method from the selector only (resolveMethod), installs a Cosmos gas meter from supplied EVM gas, charges DecodeGasCost (linear calldata scan plus string-copy volume at KV read rates, with a cheap structural pre-scan for aliased string[] encodings), then unpacks. Out-of-gas from decode charges becomes a revert; other panics still propagate. Invalid encodings are rejected before Unpack.

Frozen legacy/v601 and legacy/v603 precompile snapshots import precompiles/common/legacy/v66 so historical replay behavior stays unchanged. Integration tests update expected gas, refunds, and remaining gas where the new decode charge applies.

Reviewed by Cursor Bugbot for commit a31cb2b. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prices dynamic-gas precompile calldata decoding out of the supplied gas before decoding, closing a free-parse DoS; the cost model faithfully mirrors go-ethereum's unpack traversal and is well-tested. No blockers — a few clarifying notes, and I disagree with Codex's sole finding.

Findings: 0 blocking | 7 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Codex's High finding (frozen historical replay is affected because legacy versions share precompiles/common) is a false positive: each legacy precompile imports its own frozen precompiles/common/legacy/vNNN (e.g. distribution/legacy/v605 -> common/legacy/v605), which is untouched. The changed v6.0.x expectations in evmrpc/tests/regression_test.go are re-baselines of the CURRENT code path — the pacific regression harness sets no upgrade done-heights, so precompiles resolve to latestUpgrade and those tests always exercised current code, not the v6.0.x snapshots. The author's Compatibility note is correct.
  • Cursor produced no review output (file empty).
  • This is a consensus/app-hash-breaking change to the latest precompile version (correctly labeled). Ensure it ships coordinated with an upgrade and that the pre-change precompiles/common is snapshotted as a frozen legacy version at release-cut time so post-upgrade historical replay of pre-upgrade blocks stays deterministic.
  • Cost-model edge: the ok=false fallback in DecodeGasCost charges only the linear base. This is safe in practice (hitting maxDecodeWalkOps requires ~32MB of gas-prohibitive calldata, and other failure paths correspond to inputs the real decoder rejects with bounded work), but it does rely on that invariant — worth a brief comment noting that no ABI shape used by current precompiles can make the real decoder copy super-linearly while the walk bails to base.
  • Consider a targeted unit test for the fixed-size-array-of-dynamic-element path (ArrayTy with dynamic elem) in decode_cost_test.go; current tests cover string, bytes, string[] (SliceTy), and tuple[], but not the fixed-array branch of walk().
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

// metering the calldata decode below — into a reverted precompile call,
// mirroring how the individual executor methods recover. This keeps a
// single precompile call's failure from aborting the enclosing EVM frame.
if r := recover(); r != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This new top-level recover() is the right way to convert the OOG panic from the ConsumeGas(DecodeGasCost(...)) call below into a reverted precompile call. Note it also now catches any panic escaping d.executor.Execute (previously none were recovered at this level, though the individual executors recover internally). That's consistent with the existing executor recover pattern and safer for the enclosing EVM frame, but it does broaden what gets silently turned into ErrExecutionReverted — confirm that's intended and won't mask a genuinely non-deterministic panic during execution.

Comment thread precompiles/common/precompiles.go Outdated
gasLimit := d.executor.EVMKeeper().GetCosmosGasLimitFromEVMGas(ctx.WithGasMeter(sdk.NewInfiniteGasMeterWithMultiplier(ctx)), suppliedGas)
ctx = ctx.WithGasMeter(sdk.NewGasMeterWithMultiplier(ctx, gasLimit))
operation = method.Name
ctx.GasMeter().ConsumeGas(DecodeGasCost(method.Inputs, input), fmt.Sprintf("%s precompile calldata decode", d.name))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The decode charge is metered against the Cosmos gas meter (via the supplied-EVM-gas-derived limit) rather than charged from the EVM gas pool up front like the static path's RequiredGas. This flows through to remainingGas correctly (as the updated test expectations confirm) and is consistent with how execution gas is already metered here, so no change needed — just flagging that the model differs from the static-precompile charge point for reviewers cross-checking gas accounting.

Comment on lines 116 to +124
return ctxer.Ctx(), method, args, nil
}

// resolveMethod extracts the 4-byte selector from input and resolves the ABI
// method WITHOUT decoding the (attacker-controlled) argument payload. Decoding
// is deliberately deferred so callers can perform it under a gas gate — see
// DynamicGasPrecompile.RunAndCalculateGas.
func (p Precompile) resolveMethod(input []byte) (*abi.Method, error) {
methodID, err := ExtractMethodID(input)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new decode-cost gating in this PR only covers DynamicGasPrecompile.RunAndCalculateGas; Precompile.Prepare (the static-precompile path, used e.g. by gov.voteWeighted) still calls method.Inputs.Unpack with no size-proportional gas charge. Because gov.RequiredGas returns a flat 30000 gas regardless of input size, and voteWeighted's ABI takes WeightedVoteOption[] (a tuple array containing a string), the same aliased-string amplification this PR's own TestDecodeStringCopyBytes_Aliased demonstrates can force a validator to copy/allocate megabytes-to-gigabytes of string data for a fixed 30000 gas. Consider applying DecodeGasCost (or an equivalent charge) in Prepare/the static Run path as well.

Extended reasoning...

The gap: This PR's stated rationale (per the description) is that the static precompile path is already safe because vm.RunPrecompiledContract charges RequiredGas(input) up front, before Run()/Prepare()/Unpack() ever execute. That's true as a mechanism, but it only actually bounds the decode cost if RequiredGas scales with len(input) (or with the resulting decode work). For precompiles/gov it doesn't: gov.go's RequiredGas returns a flat 30000 for vote/voteWeighted regardless of input size (precompiles/gov/gov.go:82-91). gov is registered via pcommon.NewPrecompile (not NewDynamicGasPrecompile), so it runs through Precompile.Run -> Prepare -> method.Inputs.Unpack(input[4:]), which this PR left completely ungated.\n\nWhy voteWeighted specifically is exploitable: its ABI (precompiles/gov/abi.json) is voteWeighted(uint64 proposalID, WeightedVoteOption[] options) where WeightedVoteOption = (int32 option, string weight) — i.e. a tuple[] containing a string. This is exactly the aliasable-string shape that this PR's own decode_cost_test.go (TestDecodeStringCopyBytes_Aliased) proves causes super-linear decode cost in go-ethereum's real ABI decoder: an attacker can craft K array-element offsets that all point at the same dynamic string of length S, so Unpack copies K*S bytes even though the raw calldata is only ~32*K + S bytes.\n\nStep-by-step proof of the exploit path:\n1. Attacker (or contract making an internal CALL, so no tx-level per-byte calldata pricing applies) builds calldata for voteWeighted whose options array has K elements, each element's weight string-offset pointing at one shared location holding an S-byte string. Total calldata size ≈ 32*K + S + 68 bytes (selector + proposalID + array head/offsets + one shared string payload).\n2. This calldata reaches vm.RunPrecompiledContract in go-ethereum (vendored core/vm/contracts.go), which for a non-dynamic-gas precompile computes gasCost := p.RequiredGas(input) — here always 30000 — checks suppliedGas >= gasCost, subtracts it, and calls p.Run(input).\n3. Run calls Precompile.Prepare, which calls p.resolveMethod then immediately method.Inputs.Unpack(input[4:]) — still inside this PR's modified file, still completely unmetered beyond the flat 30000 already charged.\n4. go-ethereum's ABI decoder materializes the weight string via string(output[begin:end]) for each of the K elements, so it performs K copies of S bytes = K*S bytes copied/allocated, even though only ~32*K + S bytes were supplied and only 30000 gas was charged.\n5. Choosing, e.g., K ≈ S to maximize K*S under a fixed calldata length budget gives copy volume that grows quadratically in calldata length while the charged gas stays flat at 30000 — the identical DoS class this PR closes for bank/distribution/ibc/etc., just left open here.\n6. Because this happens in Prepare(), it runs before Execute()'s readOnly/delegatecall/association checks, so an unassociated caller (whose call will ultimately be rejected) still forces the validator to do the expensive decode/allocation first — the cost is paid by every node regardless of whether the call would succeed.\n\nWhy nothing else in the codebase prevents this: the PR explicitly scoped its fix to DynamicGasPrecompile.RunAndCalculateGas (see the new resolveMethod/DecodeGasCost wiring in precompiles/common/precompiles.go), leaving the sibling static path (Precompile.Prepare, used by Precompile.Run) with the pre-existing unmetered Unpack. The PR's own decode-cost test suite (decode_cost_test.go) proves the amplification is real against the actual go-ethereum decoder — it just isn't wired up on this code path.\n\nSuggested fix: apply DecodeGasCost(method.Inputs, input) (or an equivalent per-byte/string-copy-volume charge) inside Precompile.Prepare before calling Unpack, mirroring what RunAndCalculateGas now does for the dynamic-gas path — or, more targeted, fix gov.RequiredGas to scale with the decode cost of voteWeighted's argument shape. Since Prepare runs before a Cosmos gas meter is necessarily installed for the static path, the former likely requires slightly more plumbing (installing/consuming from a meter derived from the EVM-supplied gas, as the dynamic path now does) rather than a one-line change.\n\nThis is flagged as normal rather than pre_existing because, while the underlying gov.RequiredGas flat-gas design predates this PR, this PR is specifically the security fix for this exact DoS class, directly modifies Prepare()/resolveMethod() in this exact file, and its description explicitly (but incorrectly, for flat-RequiredGas precompiles like gov) asserts the static path is already covered. Multiple independent verifiers traced the same conclusion by reading the vendored go-ethereum RunPrecompiledContract, gov.go, and abi.json, and all converged on normal severity given it's a concretely triggerable validator-side CPU/memory DoS.

…data early

Charge a length-proportional amount before running the calldata scan, so the
scan is itself bounded by the supplied gas and can never run for free even if it
later becomes costlier; the remaining (string-copy) portion is charged after, so
the total is unchanged. DecodeGasCost now reports ok=false for structurally
invalid calldata, and the caller rejects it before Unpack rather than charging a
fallback and letting Unpack fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR correctly closes a DoS gap by pricing ABI calldata decoding against supplied gas before decoding on the dynamic-gas precompile path; the cost estimator is conservative and well-tested, and the gas gate is ordered correctly. No blocking issues found — the one external "High" finding does not hold up on investigation.

Findings: 0 blocking | 7 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Disagree with Codex's High finding (regression_test.go historical-replay leak). The evmrpc/tests testTx harness never registers upgrade done-heights (no mockUpgrade/SetDone call), so GetCustomPrecompilesVersions hits the noForkHistory branch and resolves every precompile to latestUpgrade — the current framework this PR modifies — regardless of the "v6.0.x" version string. Production tracing of those historical txns resolves to the frozen precompiles/*/legacy/vNNN snapshots (which embed their own frozen precompiles/common/legacy/vNNN copy of RunAndCalculateGas, untouched here), so there is no production historical-replay change. The updated expectations are a test-harness artifact, not a regression.
  • Confirm the release process: this is (correctly) labeled app-hash-breaking and applies to the current precompile version with no in-code upgrade-height gate. Ensure the release that ships this snapshots the pre-change common framework into a new legacy/vNNN and registers the upgrade at a height, so blocks produced under the current version before the upgrade boundary continue to replay with the old gas (per evmrpc/AGENTS.md: historical RPC responses must not change across upgrades).
  • REVIEW_GUIDELINES.md and cursor-review.md are both empty (no repo-specific guidelines and no Cursor second-opinion output to merge).
  • The estimator diverges from go-ethereum only on the fixed-array-of-dynamic-element path (reads the offset from the low 8 bytes of the head word rather than the full 32-byte word). This is safe in practice: when the high 24 bytes are non-zero the real decoder rejects the input, so whenever both succeed they agree — no undercharge. Worth a brief comment noting this is intentionally narrower than the general ABI grammar precompiles actually use.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

// metering the calldata decode below — into a reverted precompile call,
// mirroring how the individual executor methods recover. This keeps a
// single precompile call's failure from aborting the enclosing EVM frame.
if r := recover(); r != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This recover() now converts any panic in the decode/execute path into a reverted call, not just the intended out-of-gas panic from ConsumeGas. That is the right behavior for the OOG gate, but it also silently swallows genuine executor bugs (nil derefs, invariant violations) that previously propagated. Consider distinguishing the OOG/gas panic (e.g. sdk.ErrorOutOfGas) from other panics — re-panicking or at least logging unexpected panic types — so real defects aren't masked as ordinary reverts. Non-blocking.

}
// DecodeGasCost already includes scanCost; charge only the remaining
// (string-copy) portion so the decode is priced exactly once.
ctx.GasMeter().ConsumeGas(decodeCost-scanCost, fmt.Sprintf("%s precompile calldata decode", d.name))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Ordering and arithmetic here are correct: scanCost == DefaultGasCost(input,false) is charged first, and decodeCost is satAdd(base, ...) with base == scanCost, so decodeCost - scanCost can never underflow and the decode is priced exactly once. Good.

Comment thread evmrpc/tests/regression_test.go Outdated
"0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1",
"v6.0.3",
"0x8214a",
"0x82932",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] These expectation changes are expected, not a historical-replay regression (addressing the external review's flag). testTx doesn't call mockUpgrade/SetDone, so GetCustomPrecompilesVersions takes the noForkHistory branch and runs the latest precompile framework — the code this PR changes — regardless of the "v6.0.3" argument. Production tracing of these txns resolves to the frozen legacy/vNNN snapshots, which are untouched. No change needed; noting for the record.

Only convert sdk.ErrorOutOfGas / sdk.ErrorGasOverflow panics (from the calldata
decode gas charges) into a reverted call; re-panic anything else so genuine bugs
are not masked as reverts. Add a regression test asserting a non-gas panic
propagates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
seidroid[bot]
seidroid Bot previously requested changes Jul 14, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decode-gas charge is added to the shared common.DynamicGasPrecompile.RunAndCalculateGas with no upgrade-height gate, so it also alters gas for the frozen v6.0.1/v6.0.3 precompile snapshots (bank/addr/oracle/ibc/wasmd/json import the current common), breaking historical replay/app-hash for those mainnet heights; the changed v6.0.0–v6.0.3 regression fixtures are evidence of this, not just a current-version reprice. The decode-cost estimator itself is well-implemented and well-tested.

Findings: 4 blocking | 4 non-blocking | 2 posted inline

Blockers

  • Historical-replay / consensus break: the new calldata-decode charge is applied unconditionally in the shared precompiles/common/precompiles.go RunAndCalculateGas. The v601 and v603 legacy precompile snapshots (precompiles/{bank,addr,oracle,ibc,wasmd,json}/legacy/v60{1,3}) import the CURRENT precompiles/common (verified: grep shows pcommon "github.com/sei-protocol/sei-chain/precompiles/common"), whereas v5.5.x and v6.0.5+ snapshots import frozen precompiles/common/legacy/vNNN. So the charge changes gas for the frozen v6.0.0–v6.0.3 precompile versions too. Re-executing/state-syncing mainnet blocks in that height range would now compute a different app hash than the network committed. The PR description's claim that “historical replay runs against frozen legacy snapshots which are unchanged, so no upgrade-height guard is required” is therefore incorrect. Fix per the repo’s own pattern: snapshot the current common into a new common/legacy/v6XX, repoint the v601/v603 precompile packages to it, and apply the decode charge only to current/latest — or gate the charge behind ctx.ClosestUpgradeName().
  • The changed v6.0.0–v6.0.3 entries in evmrpc/tests/regression_test.go are the direct symptom of the break above: those tests route to the v601/v603 snapshots that share the current common, so their gasUsed shifted, while all other versions (frozen common) are unchanged. Updating these fixtures masks the divergence rather than resolving it — they should return to their original values once the change is correctly scoped to the current version (matches Codex’s finding).
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor produced no review output (cursor-review.md was empty); this synthesis is based on the diff, the repository code, and the Codex pass only.
  • The regression suite cannot independently guard against this class of leak: it did surface the v6.0.0–v6.0.3 shift, but only because those snapshots happen to import current common. Consider an explicit test/assertion that frozen precompile versions do not change gas when the shared framework changes.
  • decode_cost.go is well-constructed — it faithfully mirrors go-ethereum’s toGoType/forEachUnpack/lengthPrefixPointsTo bounds and offset handling, prunes non-string subtrees, stays linear via the pre-scan + maxDecodeWalkOps cap, and is covered by the aliased/tuple/malformed unit tests (including a cross-check against the real decoder). No correctness issue found in the estimator itself; the only concern is where the resulting charge is applied.
  • The pre-existing fmt.Printf("precompile %s encountered error...") debug line is retained in the modified deferred block; unrelated to this PR but worth replacing with structured logging at some point.

// Charge a length-proportional amount up front, before the structural scan
// inside DecodeGasCost runs, so the scan itself is bounded by the supplied gas
// and can never be performed for free even if it later becomes costlier.
scanCost := DefaultGasCost(input, false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] This decode charge is applied unconditionally in the shared framework, with no ctx.ClosestUpgradeName() gate. The v601/v603 legacy precompile snapshots (bank/addr/oracle/ibc/wasmd/json) import this current precompiles/common, so the charge also changes gas for those frozen historical versions — not just the current one. That diverges from the gas the network already committed for v6.0.0–v6.0.3 mainnet blocks (re-execution/state-sync would produce a different app hash). Scope this to current/latest: freeze the current common into a common/legacy/v6XX snapshot and repoint the v601/v603 packages to it, or gate the charge by upgrade height. The PR’s “legacy snapshots are unchanged” claim does not hold for v601/v603, which share this file.

Comment thread evmrpc/tests/regression_test.go Outdated
"0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1",
"v6.0.3",
"0x8214a",
"0x82932",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocker] Every regression fixture that changed in this PR is a v6.0.0–v6.0.3 transaction, and those versions route to the v601/v603 precompile snapshots that import the current precompiles/common (v5.5.x and v6.0.5+ route to frozen common/legacy/* and are unchanged). So this gasUsed change reflects an alteration of already-committed historical precompile behavior, not a current-version-only reprice. Once the decode charge is correctly confined to the current version, these expected values should revert to their originals.

The v601/v603 snapshots of addr, bank, ibc, json, oracle and wasmd imported the
current precompiles/common, so changes to the current common leaked into their
replay path. Point them at the frozen precompiles/common/legacy/v66 snapshot
(byte-identical to the current common prior to the recent decode-cost change),
so historical replay is insulated from current-common changes.

This restores the pre-change replay gas for the affected traces, so the
evmrpc/tests replay fixtures revert to their original values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization has reached its monthly code review spending cap.

An organization admin can view or raise the cap at claude.ai/admin-settings/claude-code. The cap resets at the start of the next billing period.

Once the cap resets or is raised, push a new commit or reopen this pull request to trigger a review.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a31cb2b. Configure here.

w.walkElems(output[offset:], t.Size, *t.Elem)
} else {
w.walkElems(output[index:], t.Size, *t.Elem)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed dynamic array walk wrong

Low Severity

In decodeStringCopyBytes, the ArrayTy branch treats fixed-size arrays whose elements are dynamic (e.g. string[N]) like a single tail offset and walks from one offset. ABI encoding places N consecutive 32-byte head words in the head region, matching go-ethereum’s inline unpack. The estimator can undercount string-copy volume or mark valid encodings invalid.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a31cb2b. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid, well-tested fix that prices ABI calldata decoding under the supplied-gas meter for dynamic-gas precompiles, closing a DoS where near-zero-gas calls forced free parse/allocation. The cost estimator faithfully mirrors go-ethereum's unpack traversal and the legacy precompiles are correctly re-pinned to the frozen common snapshot so historical replay is unaffected. No blockers; a few points worth confirming.

Findings: 0 blocking | 4 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • app-hash-breaking: this changes gas cost for every current dynamic-gas precompile (distribution/staking/bank/wasmd/oracle/ibc/pointer/addr/solo). Confirm the new charge only becomes active at the coordinated release/version boundary so upgraded and un-upgraded nodes cannot diverge mid-block. Legacy vNNN snapshots are correctly frozen (verified v66 == pre-PR live common, and no legacy precompile still imports live precompiles/common), so replay is unaffected — the concern is purely the live-version rollout, which the label acknowledges.
  • The op-cap fallback (maxDecodeWalkOps) makes DecodeGasCost return not-ok for structurally-valid but extremely nested/aliased calldata that the real decoder would accept, causing such calls to be rejected as "invalid encoding". This is a safe/defensive tradeoff for the fixed, shallow precompile ABIs, but it means a legitimately-crafted (non-attack) deeply-nested input at the extreme would be rejected rather than decoded. Documented in the code; noting for awareness.
  • Second-opinion passes: OpenAI Codex reported no material issues; the Cursor review file was empty (no output produced).
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

// (a call that could not afford the decode), keeping the failure from
// aborting the enclosing EVM frame. Re-panic anything else so genuine
// bugs are not masked as reverts.
if r := recover(); r != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The recover now spans the entire function, including d.executor.Execute(...), so an ErrorOutOfGas/ErrorGasOverflow panic originating inside execution (not just the decode charges) is now converted to ErrExecutionReverted here rather than propagating as before. This appears gas-equivalent (on the recovered path remainingGas stays 0, matching all-gas-consumed OOG semantics), but the comment frames the recover as only covering "the calldata-decode gas charges below" — the scope is actually wider. Worth confirming execution-path OOG panics were previously turned into reverts downstream too, so behavior is unchanged.

@seidroid seidroid Bot dismissed their stale review July 15, 2026 03:23

Superseded: latest AI review found no blocking issues.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant