precompile: price dynamic-gas calldata decoding by supplied gas#3737
precompile: price dynamic-gas calldata decoding by supplied gas#3737codchen wants to merge 6 commits into
Conversation
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>
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
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>
…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>
PR SummaryHigh Risk Overview
Frozen Reviewed by Cursor Bugbot for commit a31cb2b. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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 frozenprecompiles/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 tolatestUpgradeand 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/commonis 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=falsefallback 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 { |
There was a problem hiding this comment.
[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.
| 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)) |
There was a problem hiding this comment.
[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.
| 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) |
There was a problem hiding this comment.
🔴 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>
There was a problem hiding this comment.
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
testTxharness never registers upgrade done-heights (nomockUpgrade/SetDonecall), soGetCustomPrecompilesVersionshits thenoForkHistorybranch and resolves every precompile tolatestUpgrade— the current framework this PR modifies — regardless of the"v6.0.x"version string. Production tracing of those historical txns resolves to the frozenprecompiles/*/legacy/vNNNsnapshots (which embed their own frozenprecompiles/common/legacy/vNNNcopy 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
commonframework into a newlegacy/vNNNand 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 { |
There was a problem hiding this comment.
[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)) |
There was a problem hiding this comment.
[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.
| "0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1", | ||
| "v6.0.3", | ||
| "0x8214a", | ||
| "0x82932", |
There was a problem hiding this comment.
[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>
There was a problem hiding this comment.
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.goRunAndCalculateGas. Thev601andv603legacy precompile snapshots (precompiles/{bank,addr,oracle,ibc,wasmd,json}/legacy/v60{1,3}) import the CURRENTprecompiles/common(verified:grepshowspcommon "github.com/sei-protocol/sei-chain/precompiles/common"), whereas v5.5.x and v6.0.5+ snapshots import frozenprecompiles/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 currentcommoninto a newcommon/legacy/v6XX, repoint the v601/v603 precompile packages to it, and apply the decode charge only to current/latest — or gate the charge behindctx.ClosestUpgradeName(). - The changed v6.0.0–v6.0.3 entries in
evmrpc/tests/regression_test.goare the direct symptom of the break above: those tests route to the v601/v603 snapshots that share the currentcommon, 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.mdwas 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.gois well-constructed — it faithfully mirrors go-ethereum’stoGoType/forEachUnpack/lengthPrefixPointsTobounds and offset handling, prunes non-string subtrees, stays linear via the pre-scan +maxDecodeWalkOpscap, 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) |
There was a problem hiding this comment.
[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.
| "0x362af85584493ed225bf592606c989796ba5f0484f5c80b989b8573f85517ec1", | ||
| "v6.0.3", | ||
| "0x8214a", | ||
| "0x82932", |
There was a problem hiding this comment.
[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>
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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) | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit a31cb2b. Configure here.
There was a problem hiding this comment.
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) makesDecodeGasCostreturn 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 { |
There was a problem hiding this comment.
[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.
Superseded: latest AI review found no blocking issues.


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
RequiredGasup front invm.RunPrecompiledContract; that step isskipped on the dynamic-gas path.
This prices the decode and charges it before
Unpack, insideDynamicGasPrecompile.RunAndCalculateGas:resolveMethod),without decoding the argument payload.
decoding, so a call that cannot afford the decode is rejected before the
parse/allocation work runs.
Cost model
The Go ABI decoder's cost is dominated by copying
stringpayloads: itmaterializes each string via
string(output[begin:end]), and because onestring can be referenced by many array/tuple slots, the copied volume can grow
faster than
len(input).bytesvalues are excluded because the decoderreslices them without copying.
The charge is
ReadCostFlat + ReadCostPerByte * (len(input) + stringCopyVolume).stringCopyVolumeis computed bydecodeStringCopyBytes, a structural pre-scanthat 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/vNNNprecompile 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 malformedinput — the repeated-reference case is cross-checked against the real decoder.
TestDynamicGasPrecompileGasGate: a call that cannot afford the decode isrejected before the executor runs.
charge (distribution, bank, ibc, pointer).
All
precompiles/...package tests pass;gofmt/goimportsclean.🤖 Generated with Claude Code