diff --git a/consensus/block.go b/consensus/block.go new file mode 100644 index 0000000..bbf131f --- /dev/null +++ b/consensus/block.go @@ -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] +} diff --git a/consensus/block_test.go b/consensus/block_test.go new file mode 100644 index 0000000..2eb136a --- /dev/null +++ b/consensus/block_test.go @@ -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) +}