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:
Claude 2026-02-21 00:50:39 +00:00
parent e2068338a5
commit b3f33f5265
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 123 additions and 0 deletions

59
consensus/block.go Normal file
View 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
View 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)
}