Skip to content

fix(common/countdown,params): correct the exponential timeout arithmetic and validate its bounds - #2836

Open
gzliudan wants to merge 3 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-countdown-round-diff-truncation
Open

gzliudan wants to merge 3 commits into
XinFinOrg:dev-upgradefrom
gzliudan:fix-countdown-round-diff-truncation

Conversation

@gzliudan

@gzliudan gzliudan commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Three defects in the XDPoS v2 exponential timeout. All of them are dormant on every network shipped in this repository (base=1, maxExponent=0), and all are covered by unit tests.

ExpTimeoutDuration.GetTimeoutDuration derived the exponent of the exponential timeout from a uint8 conversion of the round difference, which truncates it modulo 256 (types.Round is uint64). For every roundDiff of 257 or more whose low 8 bits fall in [1, maxExponent] the exponent came out too small; at roundDiff = 257 it became 0 and the timeout collapsed to duration (the minimum) instead of saturating at maxExponent. The window recurs every 256 rounds.

The same expression multiplied duration * base^exp in int64 without a range check, so a large base overflowed and wrapped around to a negative timeout — which masked the truncation above once the difference outgrew the int64 budget, and would have retriggered the timer immediately.

validateV2ExpTimeoutConfig bounded max_exponent and base^max_exponent but never checked that the resulting timeout fits in a time.Duration. The engine converts the period with time.Duration(TimeoutPeriod) * time.Second and common/countdown then multiplies by base^exp; when either step overflows, the timer saturates at math.MaxInt64 (~292 years) and never fires, so the pacemaker silently stops.

Changes

  • compute the round difference in uint64 and clamp the exponent afterwards;
  • saturate the product at math.MaxInt64 instead of wrapping around;
  • bound both steps of the timeout conversion in whole seconds — the period conversion and the product with base^max_exponent — and reject an unrepresentable config at startup;
  • correct the TimeoutPeriod comment, which claimed milliseconds while every consumer multiplies by time.Second.

Reachability today

All built-in configurations (params/config_networks.go) use ExpTimeoutConfig{Base: 1.0, MaxExponent: 0}, so the exponent is always 0 and none of the defects can manifest on mainnet, testnet or devnet. They become observable only on a custom chain that enables the exponential backoff (Base >= 2, MaxExponent >= 1).

roundDiff only grows while the engine advances rounds through timeout certificates without applying a newer QC (for example when processQC aborts with "Block not found using the QC"), so reaching 257 requires a long stretch of that failure mode.

The config check only affects a custom genesis whose own maximum exponential timeout does not fit in a time.Duration; such a chain used to start and then stall silently, and now fails at startup.

Tests

  • TestExpDurationRoundDiff: a table of (maxExponent, roundDiff) pairs over maxExponent = 0, 3, 31, with roundDiff spanning below and beyond the uint8 wrap point (1, 2, 4, 5, 32, 256, 257, 258, 259, 513), asserting the intended saturated value everywhere. It fails against the previous implementation wherever a round difference of 257 or more has low 8 bits in [1, maxExponent] (e.g. the maxExponent = 3 rows at roundDiff = 257, 258, 259, 513) and passes now.
  • TestExpDurationOverflowSaturates: 59s * 2^27 stays exact, 59s * 2^28 and beyond saturate at math.MaxInt64 (previously negative), including the roundDiff = 257 case where both defects combine. At the exact int64 boundary (MaxInt64/3 period multiplier) the product stays exact instead of saturating.
  • TestValidateV2ExpTimeoutConfigDurationRange: walks the production CheckConfigForkOrder() path and asserts both field paths (XDPoS.V2.CurrentConfig.ExpTimeoutConfig and XDPoS.V2.AllConfigs[10].ExpTimeoutConfig); it covers the shrinking-base case (a combined check used to let an overflowing period through when base < 1), the exact seconds boundary (9,223,372,036 s fits, 9,223,372,037 s does not), the exact product boundary (3 s * 3074457345 fits) and the AllConfigs loop.

Verification: make all, gofmt/goimports, golangci-lint run --new-from-rev=eae4c6a7f4 ./common/countdown/... ./params/... (0 issues), go test -count=1 ./common/countdown/... ./params/... ./core/... ./consensus/... (all pass).

No private-network run: the fix is dormant for every built-in configuration (see Reachability above), and the defects are fully covered by the unit tests.

Upstream

go-ethereum has no common/countdown package and no equivalent chain-config check (git grep and git log -S against origin/master at 8817f99f9 both return nothing), so there is no upstream fix to port. The closest precedent is geth ethereum#31407 (eth/catalyst: sanitize simulated beacon period to avoid overflowing time.Duration), which clamps the period with an integer bound instead; this branch rejects the config at startup and saturates the runtime value as a backstop.

Merge order

#2831 (fix-countdown-race) is already merged into dev-upgrade; this branch is based on top of it and keeps the locked parameter snapshot together with the uint64/saturation arithmetic.

close #2835

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 87936ccc-143a-42ec-86ec-db1bf8933cc5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gzliudan
gzliudan force-pushed the fix-countdown-round-diff-truncation branch 4 times, most recently from 22d66c0 to 131cc18 Compare October 27, 2026 10:14
@gzliudan gzliudan changed the title fix(common/countdown): keep the full round difference in the timeout exponent fix(common/countdown,params): correct the exponential timeout arithmetic and validate its bounds Oct 27, 2026
@gzliudan
gzliudan force-pushed the fix-countdown-round-diff-truncation branch from 131cc18 to bcba4bb Compare October 27, 2026 10:45
…exponent

The exponent was derived from a uint8 conversion of the round difference,
which truncates it modulo 256. A difference of 257 therefore collapsed the
timeout to its minimum instead of saturating at maxExponent, and the window
recurs every 256 rounds. Compute the difference in uint64 instead.

This defect is pre-existing since the package was introduced (XinFinOrg#764) and is
independent of XinFinOrg#2831.

close XinFinOrg#2835
duration*base^exp can overflow int64 for a long round difference and a large
base, wrapping around to a negative timeout; the timer then retriggers
immediately. Saturate at math.MaxInt64 instead.

This is a separate defect from the truncation in the previous commit:
truncation recurs every 256 rounds, while the overflow depends on the
exponent, and each can occur without the other. It is pre-existing since the
package was introduced (XinFinOrg#764).

The saturated value never fires in practice; a chain whose own maximum
exponential timeout is unrepresentable is rejected by the chain config
validation at startup.
common/countdown arms the pacemaker timer with timeout_period * base^exp. The
engine converts the period with time.Duration(TimeoutPeriod) * time.Second
first, then common/countdown multiplies by base^exp; when either step leaves
the int64 range the timer saturates at math.MaxInt64 (~292 years) and never
fires, so the node silently stops producing timeout messages.
validateV2ExpTimeoutConfig only bounded max_exponent and base^max_exponent,
which admits such a genesis: with base=2, maxExponent=30 and the 30s mainnet
timeout period the product already overflows from round difference 30 on.

Bound both steps at the time.Duration range in whole seconds, so a chain whose
own maximum exponential timeout is unrepresentable fails at startup instead of
running until the round difference grows. The per-step bound matters: with
base < 1, base^max_exponent shrinks the multiplier, so a single combined check
would let an overflowing period through.

No network shipped in this repository is affected: all 19 built-in V2Config
entries and every genesis file use base=1 and maxExponent=0, where the product
is just timeout_period seconds. Also correct the TimeoutPeriod comment, which
claims milliseconds while every consumer multiplies by time.Second.

go-ethereum has no common/countdown package and no equivalent chain-config
check. The closest upstream precedent is geth ethereum#31407 (eth/catalyst: sanitize
simulated beacon period to avoid overflowing time.Duration), which clamps the
period with an integer bound instead; this commit rejects the config instead,
because a saturated pacemaker stalls silently while a rejected config fails
loudly at startup.

Checking the largest exponent covers every smaller one only while base >= 1;
with base < 1 the largest timeout is at exponent 0, which the period
conversion check above bounds on its own.
@gzliudan
gzliudan force-pushed the fix-countdown-round-diff-truncation branch from bcba4bb to 0063916 Compare October 28, 2026 08:21

This branch has not been deployed

No deployments
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.

3 participants