Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
- (app/upgrades) [#289](https://github.com/mocachain/moca/pull/289) Pin v2 feemarket `min_gas_price` to 20 gwei (moca's intended floor) so upgraded chains match genesis.
- (evm) [#290](https://github.com/mocachain/moca/pull/290) Restore `CallEVM` error-context wrap (method + contract in error message), fix copy-pasted "evil token" comment in `erc721NonTransferable.go`, update stale geth v1.15→v1.16 comments, remove unreachable `AddBalance` blocks in distribution precompile, fix grammar in precompile Run() default cases, and drop dead test-helper expressions.
- (evm) [#291](https://github.com/mocachain/moca/pull/291) Reject native value sent to precompiles to prevent a balance-reconciliation mint.
- (x/payment) [#287](https://github.com/mocachain/moca/pull/287) Fix an Int64-overflow panic in the settle-timestamp math of `UpdateStreamRecord`/`TryResumeStreamRecord` (a chain-halt DoS reachable via a large balance + tiny netflow rate): compute in `sdkmath.Int` and bound to int64 range — reject an over-funding user deposit (`ErrSettleTimestampOverflow`), saturate on forced/EndBlocker paths so the chain can't halt (MOCA-385).
- (x/challenge) [#286](https://github.com/mocachain/moca/pull/286) Retire a challenge from the active set once it is attested, making attestation idempotent so duplicate submissions (e.g. redundant relayers or resubmissions by the in-turn submitter) are rejected instead of re-running heartbeat rewards and re-emitting attestation events.
- (ci) [#65](https://github.com/mocachain/moca/pull/65) Resolve goreleaser CI failures for arm64 docker builds
- (audit) [#63](https://github.com/mocachain/moca/pull/63) Apply audit fixes
Expand Down
55 changes: 53 additions & 2 deletions x/payment/keeper/stream_record.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package keeper

import (
"fmt"
"math"

sdkmath "cosmossdk.io/math"
"cosmossdk.io/store/prefix"
Expand All @@ -12,6 +13,13 @@ import (
"github.com/mocachain/moca/v2/x/payment/types"
)

// int64 bounds as sdkmath.Int, hoisted so the settle-timestamp range checks in
// UpdateStreamRecord / TryResumeStreamRecord don't re-allocate on every call.
var (
settleTimestampMax = sdkmath.NewInt(math.MaxInt64)
settleTimestampMin = sdkmath.NewInt(math.MinInt64)
)

func (k Keeper) CheckStreamRecord(streamRecord *types.StreamRecord) {
if streamRecord == nil {
panic("streamRecord is nil")
Expand Down Expand Up @@ -214,7 +222,36 @@ func (k Keeper) UpdateStreamRecord(ctx sdk.Context, streamRecord *types.StreamRe
return fmt.Errorf("check and force settle failed, err: %w", err)
}
}
settleTimestamp = currentTimestamp - int64(params.ForcedSettleTime) + payDuration.Int64()
// The full expression can exceed int64 range, where Int64() panics — fatal in
// the no-recover EndBlocker. SettleTimestamp is only an auto-settle queue hint,
// recomputed on the next change, so saturate instead of overflowing.
settleTimestampFull := sdkmath.NewInt(currentTimestamp).
Sub(sdkmath.NewIntFromUint64(params.ForcedSettleTime)).
Add(payDuration)
switch {
case settleTimestampFull.GT(settleTimestampMax):
// A real user deposit = non-forced, positive-static-only change (forced is set on
// every EndBlocker path); reject it. Saturate elsewhere — an error on a forced path re-panics.
isUserDeposit := !forced && change.StaticBalanceChange.IsPositive() && change.RateChange.IsZero()
if isUserDeposit {
return types.ErrSettleTimestampOverflow.Wrapf(
"account %s pay duration %s exceeds int64 range; reduce deposit",
streamRecord.Account, payDuration.String())
}
ctx.Logger().Error("settle timestamp overflow, capping at MaxInt64",
"account", streamRecord.Account, "payDuration", payDuration.String(),
"forced", forced, "height", ctx.BlockHeight())
settleTimestamp = math.MaxInt64
case settleTimestampFull.LT(settleTimestampMin):
// Deeply indebted; only reachable on a forced path (account already
// force-settled above), never a deposit — saturate.
ctx.Logger().Error("settle timestamp underflow, capping at MinInt64",
"account", streamRecord.Account, "payDuration", payDuration.String(),
"forced", forced, "height", ctx.BlockHeight())
settleTimestamp = math.MinInt64
default:
settleTimestamp = settleTimestampFull.Int64()
}
}
k.UpdateAutoSettleRecord(ctx, sdk.MustAccAddressFromHex(streamRecord.Account), streamRecord.SettleTimestamp, settleTimestamp)
streamRecord.SettleTimestamp = settleTimestamp
Expand Down Expand Up @@ -401,7 +438,21 @@ func (k Keeper) TryResumeStreamRecord(ctx sdk.Context, streamRecord *types.Strea
}

prevSettleTime := streamRecord.SettleTimestamp
streamRecord.SettleTimestamp = now + streamRecord.StaticBalance.Quo(totalRate.Abs()).Int64() - int64(forcedSettleTime)
settleTimestampFull := sdkmath.NewInt(now).
Add(streamRecord.StaticBalance.Quo(totalRate.Abs())).
Sub(sdkmath.NewIntFromUint64(forcedSettleTime))
switch {
case settleTimestampFull.GT(settleTimestampMax):
// Deposit-only path (runTx, not the no-recover EndBlocker): reject over-funding.
return types.ErrSettleTimestampOverflow.Wrapf(
"account %s: deposit would fund account beyond the representable future; reduce deposit",
streamRecord.Account)
case settleTimestampFull.LT(settleTimestampMin):
// Unreachable (the resume guard above bounds StaticBalance from below); clamped for
// parity with UpdateStreamRecord so the Int64() cast is provably in range.
settleTimestampFull = settleTimestampMin
}
streamRecord.SettleTimestamp = settleTimestampFull.Int64()
streamRecord.BufferBalance = expectedBalanceToResume
streamRecord.StaticBalance = streamRecord.StaticBalance.Sub(expectedBalanceToResume)
streamRecord.CrudTimestamp = now
Expand Down
223 changes: 223 additions & 0 deletions x/payment/keeper/stream_record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package keeper_test

import (
"errors"
"math"
"testing"
"time"

Expand Down Expand Up @@ -826,6 +827,228 @@ func TestSettleStreamRecord(t *testing.T) {
require.Equal(t, userStreamRecord2.CrudTimestamp, streamRecord.CrudTimestamp+seconds)
}

// TestUpdateStreamRecord_SettleTimestampOverflow_ForcedSaturates reproduces the
// original chain-halt: a forced EndBlocker update (ForceDeleteObject during
// discontinue) decreases the netflow rate on a large-balance account, pushing
// payDuration past MaxInt64. The EndBlocker cannot surface an error (abci.go
// panics on any error from DeleteDiscontinueObjectsUntil), so the settle
// timestamp must saturate to MaxInt64 instead of overflowing.
func TestUpdateStreamRecord_SettleTimestampOverflow_ForcedSaturates(t *testing.T) {
k, ctx, _ := makePaymentKeeper(t)
ctx = ctx.WithBlockTime(time.Unix(1000, 0))
ctx = ctx.WithValue(types.ForceUpdateStreamRecordKey, true)

params := k.GetParams(ctx)
// Buffer reserved for the starting rate (-2); after the rate drops to -1 the
// buffer recomputes to 1×ReserveTime. payDuration overflows either way.
bufferBalance := sdkmath.NewIntFromUint64(params.VersionedParams.ReserveTime).MulRaw(2)

user := sample.RandAccAddress()
sr := &types.StreamRecord{
Account: user.String(),
Status: types.STREAM_ACCOUNT_STATUS_ACTIVE,
StaticBalance: sdkmath.NewIntFromUint64(math.MaxUint64),
BufferBalance: bufferBalance,
LockBalance: sdkmath.ZeroInt(),
NetflowRate: sdkmath.NewInt(-2),
FrozenNetflowRate: sdkmath.ZeroInt(),
CrudTimestamp: ctx.BlockTime().Unix(),
}

// Forced rate decrease (-2 → -1), as when an object is force-deleted.
change := types.NewDefaultStreamRecordChangeWithAddr(user).WithRateChange(sdkmath.NewInt(1))
require.NotPanics(t, func() {
err := k.UpdateStreamRecord(ctx, sr, change)
require.NoError(t, err, "forced path must not return an error (would panic EndBlocker)")
})
require.Equal(t, int64(math.MaxInt64), sr.SettleTimestamp,
"forced overflow must saturate to MaxInt64, not panic")
}

// TestUpdateStreamRecord_SettleTimestampUnderflow_ForcedSaturates covers the low
// side: a forced update on a deeply indebted active account (a large rate that
// collapsed to a tiny one while the balance was negative) makes payDuration
// hugely negative, so currentTimestamp - forcedSettleTime + payDuration underflows
// int64. Int64() would panic inside the EndBlocker and halt the chain; instead the
// settle timestamp must saturate to MinInt64. This is reachable only when forced —
// a non-forced update returns early once payDuration drops below ForcedSettleTime.
func TestUpdateStreamRecord_SettleTimestampUnderflow_ForcedSaturates(t *testing.T) {
k, ctx, dep := makePaymentKeeper(t)
ctx = ctx.WithBlockTime(time.Unix(1_000_000_000, 0))
ctx = ctx.WithValue(types.ForceUpdateStreamRecordKey, true)

// No bank account, so the negative-balance auto-transfer (for the user and for
// the governance account inside ForceSettle) is skipped.
dep.AccountKeeper.EXPECT().HasAccount(gomock.Any(), gomock.Any()).Return(false).AnyTimes()

params := k.GetParams(ctx)
// Buffer matches |rate|=1 so the buffer recompute does not adjust staticBalance.
bufferBalance := sdkmath.NewIntFromUint64(params.VersionedParams.ReserveTime)
// staticBalance far below MinInt64 → payDuration (÷1) underflows int64.
staticBalance := sdkmath.NewInt(math.MinInt64).MulRaw(2)

user := sample.RandAccAddress()
sr := &types.StreamRecord{
Account: user.String(),
Status: types.STREAM_ACCOUNT_STATUS_ACTIVE,
StaticBalance: staticBalance,
BufferBalance: bufferBalance,
LockBalance: sdkmath.ZeroInt(),
NetflowRate: sdkmath.NewInt(-1),
FrozenNetflowRate: sdkmath.ZeroInt(),
CrudTimestamp: ctx.BlockTime().Unix(),
}

change := types.NewDefaultStreamRecordChangeWithAddr(user)
require.NotPanics(t, func() {
err := k.UpdateStreamRecord(ctx, sr, change)
require.NoError(t, err, "forced path must not return an error (would panic EndBlocker)")
})
require.Equal(t, int64(math.MinInt64), sr.SettleTimestamp,
"forced underflow must saturate to MinInt64, not panic")
}

// TestUpdateStreamRecord_SettleTimestampOverflow_UserDepositRejected covers the
// user-initiated deposit path: a positive static-balance change with no rate
// change that pushes payDuration past MaxInt64 is rejected with
// ErrSettleTimestampOverflow, so the depositor can simply deposit less. This is
// the one path where the degenerate state is genuinely user-created.
func TestUpdateStreamRecord_SettleTimestampOverflow_UserDepositRejected(t *testing.T) {
k, ctx, _ := makePaymentKeeper(t)
ctx = ctx.WithBlockTime(time.Unix(1000, 0))
// No ForceUpdateStreamRecordKey → forced=false (user-initiated path).

params := k.GetParams(ctx)
bufferBalance := sdkmath.NewIntFromUint64(params.VersionedParams.ReserveTime)

user := sample.RandAccAddress()
sr := &types.StreamRecord{
Account: user.String(),
Status: types.STREAM_ACCOUNT_STATUS_ACTIVE,
StaticBalance: sdkmath.NewIntFromUint64(math.MaxUint64),
BufferBalance: bufferBalance,
LockBalance: sdkmath.ZeroInt(),
NetflowRate: sdkmath.NewInt(-1),
FrozenNetflowRate: sdkmath.ZeroInt(),
CrudTimestamp: ctx.BlockTime().Unix(),
}

// A real deposit: positive static-balance change, no rate change.
change := types.NewDefaultStreamRecordChangeWithAddr(user).
WithStaticBalanceChange(sdkmath.NewInt(1_000))
err := k.UpdateStreamRecord(ctx, sr, change)
require.ErrorIs(t, err, types.ErrSettleTimestampOverflow,
"a deposit that overflows the settle timestamp must be rejected")
}

// TestUpdateStreamRecord_SettleTimestampOverflow_RateDecreaseSaturates covers a
// non-forced rate decrease — a user MsgDeleteObject, or lazy re-pricing when the
// SP price falls. Removing rate increases payDuration and can overflow int64, but
// this is a legitimate action (not over-funding), so it must NOT be rejected: the
// settle timestamp saturates to MaxInt64 and the operation succeeds. This is the
// asymmetry B fixes — a user can always delete their own object.
func TestUpdateStreamRecord_SettleTimestampOverflow_RateDecreaseSaturates(t *testing.T) {
k, ctx, _ := makePaymentKeeper(t)
ctx = ctx.WithBlockTime(time.Unix(1000, 0))
// No ForceUpdateStreamRecordKey → forced=false (user-initiated path).

params := k.GetParams(ctx)
bufferBalance := sdkmath.NewIntFromUint64(params.VersionedParams.ReserveTime).MulRaw(2)

user := sample.RandAccAddress()
sr := &types.StreamRecord{
Account: user.String(),
Status: types.STREAM_ACCOUNT_STATUS_ACTIVE,
StaticBalance: sdkmath.NewIntFromUint64(math.MaxUint64),
BufferBalance: bufferBalance,
LockBalance: sdkmath.ZeroInt(),
NetflowRate: sdkmath.NewInt(-2),
FrozenNetflowRate: sdkmath.ZeroInt(),
CrudTimestamp: ctx.BlockTime().Unix(),
}

// Rate decrease (-2 → -1), as when a user deletes an object. No static-balance
// change, so this is not a user deposit and must not be rejected.
change := types.NewDefaultStreamRecordChangeWithAddr(user).WithRateChange(sdkmath.NewInt(1))
require.NotPanics(t, func() {
err := k.UpdateStreamRecord(ctx, sr, change)
require.NoError(t, err, "a legitimate rate decrease must not be rejected")
})
require.Equal(t, int64(math.MaxInt64), sr.SettleTimestamp,
"rate-decrease overflow must saturate to MaxInt64, not reject")
}

// TestTryResumeStreamRecord_SettleTimestampInt64Overflow covers the Deposit
// path to a frozen account. TryResumeStreamRecord is only reachable from
// MsgDeposit (never from an EndBlocker), so an overflow must be rejected with
// ErrSettleTimestampOverflow; the user should reduce their deposit amount.
func TestTryResumeStreamRecord_SettleTimestampInt64Overflow(t *testing.T) {
k, ctx, _ := makePaymentKeeper(t)
ctx = ctx.WithBlockTime(time.Unix(1000, 0))

user := sample.RandAccAddress()
sr := &types.StreamRecord{
Account: user.String(),
Status: types.STREAM_ACCOUNT_STATUS_FROZEN,
StaticBalance: sdkmath.ZeroInt(),
BufferBalance: sdkmath.ZeroInt(),
LockBalance: sdkmath.ZeroInt(),
NetflowRate: sdkmath.ZeroInt(),
FrozenNetflowRate: sdkmath.NewInt(-1),
OutFlowCount: 1,
}
k.SetStreamRecord(ctx, sr)

deposit := sdkmath.NewIntFromUint64(math.MaxUint64)
err := k.TryResumeStreamRecord(ctx, sr, deposit)
require.ErrorIs(t, err, types.ErrSettleTimestampOverflow,
"deposit that overflows settle timestamp must be rejected, not silently absorbed")
}

// TestUpdateStreamRecord_SettleTimestampSilentWrap catches the secondary overflow:
// even when payDuration < MaxInt64 (so Int64() would not have panicked), the full
// expression currentTimestamp - forcedSettleTime + payDuration can itself overflow
// int64 and silently wrap to a large negative value. A negative settle timestamp
// makes the account look overdue and drives repeated force-settle attempts.
func TestUpdateStreamRecord_SettleTimestampSilentWrap(t *testing.T) {
k, ctx, _ := makePaymentKeeper(t)
params := k.GetParams(ctx)
reserveTime := sdkmath.NewIntFromUint64(params.VersionedParams.ReserveTime)

timestamp := int64(1_000_000_000)
ctx = ctx.WithBlockTime(time.Unix(timestamp, 0))
ctx = ctx.WithValue(types.ForceUpdateStreamRecordKey, true)

// payDuration = MaxInt64 - 500 fits in int64, so Int64() would not panic
// pre-fix. But timestamp + payDuration - forcedSettleTime overflows int64.
payDuration := sdkmath.NewInt(math.MaxInt64).SubRaw(500)
// With |rate|=1 and bufferBal=reserveTime: staticBal = payDuration - reserveTime.
staticBal := payDuration.Sub(reserveTime)
bufferBal := reserveTime

user := sample.RandAccAddress()
sr := &types.StreamRecord{
Account: user.String(),
Status: types.STREAM_ACCOUNT_STATUS_ACTIVE,
StaticBalance: staticBal,
BufferBalance: bufferBal,
LockBalance: sdkmath.ZeroInt(),
NetflowRate: sdkmath.NewInt(-1),
FrozenNetflowRate: sdkmath.ZeroInt(),
CrudTimestamp: timestamp,
}

change := types.NewDefaultStreamRecordChangeWithAddr(user)
require.NotPanics(t, func() {
err := k.UpdateStreamRecord(ctx, sr, change)
require.NoError(t, err)
})
require.Equal(t, int64(math.MaxInt64), sr.SettleTimestamp,
"settle timestamp must saturate to MaxInt64 when full expression overflows int64")
require.Greater(t, sr.SettleTimestamp, int64(0),
"settle timestamp must not silently wrap to negative")
}

func TestAutoForceSettle(t *testing.T) {
keeper, ctx, depKeepers := makePaymentKeeper(t)
t.Logf("depKeepers: %+v", depKeepers)
Expand Down
1 change: 1 addition & 0 deletions x/payment/types/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ var (
ErrIncorrectWithdrawAmount = errorsmod.Register(ModuleName, 1211, "the withdrawal amount is not equal to the delayed one")
ErrNotReachTimeLockDuration = errorsmod.Register(ModuleName, 1212, "the withdrawal does not reach to the delayed duration")
ErrExistsDelayedWithdrawal = errorsmod.Register(ModuleName, 1213, "delayed withdrawal already exists")
ErrSettleTimestampOverflow = errorsmod.Register(ModuleName, 1214, "settle timestamp overflow: deposit would fund the account beyond the representable future")
)
Loading