feat(consensus): block timestamp validation
CheckTimestamp enforces future time limits (7200s PoW, 1200s PoS) and median-of-last-60 timestamp ordering. Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
parent
e2068338a5
commit
b3f33f5265
2 changed files with 123 additions and 0 deletions
59
consensus/block.go
Normal file
59
consensus/block.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
|
||||
//
|
||||
// Licensed under the European Union Public Licence (EUPL) version 1.2.
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package consensus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"forge.lthn.ai/core/go-blockchain/config"
|
||||
)
|
||||
|
||||
// IsPoS returns true if the block flags indicate a Proof-of-Stake block.
|
||||
// Bit 0 of the flags byte is the PoS indicator.
|
||||
func IsPoS(flags uint8) bool {
|
||||
return flags&1 != 0
|
||||
}
|
||||
|
||||
// CheckTimestamp validates a block's timestamp against future limits and
|
||||
// the median of recent timestamps.
|
||||
func CheckTimestamp(blockTimestamp uint64, flags uint8, adjustedTime uint64, recentTimestamps []uint64) error {
|
||||
// Future time limit.
|
||||
limit := config.BlockFutureTimeLimit
|
||||
if IsPoS(flags) {
|
||||
limit = config.PosBlockFutureTimeLimit
|
||||
}
|
||||
if blockTimestamp > adjustedTime+limit {
|
||||
return fmt.Errorf("%w: %d > %d + %d", ErrTimestampFuture,
|
||||
blockTimestamp, adjustedTime, limit)
|
||||
}
|
||||
|
||||
// Median check — only when we have enough history.
|
||||
if uint64(len(recentTimestamps)) < config.TimestampCheckWindow {
|
||||
return nil
|
||||
}
|
||||
|
||||
median := medianTimestamp(recentTimestamps)
|
||||
if blockTimestamp < median {
|
||||
return fmt.Errorf("%w: %d < median %d", ErrTimestampOld,
|
||||
blockTimestamp, median)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// medianTimestamp returns the median of a slice of timestamps.
|
||||
func medianTimestamp(timestamps []uint64) uint64 {
|
||||
sorted := make([]uint64, len(timestamps))
|
||||
copy(sorted, timestamps)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
|
||||
n := len(sorted)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return sorted[n/2]
|
||||
}
|
||||
64
consensus/block_test.go
Normal file
64
consensus/block_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//go:build !integration
|
||||
|
||||
package consensus
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-blockchain/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckTimestamp_Good(t *testing.T) {
|
||||
now := uint64(time.Now().Unix())
|
||||
|
||||
// PoW block within limits.
|
||||
err := CheckTimestamp(now, 0, now, nil) // flags=0 -> PoW
|
||||
require.NoError(t, err)
|
||||
|
||||
// With sufficient history, timestamp above median.
|
||||
timestamps := make([]uint64, config.TimestampCheckWindow)
|
||||
for i := range timestamps {
|
||||
timestamps[i] = now - 100 + uint64(i)
|
||||
}
|
||||
err = CheckTimestamp(now, 0, now, timestamps)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCheckTimestamp_Bad(t *testing.T) {
|
||||
now := uint64(time.Now().Unix())
|
||||
|
||||
// PoW block too far in future.
|
||||
future := now + config.BlockFutureTimeLimit + 1
|
||||
err := CheckTimestamp(future, 0, now, nil)
|
||||
assert.ErrorIs(t, err, ErrTimestampFuture)
|
||||
|
||||
// PoS block too far in future (tighter limit).
|
||||
posFlags := uint8(1) // bit 0 = PoS
|
||||
posFuture := now + config.PosBlockFutureTimeLimit + 1
|
||||
err = CheckTimestamp(posFuture, posFlags, now, nil)
|
||||
assert.ErrorIs(t, err, ErrTimestampFuture)
|
||||
|
||||
// Timestamp below median of last 60 blocks.
|
||||
timestamps := make([]uint64, config.TimestampCheckWindow)
|
||||
for i := range timestamps {
|
||||
timestamps[i] = now - 60 + uint64(i) // median ~ now - 30
|
||||
}
|
||||
oldTimestamp := now - 100 // well below median
|
||||
err = CheckTimestamp(oldTimestamp, 0, now, timestamps)
|
||||
assert.ErrorIs(t, err, ErrTimestampOld)
|
||||
}
|
||||
|
||||
func TestCheckTimestamp_Ugly(t *testing.T) {
|
||||
now := uint64(time.Now().Unix())
|
||||
|
||||
// Fewer than 60 timestamps: skip median check.
|
||||
timestamps := make([]uint64, 10)
|
||||
for i := range timestamps {
|
||||
timestamps[i] = now - 100
|
||||
}
|
||||
err := CheckTimestamp(now-200, 0, now, timestamps) // old but under 60 entries
|
||||
require.NoError(t, err)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue