Skip to content

fix(payment): prevent Int64 overflow panic in settle-timestamp arithmetic#287

Merged
puneet2019 merged 4 commits into
mainfrom
fix/payment-int64-overflow
Jul 2, 2026
Merged

fix(payment): prevent Int64 overflow panic in settle-timestamp arithmetic#287
puneet2019 merged 4 commits into
mainfrom
fix/payment-int64-overflow

Conversation

@puneet2019

@puneet2019 puneet2019 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Linear

https://linear.app/mocanetwork/issue/MOCA-385/prevent-int64-overflow-panic-in-stream-record-updates

What

UpdateStreamRecord and TryResumeStreamRecord called Int.Int64() on an unbounded sdkmath.Int to compute the settle timestamp. When a stream account's pay-duration exceeded MaxInt64 seconds (~2.9e11 years), the SDK panicked with "Int64() out of bound". Both functions run inside the storage EndBlocker with no recover(), so the panic was deterministic across every validator and halted the chain.

Why it happens

The settle-timestamp formula is:

settleTimestamp = currentTimestamp - forcedSettleTime + (staticBalance + bufferBalance) / |netflowRate|

payDuration blows past MaxInt64 when |netflowRate| is tiny relative to the balance — reachable when SP prices are low (governance/devnet) and the account is heavily funded, or when a rate decrease (object deletion / discontinue) shrinks the denominator. The storage EndBlocker (ForceDeleteObject → UnChargeObjectStoreFee → UpdateStreamRecord) then panics, and every validator hits a CONSENSUS FAILURE at the same height.

Fix

Compute the full expression in arbitrary-precision sdkmath.Int, then handle an out-of-range result by intent, not by call site:

  • User-initiated deposit (positive static-balance change, no rate change) that would overflow → reject with ErrSettleTimestampOverflow. The depositor is over-funding past what we can represent and can simply deposit less. This is the only path where the degenerate state is genuinely user-created.
  • Every other path → saturate the settle timestamp to MaxInt64:
    • forced EndBlocker updates (an error there would re-trigger the same halt via abci.go panic("should not happen"));
    • rate decreases from object deletion / discontinue;
    • lazy re-pricing when the SP price falls.

The settle timestamp is only a scheduling hint for the auto-settle queue — recomputed on the next balance change or bucket touch — so capping it is loss-free and never blocks a legitimate object deletion. This avoids the asymmetry of rejecting a user's own MsgDeleteObject.

Why not cap/refund the deposit?

Storage prices are SP-set (median-aggregated, time-versioned, recomputed monthly), and buckets are re-priced on every touch. Prepayment does not lock a price — staticBalance drains at the current rate and is withdrawable anytime. So "N years of runway" isn't a stable quantity, and a time-based deposit cap would fight the floating-price model while only ever triggering at the absurd ~2.9e11-year boundary. The overflow is a representation limit, so it gets a representation fix.

ErrSettleTimestampOverflow (code 1214) is added to x/payment/types/errors.go.

Tests

In the existing x/payment/keeper/stream_record_test.go (no new files):

Test Scenario Asserts
..._ForcedSaturates forced rate-decrease (EndBlocker discontinue — the halt repro) no panic, no error, settle = MaxInt64
..._UserDepositRejected non-forced deposit (positive static-balance change) returns ErrSettleTimestampOverflow
..._RateDecreaseSaturates non-forced rate-decrease (user MsgDeleteObject / re-price) no error, settle = MaxInt64 (delete never blocked)
TestTryResumeStreamRecord_SettleTimestampInt64Overflow deposit to frozen account returns ErrSettleTimestampOverflow
..._SilentWrap forced, secondary full-expression overflow settle = MaxInt64, not negative

Checklist

  • Tests included
  • Changelog updated
  • CI green
  • 2 approvals

…etic

UpdateStreamRecord and TryResumeStreamRecord computed settle timestamps by
calling Int.Int64() on an unbounded sdkmath.Int. When a stream account's
pay-duration exceeded MaxInt64 seconds (~2.9e11 years) the call panicked with
"Int64() out of bound". Because both functions run inside the storage
EndBlocker with no recover(), the panic was deterministic across every
validator and halted the chain.

Fix: perform the full settle-timestamp expression in sdkmath.Int, then handle
an out-of-range result by intent rather than by call site:

- A user-initiated deposit (positive static-balance change, no rate change)
  that would overflow is rejected with ErrSettleTimestampOverflow — the
  depositor is over-funding past what we can represent and can deposit less.
- Every other path saturates the settle timestamp to math.MaxInt64: forced
  EndBlocker updates (where an error would re-trigger the same halt via
  abci.go "should not happen"), rate decreases from object deletion or
  discontinue, and lazy re-pricing when the SP price falls. The settle
  timestamp is only a scheduling hint for the auto-settle queue and is
  recomputed on the next balance change or bucket touch, so capping it is
  loss-free and never blocks a legitimate object deletion.

Closes MOCA-385
The previous commit guarded only the high side of the settle-timestamp
arithmetic. Int.Int64() panics on either bound, and UpdateStreamRecord has a
route to a hugely negative value: when payDuration <= ForcedSettleTime the code
calls ForceSettle and then falls through to compute the settle timestamp from
the same (stale, negative) payDuration. A deeply indebted account whose netflow
rate collapses while its balance is negative makes that value underflow int64,
and the else-branch Int64() panics. The route is reachable only when forced
(a non-forced update returns early once payDuration drops below
ForcedSettleTime), i.e. inside the no-recover EndBlocker -> chain halt.

Replace the high-only check with a 3-way bound: reject a user deposit on
overflow (unchanged), saturate to MaxInt64 on any other overflow, and saturate
to MinInt64 on underflow (never a deposit; the account is already force-settled,
so "overdue" is the correct hint). TryResumeStreamRecord is unaffected: after
its resume-floor check the pay duration is provably positive.

Relates to MOCA-385
@puneet2019 puneet2019 marked this pull request as ready for review July 1, 2026 10:23
@puneet2019 puneet2019 requested review from a team and phenix3443 July 1, 2026 10:23
- Hoist the int64 settle-timestamp bounds to package vars (settleTimestampMax/Min)
  so the UpdateStreamRecord/TryResumeStreamRecord range checks don't re-allocate per call.
- TryResumeStreamRecord: clamp the MinInt64 side too — unreachable (the resume guard
  bounds StaticBalance) but kept for parity with UpdateStreamRecord and a provably
  in-range Int64() cast; correct the comment to the actual invariant.
- Clarify the isUserDeposit classification (forced is set on every EndBlocker path).

MOCA-385

Signed-off-by: puneetmahajan <59960662+puneet2019@users.noreply.github.com>
@puneet2019 puneet2019 merged commit cb72263 into main Jul 2, 2026
27 checks passed
@puneet2019 puneet2019 deleted the fix/payment-int64-overflow branch July 2, 2026 04:36
puneet2019 added a commit that referenced this pull request Jul 2, 2026
Resolve CHANGELOG Unreleased > Bug Fixes conflict: keep both the #292 (rpc) and #287 (payment) entries. Code (rpc/*, types/*) merged cleanly; build + rpc/types tests green.

Signed-off-by: puneetmahajan <59960662+puneet2019@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants