Skip to content

fix: transaction value uint64 overflow and Float32 precision loss#236

Merged
leobago merged 10 commits into
masterfrom
dev
Mar 10, 2026
Merged

fix: transaction value uint64 overflow and Float32 precision loss#236
leobago merged 10 commits into
masterfrom
dev

Conversation

@Zyra-V21
Copy link
Copy Markdown
Collaborator

@Zyra-V21 Zyra-V21 commented Mar 3, 2026

Summary

  • Fix silent uint64 overflow in transaction value parsing that caused all transactions > 18.44 ETH to store wrong values
  • Fix Float32 precision loss in ClickHouse storage (only ~7 significant digits for values up to 10^26)
  • Add migration 000035 to ALTER f_value from Float to String

Root cause

  1. parsedTx.Value().Uint64() in pkg/spec/transactions.go:130 — Go's (*big.Int).Uint64() silently returns only the low 64 bits, wrapping values > 2^64 wei (~18.44 ETH)
  2. float32(transaction.Value) in pkg/db/transactions.go:77 — casting to Float32 loses precision beyond ~7 significant digits
  3. f_value Float in the ClickHouse schema — Float32 is inadequate for wei amounts

Example: tx 0x1b7b...22719 (slot 13,587,965)

Source Value (wei) ETH
EL node (real) 33,066,915,805,160,912,602 33.07
Our DB (before fix) 14,620,172,000,000,000,000 14.62
Our DB (after fix) 33,066,915,805,160,912,602 33.07
Beaconcha.in 33,066,915,800,000,000,000 33.07

Overflow math: 33066915805160912602 % 2^64 = 14620171731451360986 → Float32 → 14620172000000000000

Evidence the bug is real

  • The max f_value in the entire production DB is 18.4467 ETH — exactly at the uint64 ceiling
  • Zero transactions above that threshold exist, which is statistically impossible on Ethereum
  • In a test run of just ~48 slots, 10+ transactions exceeded uint64 max (up to 4,000 ETH)

Changes

  • pkg/spec/transactions.go: Value field changed from uint64 to *big.Int; parsing uses new(big.Int).Set() instead of .Uint64()
  • pkg/db/transactions.go: Column type changed from proto.ColFloat32 to proto.ColStr; serialization uses .String() instead of float32() cast
  • pkg/db/migrations/000035_alter_tx_value_string.{up,down}.sql: ALTER COLUMN migration

Test plan

  • Built and ran fixed binary locally against slot 13,587,965 using SSH tunnels to eth-archive BN/EL
  • Verified tx 0x1b7b...22719 now stores 33,066,915,805,160,912,602 wei (matches EL node exactly)
  • Verified transactions > uint64 max are now stored correctly in test DB
  • Run on production after merging and rebuilding the live goteth container

Closes #235

Zyra-V21 added 7 commits March 3, 2026 17:24
Preparing to change the Value field from uint64 to *big.Int to fix
the silent uint64 overflow that wraps transaction values > 18.44 ETH.

Fixes #235
uint64 max is ~18.44 ETH in wei. Any transaction value above that
silently wrapped around via Go's (*big.Int).Uint64() which returns
only the low 64 bits. For example, a 33.07 ETH tx was stored as
14.62 ETH because 33066915805160912602 % 2^64 = 14620171731451360986.

Using *big.Int preserves the full 256-bit Ethereum value.

Fixes #235
parsedTx.Value() returns *big.Int. The old code called .Uint64() which
silently discards bits above 64, causing wraparound for values > 2^64 wei.
Now we copy the full big.Int value with no truncation.

Fixes #235
Float32 has only ~7 significant digits of precision, which is wholly
inadequate for wei values (up to 10^26+). Storing as string preserves
the exact decimal representation of the big.Int value without any
precision loss or overflow risk.

Fixes #235
The old code did float32(transaction.Value) which:
1. Truncated the uint64 to float32 (~7 digits precision)
2. The uint64 was already wrong due to big.Int overflow

Now we call .String() on the *big.Int to get the exact decimal
representation, which ClickHouse can store losslessly as String.

Fixes #235
… String

ClickHouse Float (Float32) only has ~7 significant digits, causing
precision loss for wei values. String preserves the exact decimal
representation of any Ethereum value without overflow or truncation.

Existing Float32 data will be auto-converted to its string representation
by ClickHouse during the ALTER. Note: already-corrupted values from the
uint64 overflow cannot be recovered by this migration alone — they need
a separate data backfill.

Fixes #235
…ion loss in transaction values

Transaction values > 18.44 ETH (2^64 wei) were silently truncated by
Go's (*big.Int).Uint64(), then further degraded by Float32 storage.
Now stored as String via *big.Int for exact precision.

Fixes #235
Zyra-V21 added 3 commits March 9, 2026 12:43
… drops

HeadChan was unbuffered (pkg/events/standard.go:36), meaning the
non-blocking send in pkg/events/head.go:40-45 would silently drop head
events whenever the consumer in runHead() was busy processing a previous
event.

A buffer of 32 slots gives the consumer a full epoch of breathing room,
preventing head event drops during processing spikes. This is the first
half of the fix for impossible VRS records (f_reward > f_max_reward)
documented in issue #238 — dropped epoch-boundary head events caused
stale state roots to be used for reward calculation.
Previously, every head event overwrote the epoch boundary state root
cache (lines 83-84), even mid-epoch events. When the actual last-slot
head event was dropped (due to the unbuffered HeadChan fixed in the
previous commit), a stale mid-epoch state root was used for the beacon
state download. This caused withdrawal double-counting in reward
calculation, producing f_reward > f_max_reward records.

Now the state root is only cached when event.HeadEvent.Slot matches the
last slot of the epoch, ensuring DownloadState always fetches the correct
epoch-boundary state. This is the second half of the fix for issue #238,
which produced 64 impossible VRS records across epochs 431671, 431724,
432097, and 432474.
Two-part fix for issue #238 where 64 impossible VRS records were produced
across epochs 431671, 431724, 432097, and 432474:

1. Buffer HeadChan to 32 slots to prevent silent head event drops
2. Only cache state root at actual epoch boundary slot

Verified locally: epoch 431671 reprocessed with 0 impossible rewards
across 955,583 validators.
@Zyra-V21
Copy link
Copy Markdown
Collaborator Author

Zyra-V21 commented Mar 9, 2026

Added: Fix for impossible VRS records (issue #238)

Two commits merged to dev that fix f_reward > f_max_reward in t_validator_rewards_summary:

  1. dc82fe8 — Buffer HeadChan to 32 slots to prevent silent head event drops (pkg/events/standard.go)
  2. 1911ecc — Only cache state root at actual epoch boundary slot (pkg/analyzer/routines.go)

Fixes #238 — verified locally: epoch 431671 reprocessed with 0 impossible rewards across 955,583 validators.

@leobago leobago self-requested a review March 10, 2026 14:35
Copy link
Copy Markdown
Member

@leobago leobago left a comment

Choose a reason for hiding this comment

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

LGTM

@leobago leobago merged commit 80a735e into master Mar 10, 2026
@Zyra-V21 Zyra-V21 mentioned this pull request May 7, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transaction value overflow: f_value silently wraps around for amounts > 18.44 ETH

2 participants