Skip to content

feat(precompiles): expose module Query rpcs as precompile methods#3767

Draft
codchen wants to merge 4 commits into
mainfrom
claude/audit-missing-precompile-impls-07737c
Draft

feat(precompiles): expose module Query rpcs as precompile methods#3767
codchen wants to merge 4 commits into
mainfrom
claude/audit-missing-precompile-impls-07737c

Conversation

@codchen

@codchen codchen commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Motivation

An audit of all rpc defs in service Query (query.proto) and service Msg (tx.proto) under /proto and /sei-cosmos/proto found that many module Query rpcs have no EVM precompile counterpart: bank/distribution were partial, gov was write-only, and auth, authz, evidence, feegrant, mint, params, slashing, and upgrade had no precompile at all.

Changes

Extended existing precompiles (new view methods, head version only)

  • bank (+5): spendableBalances, totalSupply (paginated all-denoms; existing supply(denom) unchanged), params, denomMetadata, denomsMetadata
  • distribution (+8): params, validatorOutstandingRewards, validatorCommission, validatorSlashes, delegationRewards, delegatorValidators, delegatorWithdrawAddress, communityPool
  • gov (+8): proposal, proposals (status/voter/depositor filters), getVote, votes, params (voting+deposit+tallying merged), getDeposit, deposits, tallyResultgetVote(uint64,address) / getDeposit(uint64,address) (named to avoid overloading the tx methods, which breaks ethers.js name resolution)

New query-only precompiles

Precompile Address Methods
auth 0x…100D account, accounts, params, nextAccountNumber
authz 0x…100E grants, granterGrants, granteeGrants
evidence 0x…100F evidence, allEvidence
feegrant 0x…1010 allowance, allowances, allowancesByGranter
mint 0x…1012 params, minter
params 0x…1013 params(subspace, key)
slashing 0x…1014 params, signingInfo, signingInfos
upgrade 0x…1015 currentPlan, appliedPlan, upgradedConsensusState, moduleVersions

(0x…1011 is already p256.) Each new precompile follows the oracle-style DynamicGasPrecompile shape with abi.json, a Solidity interface, a versions file (single v6.6 entry), and a generated-style setup.go, so scripts/bump_version manages them going forward.

Plumbing

  • 11 narrow querier interfaces (mirroring the existing StakingQuerier pattern) plus Codec() added to precompiles/utils/expected_keepers.go, wired to concrete keepers in app/precompiles.go (mint via mintkeeper.NewQuerier; others implement their gRPC QueryServers directly)
  • The gov precompile is migrated from the static-gas Precompile wrapper (fixed 30k/50k per tx method) to the DynamicGasPrecompile pattern used by bank/staking/distribution, metering actual Cosmos gas consumption
  • The 8 new addresses are registered in giga's fail-fast list (giga/executor/precompiles/failfast.go) so giga aborts and falls back to v2 on calls to them, like the existing custom precompiles
  • Conventions follow the staking precompile: Dec/Int → string, times → Unix seconds, durations → seconds, pagination via bytes pageKey + nextKey
  • Any-typed response fields (gov proposal content, authz authorizations, evidence, feegrant allowances) are returned as registry-resolved JSON bytes ({"@type": …}), consumable on-chain with the json precompile

Notes for reviewers

  • These are consensus-visible additions at the current head version (v6.6). If v6.6 is already live, they should ride the next upgrade tag — the bump_version flow handles archival when the tag is added.
  • Account-typed query inputs take EVM address and require address association (consistent with existing bank/staking/distribution precompiles); validator/consensus addresses are bech32 strings.
  • precompiles/setup.go registers the new precompiles in GetCustomPrecompiles only (same as solo), not in the legacy InitializePrecompiles global-VM path.

Testing

  • go build ./... clean
  • go test ./precompiles/... -count=1 — all 22 precompile packages pass, including new tests covering happy paths, pagination, error paths (unassociated address, not-found), and exact packed-output assertions
  • go test ./x/evm/keeper/ -run TestGetCustomPrecompiles passes
  • go vet clean; all touched files gofmt -s / goimports compliant

🤖 Generated with Claude Code

Add query counterparts for Cosmos module Query rpcs that had no EVM
precompile surface:

- bank: spendableBalances, totalSupply, params, denomMetadata,
  denomsMetadata
- distribution: params, validatorOutstandingRewards, validatorCommission,
  validatorSlashes, delegationRewards, delegatorValidators,
  delegatorWithdrawAddress, communityPool
- gov: proposal, proposals, vote, votes, params, deposit, deposits,
  tallyResult

New query-only precompiles: auth (0x100D), authz (0x100E), evidence
(0x100F), feegrant (0x1010), mint (0x1012), params (0x1013), slashing
(0x1014), upgrade (0x1015).

Querier interfaces and codec plumbing are added to precompiles/utils and
wired in app/precompiles.go. Any-typed response fields (gov content,
authz authorizations, evidence, feegrant allowances) are returned as
registry-resolved JSON bytes for use with the json precompile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 16, 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 17, 2026, 4:43 AM

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.99496% with 540 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.01%. Comparing base (ad863c7) to head (5be3ea2).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
precompiles/gov/gov.go 61.48% 73 Missing and 46 partials ⚠️
precompiles/distribution/distribution.go 54.58% 75 Missing and 29 partials ⚠️
precompiles/bank/bank.go 65.80% 30 Missing and 23 partials ⚠️
precompiles/authz/authz.go 66.91% 25 Missing and 20 partials ⚠️
precompiles/feegrant/feegrant.go 64.00% 25 Missing and 20 partials ⚠️
precompiles/auth/auth.go 64.70% 25 Missing and 17 partials ⚠️
precompiles/upgrade/upgrade.go 70.47% 18 Missing and 13 partials ⚠️
precompiles/evidence/evidence.go 67.60% 14 Missing and 9 partials ⚠️
precompiles/slashing/slashing.go 76.04% 14 Missing and 9 partials ⚠️
precompiles/mint/mint.go 73.97% 12 Missing and 7 partials ⚠️
... and 10 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3767      +/-   ##
==========================================
- Coverage   59.88%   59.01%   -0.87%     
==========================================
  Files        2288     2217      -71     
  Lines      190023   181696    -8327     
==========================================
- Hits       113786   107234    -6552     
+ Misses      66091    64914    -1177     
+ Partials    10146     9548     -598     
Flag Coverage Δ
sei-chain-pr 57.08% <65.99%> (?)
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 Δ
app/precompiles.go 100.00% <100.00%> (ø)
giga/executor/precompiles/failfast.go 47.36% <ø> (ø)
precompiles/setup.go 50.90% <100.00%> (+3.85%) ⬆️
precompiles/auth/setup.go 71.42% <71.42%> (ø)
precompiles/authz/setup.go 71.42% <71.42%> (ø)
precompiles/evidence/setup.go 71.42% <71.42%> (ø)
precompiles/feegrant/setup.go 71.42% <71.42%> (ø)
precompiles/mint/setup.go 71.42% <71.42%> (ø)
precompiles/params/setup.go 71.42% <71.42%> (ø)
precompiles/slashing/setup.go 71.42% <71.42%> (ø)
... and 13 more

... and 88 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.

Overloading the vote/deposit transaction methods breaks tooling that
resolves functions by name (ethers.js rejects ambiguous names with
INVALID_ARGUMENT). Rename the query variants instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codchen
codchen marked this pull request as ready for review July 17, 2026 04:17
@cursor

cursor Bot commented Jul 17, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Consensus-visible EVM surface at v6.6 with many new precompile addresses and gov gas-model change; read-only but touches bank, distribution, and governance query paths used by on-chain contracts.

Overview
Exposes Cosmos module Query gRPC endpoints to Solidity via EVM precompiles so contracts can read chain state without off-chain RPC.

New query-only precompiles at fixed addresses (0x…100D0x…1015, skipping 1011 for p256): auth, authz, evidence, feegrant, mint, params, slashing, and upgrade, each with Solidity interfaces, abi.json, and v6.6 versioning.

Extended existing precompiles with read-only methods: bank (+5: spendable balances, paginated total supply, params, denom metadata), distribution (+8: params, validator rewards/commission/slashes, delegation rewards, delegator validators/withdraw address, community pool), and gov (+8 queries; getVote / getDeposit names avoid ABI clashes with tx methods). Gov also moves to DynamicGasPrecompile like the other query-heavy modules.

Plumbing: PrecompileKeepers gains module queriers and Codec(); new addresses are on giga’s fail-fast list. Pagination uses bytes pageKey / nextKey; Any fields return JSON bytes with @type for use with the json precompile.

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

Register the auth, authz, evidence, feegrant, mint, params, slashing,
and upgrade precompile addresses in giga's fail-fast list so calls to
them abort and fall back to v2 instead of executing as empty accounts.

Co-Authored-By: Claude Fable 5 <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 exposes many Cosmos module Query RPCs as EVM precompiles. The design and per-method implementations are largely solid and well-tested, but two consensus/gas issues block merge: (1) the new precompiles and new bank/gov/distribution methods are all registered at the existing head upgrade tag (v6.6) without a new tag or archival, so the same version has divergent behavior across binaries; and (2) the new gov query methods sit on the fixed-gas precompile path and undercharge for paginated work.

Findings: 4 blocking | 6 non-blocking | 3 posted inline

Blockers

  • Consensus/app-hash divergence from missing version bump (matches Codex P1). All 8 new precompiles register only latestUpgrade in their GetVersioned/setup.go, and the new view methods on bank/gov/distribution are added to the head implementation registered at the same latest tag. app/tags already ends at v6.6 and this PR does not add a new tag, so LatestUpgrade == v6.6 — an existing, already-live upgrade. GetCustomPrecompilesVersions (x/evm/keeper/keeper.go) resolves historical heights that are >= the v6.6 done-height to the v6.6 impl, so replaying past v6.6 blocks (or any node still on the old binary) now sees different precompile behavior (new addresses that were previously empty accounts; new bank/gov/distribution selectors that previously reverted) → app-hash mismatch. Per the repo's bump_version convention (and the author's own 'Notes for reviewers'), this must ride a NEW upgrade tag (e.g. v6.7) with the current bank/gov/distribution head impls archived as legacy versions, and the new precompiles registered at that new tag.
  • The new gov query methods are underpriced (matches Codex P1 #2 and confirmed): unlike the 8 new precompiles which use DynamicGasPrecompile (meter bounded from supplied EVM gas, actual work billed via GetRemainingGas), gov is built with the fixed-gas pcommon.NewPrecompile, and RequiredGas charges query methods only DefaultGasCost(input, false) (calldata cost). Paginated proposals/votes/deposits iterate up to the page limit and JSON-marshal per item for a flat EVM charge, letting a contract grief the block gas meter. Either migrate the gov query methods to the dynamic-gas path or charge gas proportional to work.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Test coverage gap: TestGetCustomPrecompiles in x/evm/keeper/keeper_test.go lists precompileDirs explicitly and omits all 8 new precompiles (auth, authz, evidence, feegrant, mint, params, slashing, upgrade), so their versions files are never validated by the consensus-version resolution test. Add the new directories.
  • gov Execute (precompiles/gov/gov.go:130) is the only reviewed precompile whose Execute lacks a defer recover(); the new query methods use bare type assertions (args[0].(uint64), and the voteWeighted anonymous-struct assertion). A decoder/ABI mismatch would surface as a raw Go panic instead of execution reverted as elsewhere. Low probability given ABI-driven decoding, but inconsistent with every sibling precompile.
  • gov getVote/getDeposit and the proposals voter/depositor filters resolve the queried address via GetSeiAddressFromArg, which errors for any address lacking an EVM association; votes/deposits cast by cosmos-native (unassociated) accounts cannot be queried and instead revert. Consider a read-path fallback to the casted address (as bank.accAddressFromArg does).
  • Pre-existing (not introduced here): distribution rewards (dispatched at distribution.go:157) does not call ValidateNonPayable, unlike every other query which routes through validateInput; a CALL with value>0 is silently accepted. Out of this PR's scope but worth a follow-up.
  • The Cursor second-opinion file (cursor-review.md) and REVIEW_GUIDELINES.md were both empty/absent — no Cursor pass output and no repo-specific guidelines were available to apply.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread precompiles/gov/gov.go Outdated
return 50000
} else if !p.IsTransaction(method.Name) {
// query methods are charged the default read gas cost
return pcommon.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] Blocker (gas underpricing): gov is built with the fixed-gas pcommon.NewPrecompile (line 99), so this query path charges only DefaultGasCost(input, false) (calldata cost) regardless of work done. The new paginated queries (proposals, votes, deposits) iterate up to the page limit and JSON-marshal each item, but that work is billed against the cosmos block gas meter and never reflected back into EVM gas. Every new precompile in this PR deliberately uses DynamicGasPrecompile + GetRemainingGas for exactly this reason. Migrate the gov query methods to the dynamic-gas path (or charge proportionally); as-is a contract can repeatedly call these for a flat ~1k gas and grief the block gas meter.

Comment thread precompiles/gov/gov.go Outdated
}
}

func (p PrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller common.Address, callingContract common.Address, args []interface{}, value *big.Int, readOnly bool, evm *vm.EVM, hooks *tracing.Hooks) (bz []byte, err error) {

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] Nit/robustness: unlike every other precompile touched here, gov's Execute has no defer recover(). The new query methods rely on bare type assertions (args[0].(uint64), and voteWeighted's anonymous-struct assertion at ~line 216). Consider adding the same recover guard so an unexpected decode mismatch reverts as execution reverted rather than panicking.

Comment thread precompiles/auth/setup.go
)

func GetVersioned(latestUpgrade string, keepers utils.Keepers) utils.VersionedPrecompiles {
return utils.VersionedPrecompiles{

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] Blocker (versioning): this (and every new precompile's setup.go, plus the new bank/gov/distribution head methods) registers behavior only at latestUpgrade, which is the already-existing v6.6 tag (app/tags is unchanged by this PR). Since v6.6 is a past/live upgrade, GetCustomPrecompilesVersions will resolve historical v6.6 heights to this new impl, changing already-committed execution (new addresses, new selectors) → app-hash divergence between old and new binaries. These consensus-visible additions need a new upgrade tag with the current impls archived as legacy versions, per the bump_version flow.

Replace gov's static Precompile wrapper (fixed 30k/50k gas per tx
method, flat read cost for queries) with the DynamicGasPrecompile
pattern used by bank/staking/distribution, so execution is metered
against actual Cosmos gas consumption via RunAndCalculateGas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5be3ea2. Configure here.

Comment thread precompiles/gov/gov.go
VotingEndTime: proposal.VotingEndTime.Unix(),
IsExpedited: proposal.IsExpedited,
Content: content,
}, 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.

Gov unset voting times wrong

Medium Severity

The gov proposal and proposals query paths encode VotingStartTime and VotingEndTime with time.Time.Unix() on the stored proposal. While a proposal is still in the deposit period, those fields are unset zero time.Time values in state, and Unix() on that value is a large negative second count—not zero. EVM callers expecting “not started yet” to mean 0 get incorrect timestamps.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5be3ea2. 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.

A large, well-structured, and thoroughly-tested PR that exposes Cosmos module Query RPCs as EVM precompile methods; the precompile logic itself is correct across all reviewed files, but the consensus-visible changes are attached to the already-latest v6.6 head version instead of a new upgrade tag, which will diverge app-hashes during a normal binary rollout. This must be gated behind the next upgrade before merge.

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

Blockers

  • Consensus fork risk from in-place v6.6 change. All new/extended precompile behavior is added to the head NewPrecompile, which SetCustomPrecompiles maps to latestUpgrade (v6.6) — and there is no frozen v66 legacy snapshot (bank/gov/distribution legacy dirs stop at v65). At runtime, non-tracing execution always uses versioned[latestUpgrade] (x/evm/keeper/keeper.go:166), so merging this changes what the already-released v6.6 upgrade does: nodes on the new binary would expose the new query methods and meter gov gas differently (static→DynamicGas migration) at the same v6.6 heights as nodes on the old binary, producing an app-hash divergence / chain halt during a routine rollout. Run scripts/bump_version to cut the next tag (e.g. v6.7) so the current head is archived as the v6.6 legacy implementation and the new methods gate on the next upgrade height. The PR is labeled app-hash-breaking and the author's own reviewer notes acknowledge this ("If v6.6 is already live, they should ride the next upgrade tag"); it must be resolved before merge. This spans every touched precompile: the 8 new versions files (each a lone v6.6) and the extended bank/distribution/gov methods.

Non-blocking

  • Second-opinion inputs: cursor-review.md and REVIEW_GUIDELINES.md are both empty (no Cursor findings and no repo-specific guidelines were available for this pass); OpenAI Codex produced one finding, which matches the version-gating blocker above.
  • Consistency nit: the new gov query methods do not have the per-method defer recover() that the distribution and the 8 new precompiles use, and neither RunAndCalculateGas nor Run recovers. It is harmless today (gov's assertions are on ABI-decoded args and its querier responses are value types with no nil derefs) and matches the pre-existing gov behavior, but adding a top-level recover in gov's Execute would make gas-meter/panic handling uniform across precompiles.
  • Documentation: several queries (auth account, authz grants/granterGrants/granteeGrants, feegrant, distribution delegation queries) require the queried EVM address to be association-mapped, so they cannot target Sei accounts that have no EVM association. This is consistent with existing precompiles but is a real limitation worth calling out in the Solidity NatSpec/user docs. (No security issue: all exposed params/state are already public on-chain.)

Comment thread precompiles/setup.go
Comment on lines 66 to 79
ecommon.HexToAddress(pointerview.PointerViewAddress): pointerview.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(p256.P256VerifyAddress): p256.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(solo.SoloAddress): solo.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(auth.AuthAddress): auth.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(authz.AuthzAddress): authz.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(evidence.EvidenceAddress): evidence.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(feegrant.FeegrantAddress): feegrant.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(mint.MintAddress): mint.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(params.ParamsAddress): params.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(slashing.SlashingAddress): slashing.GetVersioned(latestUpgrade, keepers),
ecommon.HexToAddress(upgrade.UpgradeAddress): upgrade.GetVersioned(latestUpgrade, keepers),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 precompiles/setup.go wires all 8 new precompiles (auth, authz, evidence, feegrant, mint, params, slashing, upgrade) and the new bank/gov/distribution query methods only at latestUpgrade, which still resolves to the existing v6.6 tag since app/tags is unchanged by this PR. No legacy snapshot preserves the pre-PR v6.6 behavior, so if v6.6 has already produced committed blocks (as the PR's own reviewer notes and the app-hash-breaking label both suggest), a post-PR binary will replay those historical blocks with the new precompiles/methods active, producing a different app hash than a pre-PR binary — this is a consensus-breaking divergence, not merely a stylistic gap. Fix: cut a new upgrade tag (e.g. v6.7) and archive the current pre-PR implementations as v6.6 legacy versions via the bump_version flow before landing this new consensus surface.

Extended reasoning...

The bug. precompiles/setup.go (GetCustomPrecompiles) registers every new precompile added by this PR — auth, authz, evidence, feegrant, mint, params, slashing, upgrade — plus the new query methods added to bank, gov, and distribution, via each package's GetVersioned(latestUpgrade, keepers). Every one of those new setup.go files (e.g. precompiles/auth/setup.go) contains a single entry: latestUpgrade: check(NewPrecompile(keepers)), with no separate legacy snapshot archiving the pre-PR behavior. latestUpgrade is app.LatestUpgrade, computed in app/upgrades.go as the last line of app/tags — which is v6.6, and this PR does not touch app/tags (confirmed: the v6.6 line was added by a prior, unrelated commit, 8ebedda/#3665).\n\nWhy this matters for consensus. In x/evm/keeper/keeper.go, SetCustomPrecompiles builds k.latestCustomPrecompiles[addr] = versioned[latestUpgrade] for every address unconditionally, and CustomPrecompiles(ctx) returns that map for all normal (non-tracing) execution — i.e. every live transaction and every block replay uses the head implementation keyed at v6.6, regardless of the block's actual height or which upgrade was active when that block was originally produced. GetCustomPrecompilesVersions (used during tracing/replay) resolves a historical block to the version whose tag is <= the block's closest upgrade name, so any block already committed under v6.6 will now resolve to this PR's new code on a post-PR binary. There is no in-binary height gate protecting already-finalized blocks.\n\nConcrete evidence this is not hypothetical. precompiles/bank/legacy/v66/ already exists in the repo (added in #3665, well before this PR, and untouched by it) and contains a frozen snapshot of bank.go from before this PR — it has no SpendableBalances/denomMetadata/etc. methods. That legacy snapshot's existence is itself proof that v6.6 was already a cut, coordinated upgrade tag with its own fixed precompile behavior before this PR landed. But precompiles/bank/setup.go's GetVersioned maps latestUpgrade (v6.6) straight to the current bank.NewPrecompile, which now includes this PR's new methods — the old, frozen legacy/v66 snapshot is orphaned and unreferenced. So there are now two incompatible definitions of what "v6.6" precompile behavior means: the frozen legacy snapshot (correct, pre-PR) and the live head mapping in setup.go (incorrect, post-PR) — and the live mapping wins.\n\nConcrete divergence walkthrough. (1) Pre-PR binary is running at height H under upgrade tag v6.6; a STATICCALL to 0x...100D (the new auth precompile address) returns empty/no-code because that address is unallocated. (2) This PR merges without a new tag. (3) A post-PR binary starts up; latestUpgrade is still v6.6, so 0x...100D now resolves to the new auth precompile via GetCustomPrecompiles. (4) If any validator or full node replays/re-executes block H (or any block at/after the v6.6 upgrade height) with the post-PR binary — normal operation for state sync, IBC relayer verification, an archive node, or simply a validator that upgrades their binary without a coordinated halt height — the STATICCALL now succeeds and returns ABI-encoded account data instead of nothing. Any contract logic gated on that call's success/failure, or the exact returndata, produces a different state root than a node still running the pre-PR binary (or than the original block execution) → app-hash mismatch.\n\nWhy existing code doesn't already prevent this. The repo's own versioning discipline () exists precisely to solve this: every previously-shipped consensus-affecting precompile change archives the outgoing head implementation as a legacy version keyed to the tag it shipped under, and only the new tag gets the new head behavior (see precompiles/bank/setup.go's full legacy chain back to v5.5.2, or precompiles/staking/setup.go). This PR's new setup.go files skip that step entirely and land the new consensus surface directly on the existing head tag.\n\nThe fix. Run (or manually replicate) the bump_version flow: add a new tag (e.g. v6.7) to app/tags, and for every touched package (bank, gov, distribution, plus the 8 new precompiles) archive the current pre-PR implementation as the v6.6 legacy entry while pointing latestUpgrade at the new head. The PR's own "Notes for reviewers" section already flags this exact risk ("If v6.6 is already live, they should ride the next upgrade tag") but the tag was never bumped, so as merged the risk is unresolved.\n\nThis was independently flagged as a blocker by three separate verification passes and by the automated seidroid reviewer bot on the PR itself, all converging on the same root cause and fix.

Comment thread precompiles/gov/gov.go
Comment thread precompiles/gov/gov.go
Comment on lines +120 to +145

func (p PrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller common.Address, callingContract common.Address, args []interface{}, value *big.Int, readOnly bool, evm *vm.EVM, suppliedGas uint64, hooks *tracing.Hooks) (bz []byte, remainingGas uint64, err error) {
if ctx.EVMPrecompileCalledFromDelegateCall() {
return nil, errors.New("cannot delegatecall gov")
return nil, 0, errors.New("cannot delegatecall gov")
}

switch method.Name {
case ProposalQueryMethod:
return p.proposalQuery(ctx, method, args, value)
case ProposalsQueryMethod:
return p.proposalsQuery(ctx, method, args, value)
case VoteQueryMethod:
return p.voteQuery(ctx, method, args, value)
case VotesQueryMethod:
return p.votesQuery(ctx, method, args, value)
case ParamsQueryMethod:
return p.paramsQuery(ctx, method, args, value)
case DepositQueryMethod:
return p.depositQuery(ctx, method, args, value)
case DepositsQueryMethod:
return p.depositsQuery(ctx, method, args, value)
case TallyResultQueryMethod:
return p.tallyResultQuery(ctx, method, args, value)
}

if readOnly {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 gov's Execute (precompiles/gov/gov.go) has no defer recover(), unlike every other precompile added or touched in this PR (auth, authz, bank, distribution, evidence, feegrant, mint, params, slashing, upgrade), all of which convert panics into a clean execution reverted: ... error. gov now dispatches to new query handlers (proposalsQuery, votesQuery, depositsQuery, voteWeighted's anonymous-struct assertion, etc.) that do bare type assertions and JSON-marshal Any-typed data, so an unexpected panic there propagates unrecovered instead of reverting like its siblings; recommend adding the same recover guard for consistency and defense-in-depth.

Extended reasoning...

What the bug is: gov.PrecompileExecutor.Execute (precompiles/gov/gov.go) has no top-level defer func(){ if r := recover(); r != nil { err = ... } }(). Every other precompile touched by this PR — auth, authz, bank, distribution, evidence, feegrant, mint, params, slashing, upgrade — wraps its Execute in exactly that pattern. gov is the one precompile in this PR that dispatches to brand-new, non-trivial query handlers (proposalQuery, proposalsQuery, voteQuery, votesQuery, paramsQuery, depositQuery, depositsQuery, tallyResultQuery, plus the pre-existing voteWeighted anonymous-struct assertion) without that safety net.

Code path: These handlers do bare type assertions on ABI-decoded args (args[0].(uint64), args[1].([]byte), the voteWeighted anonymous struct cast) and call p.cdc.MarshalAsJSON(proposal.Content) on protobuf Any values pulled straight from chain state. If any of that ever panics — a future maintenance bug, a codec edge case, an unregistered Any type, or any other unanticipated runtime panic — the panic unwinds through Execute, through pcommon.Precompile.Run (precompiles/common/precompiles.go), which also does not recover (its deferred func only maps a non-nil err to vm.ErrExecutionReverted), and keeps propagating up the EVM call stack.

Why existing code doesn't prevent it: gov is unique among the PR's precompiles in omitting the recover wrapper. Nothing else in the call chain (EVM interpreter, Precompile.Run) catches the panic on gov's behalf — it's designed to rely on the precompile executor doing its own recovery, which every sibling does and gov does not.

Impact, correcting the bug's original framing: the panic source the sibling precompiles' comments call out (// Needed to catch gas meter panics) does not apply to gov as currently wired: gov uses the fixed-gas pcommon.NewPrecompile path (not DynamicGasPrecompile), and during EVM tx execution the cosmos gas meter is swapped for sdk.NewInfiniteGasMeterWithMultiplier (x/evm/keeper/msg_server.go), so it cannot OOG-panic the way a bounded DynamicGasPrecompile meter can. Realistic panic triggers are also narrow: ABI-decoded args are type-safe by construction, and the gRPC query servers return errors rather than panicking on bad input. Should a panic occur anyway, cosmos baseapp's own recover in runTx will catch it — so there's no crash risk — but the failure surfaces as an ABCI-level tx failure instead of a controlled EVM revert, which is a worse (and inconsistent-with-siblings) failure mode for any contract that expects a normal execution reverted from a bad gov query call.

Fix: add the same defer func(){ if r := recover(); r != nil { err = fmt.Errorf("execution reverted: %v", r) } }() at the top of gov's Execute, matching every sibling precompile in this PR, for consistency and cheap defense-in-depth.

Step-by-step proof of the gap (not of an OOG trigger):

  1. gov.NewPrecompile builds p := &PrecompileExecutor{...} and returns pcommon.NewPrecompile(newAbi, p, p.address, "gov") — the fixed-gas wrapper, confirmed by grep against precompiles/gov/gov.go.
  2. Execute switches on method.Name and calls e.g. p.proposalsQuery(ctx, method, args, value), which calls p.convertProposal(proposal) for each result, which calls p.cdc.MarshalAsJSON(proposal.Content).
  3. Suppose (hypothetically, for illustration) a proposal's Content Any holds a type not registered in the interface registry at the time of marshaling — MarshalAsJSON internals could panic rather than return an error in some SDK codec paths.
  4. That panic unwinds out of convertProposal, out of proposalsQuery, out of Execute — none of which have a recover — and into pcommon.Precompile.Run, which also has no recover (grep-confirmed against precompiles/common/precompiles.go).
  5. Contrast with auth.accounts (precompiles/auth/auth.go), which does the analogous p.cdc.UnpackAny/iteration under its own defer recover() in Execute — the same class of panic there is caught and converted to fmt.Errorf("execution reverted: %v", r), giving the caller a normal EVM revert instead of an ABCI-level failure.
  6. Net effect: gov's query surface is the one precompile in this PR without that safety net, purely due to an oversight, not a structural need (there's no gas-meter panic to catch here as bug_001/verifiers established).

🔬 also observed by seidroid

Comment thread precompiles/auth/auth.go
Comment on lines +80 to +97
func (p PrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller common.Address, callingContract common.Address, args []interface{}, value *big.Int, readOnly bool, evm *vm.EVM, suppliedGas uint64, hooks *tracing.Hooks) (bz []byte, remainingGas uint64, err error) {
// Needed to catch gas meter panics
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("execution reverted: %v", r)
}
}()
switch method.Name {
case AccountMethod:
return p.account(ctx, method, args, value)
case AccountsMethod:
return p.accounts(ctx, method, args, value)
case ParamsMethod:
return p.params(ctx, method, args, value)
case NextAccountNumberMethod:
return p.nextAccountNumber(ctx, method, args, value)
}
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 auth's nextAccountNumber() is declared a Solidity view method, but its handler calls AccountKeeper.GetNextAccountNumber, which unconditionally increments and persists the global account-number counter on every invocation. Because Execute() never checks readOnly for this method and the write goes through the cosmos KVStore rather than the EVM StateDB, a plain eth_call or STATICCALL to this selector still mutates committed chain state and permanently burns an account number each time it is called, while RequiredGas still prices it as a flat read. Fix by reading the counter without incrementing it (or otherwise not exposing this mutating keeper method as a view).

Extended reasoning...

The bug. precompiles/auth/auth.go declares nextAccountNumber as a view method in both Auth.sol and abi.json, and Execute() (lines 80-97) dispatches it unconditionally to nextAccountNumber() without any readOnly check — unlike, for example, the sibling bank/gov precompiles in this same PR, which explicitly reject state-changing tx methods when readOnly is true. nextAccountNumber() calls p.authQuerier.NextAccountNumber(sdk.WrapSDKContext(ctx), ...), which resolves to AccountKeeper.NextAccountNumber in sei-cosmos/x/auth/keeper/grpc_query.go, which in turn calls AccountKeeper.GetNextAccountNumber(ctx). That keeper method's own doc comment says exactly what it does: "returns and increments the global account number counter." It performs an unconditional store.Get followed by store.Set(types.GlobalAccountNumberKey, accNumber+1) on every single call — this is the exact same counter that NewAccountWithAddress/NewAccount consume when a real account is created.

Why this isn't caught by normal precompile safety machinery. Cosmos SDK gRPC query handlers are typically safe to expose as EVM view methods because they only read from the KVStore. This one is an exception: it mutates as a side effect of "reading." Because the mutation happens directly against the live transaction's ctx.KVStore(...) (the same context backing the EVM StateDB during precompile execution, per RunAndCalculateGas in precompiles/common/precompiles.go), it bypasses go-ethereum's STATICCALL read-only guard entirely — that guard only prevents writes made through the EVM StateDB interface, not writes made directly against the underlying cosmos store by a precompile's own Go code. So a contract can STATICCALL address 0x...100D with the nextAccountNumber selector, and even though STATICCALL is supposed to guarantee no state changes are possible, the counter still advances.

Impact. (1) Any eth_call/staticcall-based read of nextAccountNumber — including routine, repeated polling from an off-chain dashboard or another contract's read-only helper — permanently advances real committed chain state with no account ever created for the burned number; (2) it violates the fundamental EVM invariant that a view-declared function (and specifically a STATICCALL) cannot mutate state, which off-chain tooling and other contracts rely on; (3) RequiredGas (pcommon.DefaultGasCost(input, p.IsTransaction(method.Name))) prices this as a flat read because IsTransaction always returns false in this file, so the KVStore write is under-priced relative to the work actually performed.

Concrete walkthrough. Suppose the current global account number is N. A contract issues STATICCALL 0x...100D with calldata for nextAccountNumber(). Execute() dispatches to nextAccountNumber() with no readOnly check → authQuerier.NextAccountNumberGetNextAccountNumber(ctx) → reads N, then does store.Set(GlobalAccountNumberKey, N+1) → returns N. The call succeeds (STATICCALL did not revert, as go-ethereum's guard never triggered because the write bypassed StateDB), and the counter is now N+1 in committed state. Repeat the same STATICCALL again: it now returns N+1 and advances the counter to N+2. No account was ever created at any point, yet two account numbers were consumed — purely from what callers and tooling believe are side-effect-free reads.

Fix. Either drop this method from the precompile, or read the raw counter value from the store without performing the increment/store.Set (i.e., implement a true non-mutating read rather than reusing the keeper's read-and-increment helper).

Comment thread precompiles/bank/bank.go
Comment on lines +418 to +440
func (p PrecompileExecutor) spendableBalances(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) {
if err := pcommon.ValidateNonPayable(value); err != nil {
return nil, 0, err
}

if err := pcommon.ValidateArgsLength(args, 2); err != nil {
return nil, 0, err
}

seiAddr, err := pcommon.GetSeiAddressFromArg(ctx, args[0], p.evmKeeper)
if err != nil {
return nil, 0, err
}

req := &banktypes.QuerySpendableBalancesRequest{
Address: seiAddr.String(),
Pagination: &query.PageRequest{
Key: args[1].([]byte),
},
}

resp, err := p.bankQuerier.SpendableBalances(sdk.WrapSDKContext(ctx), req)
if err != 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.

🟡 bank.spendableBalances resolves its address argument with the strict pcommon.GetSeiAddressFromArg, which reverts with an association-missing error if the EVM address has no explicit Sei association. Its siblings balance()/all_balances() in the same precompile fall back to the deterministic cast address in that case, so a funded-but-never-associated EVM address succeeds against balance()/all_balances() but reverts against spendableBalances() for the same account. Consider resolving the address the same way as balance/all_balances (accAddressFromArg with cast fallback) for consistency.

Extended reasoning...

spendableBalances (precompiles/bank/bank.go:418-440) resolves args[0] via pcommon.GetSeiAddressFromArg, which calls GetSeiAddressByEvmAddress (precompiles/common/precompiles.go:286-293). That helper returns types.NewAssociationMissingErr whenever the EVM address has no explicit Sei association recorded via SetAddressMapping — there is no fallback.

By contrast, the two pre-existing sibling query methods in the very same precompile, balance() and all_balances(), resolve their address argument through the file's own accAddressFromArg helper (bank.go, near the bottom of the file), which explicitly falls back to the deterministic cast address when no association is found:

seiAddr, found := p.evmKeeper.GetSeiAddress(ctx, addr)
if !found {
    // return the casted version instead
    return sdk.AccAddress(addr[:]), nil
}

Per x/evm/AGENTS.md, EVM addresses without an explicit Associate transaction still hold real bank balances at their deterministic cast Sei address — this is a first-class, documented way accounts interact with bank state pre-association. balance()/all_balances() were written to support querying those cast-address balances without requiring association; spendableBalances(), added by this PR as the paginated analog of all_balances(), does not follow that same convention.

Concrete trigger: fund a fresh, never-associated EVM address (e.g. via a bank.send precompile call or a genesis allocation to its cast address) with some usei. Calling bank.balance(addr, "usei") succeeds and returns the funded amount, and bank.all_balances(addr) succeeds too — but bank.spendableBalances(addr, []) on the exact same address reverts with an association-missing error, even though the address indisputably has a nonzero spendable balance (a cast/plain account has no vesting, so spendable == total, meaning the cast-fallback path would return the correct data if used).

The PR's own test, TestSpendableBalancesQuery, does not catch this because it calls k.SetAddressMapping(ctx, seiAddr, evmAddr) before querying, which masks the unassociated/cast-address case entirely.

Fix: resolve the address in spendableBalances via the same accAddressFromArg helper (or an equivalent cast-fallback) used by balance/all_balances, instead of the strict pcommon.GetSeiAddressFromArg.

This does not block merge: it fails closed with a clear, well-formed revert (no incorrect data, no crash, no state corruption), and a caller can always work around it by associating first or by using all_balances to get the same data. The PR description also states that "account-typed query inputs take EVM address and require address association," which is the strict convention deliberately used by every other new query method in this PR (auth, authz, feegrant, distribution, gov). The issue here is narrower and specific to bank: the author's stated rationale for spendableBalances was consistency with "existing bank precompiles," but that's factually inverted for bank's own balance/all_balances, so the divergence is a legitimate intra-precompile consistency gap worth fixing, not a merge blocker.

Comment thread precompiles/gov/gov.go
Comment on lines +679 to +699
func (p PrecompileExecutor) convertProposal(proposal govtypes.Proposal) (ProposalData, error) {
content := []byte{}
if proposal.Content != nil {
bz, err := p.cdc.MarshalAsJSON(proposal.Content)
if err != nil {
return ProposalData{}, err
}
content = bz
}
return ProposalData{
Id: proposal.ProposalId,
Status: int32(proposal.Status),
FinalTallyResult: convertTallyResult(proposal.FinalTallyResult),
SubmitTime: proposal.SubmitTime.Unix(),
DepositEndTime: proposal.DepositEndTime.Unix(),
TotalDeposit: convertCoins(proposal.TotalDeposit),
VotingStartTime: proposal.VotingStartTime.Unix(),
VotingEndTime: proposal.VotingEndTime.Unix(),
IsExpedited: proposal.IsExpedited,
Content: content,
}, 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.

🟡 convertProposal (used by both the new proposal and proposals gov precompile queries) unconditionally calls .Unix() on proposal.VotingStartTime/VotingEndTime. For any proposal still in the deposit period these fields are the Go zero-value time.Time{}, whose .Unix() is -62135596800, not 0 — so EVM callers get a large, misleading sentinel instead of an expected 0 meaning "voting hasn't started". Fix by guarding with IsZero() and emitting 0.

Extended reasoning...

The bug. precompiles/gov/gov.go's convertProposal (used by both the proposal(uint64) and proposals(...) query methods added in this PR) builds the ProposalData struct with:

VotingStartTime: proposal.VotingStartTime.Unix(),
VotingEndTime:   proposal.VotingEndTime.Unix(),

In sei-cosmos, govtypes.Proposal.VotingStartTime and VotingEndTime are value-type (non-pointer, non-nullable) time.Time fields. They are only ever populated once a proposal exits the deposit period and enters voting, in Keeper.ActivateVotingPeriod (sei-cosmos/x/gov/keeper/proposal.go: proposal.VotingStartTime = ctx.BlockHeader().Time; proposal.VotingEndTime = ...Add(votingPeriod)). NewProposal (sei-cosmos/x/gov/types/proposal.go), which is what runs at submission time, only sets SubmitTime and DepositEndTime — it leaves both voting-time fields as the Go zero value time.Time{}.

Why this manifests, and why it's not benign. time.Time{}.Unix() is not 0 — it's -62135596800 (the number of seconds between January 1, year 1 and the Unix epoch). This is a basic Go footgun: the zero value of time.Time does not round-trip through .Unix() to 0. So any EVM caller querying a proposal that is still in StatusDepositPeriod (the initial state of every proposal, not an edge case) via the new proposal or proposals precompile methods receives votingStartTime/votingEndTime == -62135596800 in the packed int64 ABI fields.

Concrete proof.

  1. A proposal is submitted via submitProposal; govKeeper.SubmitProposalNewProposal creates it with Status = StatusDepositPeriod, SubmitTime and DepositEndTime set, but VotingStartTime/VotingEndTime left as time.Time{}.
  2. A contract calls gov.proposal(proposalID) before the deposit threshold is met.
  3. proposalQueryconvertProposal runs proposal.VotingStartTime.Unix().
  4. In Go, time.Time{}.Unix() evaluates to -62135596800, confirmed by running it directly (year-1-to-epoch offset), not 0.
  5. The packed response therefore contains votingStartTime = -62135596800 instead of the natural "not started" sentinel of 0.

Why nothing in the existing code path catches this. There is no IsZero() guard anywhere in convertProposal, and the proto/JSON round-trip for stdtime fields preserves the zero-time value unchanged (it doesn't get special-cased to zero on either the cosmos or EVM side). SubmitTime/DepositEndTime don't have this problem because NewProposal always sets them, so this is specific to the two voting-period fields.

Impact. This is read-only (view) data with no consensus-safety or fund-safety implications — every node computes the same deterministic value, so there's no app-hash risk. But it's a genuinely confusing return value for any consumer: a contract or off-chain caller checking votingStartTime == 0 to mean "not started" will get it wrong, and a caller naively feeding the value into time.Unix(votingStartTime, 0) will get a nonsensical timestamp (year 1) instead of anything meaningful. Since this is a brand-new, consensus-visible query API being introduced in this PR, it's worth fixing before it ships, since correcting the sentinel later constitutes a change to already-committed precompile output.

Fix. Guard both fields with IsZero() in convertProposal and emit 0 for unset times, e.g.:

votingStartTime := int64(0)
if !proposal.VotingStartTime.IsZero() {
    votingStartTime = proposal.VotingStartTime.Unix()
}
// same for VotingEndTime

@codchen
codchen marked this pull request as draft July 17, 2026 07:48
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