From df065eee1903cead496672d968a44bb7d585e078 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Fri, 3 Oct 2014 15:29:25 -0500 Subject: [PATCH] Provide a new median time source API. This commit provides a new interface, MedianTimeSource, along with a concrete implementation which allows improved accuracy of time by making use of the median network time as calculated from multiple time samples. The time samples are to be provided by callers and are intended to come from remote clients. The calculations performed in this implementation exactly mirror those in Bitcoin Core because time calculations are part of the consensus rules and hence need to match exactly. --- chain.go | 6 +- chain_test.go | 7 +- example_test.go | 8 +- mediantime.go | 217 +++++++++++++++++++++++++++++++++++++++++ process.go | 4 +- reorganization_test.go | 3 +- validate.go | 9 +- validate_test.go | 7 +- 8 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 mediantime.go diff --git a/chain.go b/chain.go index 0b979dcc..9066a2d6 100644 --- a/chain.go +++ b/chain.go @@ -1038,7 +1038,7 @@ func (b *BlockChain) connectBestChain(node *blockNode, block *btcutil.Block, fla // - Latest block has a timestamp newer than 24 hours ago // // This function is NOT safe for concurrent access. -func (b *BlockChain) IsCurrent() bool { +func (b *BlockChain) IsCurrent(timeSource MedianTimeSource) bool { // Not current if there isn't a main (best) chain yet. if b.bestChain == nil { return false @@ -1053,8 +1053,8 @@ func (b *BlockChain) IsCurrent() bool { // Not current if the latest best block has a timestamp before 24 hours // ago. - now := time.Now() - if b.bestChain.timestamp.Before(now.Add(-24 * time.Hour)) { + minus24Hours := timeSource.AdjustedTime().Add(-24 * time.Hour) + if b.bestChain.timestamp.Before(minus24Hours) { return false } diff --git a/chain_test.go b/chain_test.go index b1e4d72e..22f0d01b 100644 --- a/chain_test.go +++ b/chain_test.go @@ -48,8 +48,10 @@ func TestHaveBlock(t *testing.T) { chain.DisableCheckpoints(true) btcchain.TstSetCoinbaseMaturity(1) + timeSource := btcchain.NewMedianTime() for i := 1; i < len(blocks); i++ { - isOrphan, err := chain.ProcessBlock(blocks[i], btcchain.BFNone) + isOrphan, err := chain.ProcessBlock(blocks[i], timeSource, + btcchain.BFNone) if err != nil { t.Errorf("ProcessBlock fail on block %v: %v\n", i, err) return @@ -62,7 +64,8 @@ func TestHaveBlock(t *testing.T) { } // Insert an orphan block. - isOrphan, err := chain.ProcessBlock(btcutil.NewBlock(&Block100000), btcchain.BFNone) + isOrphan, err := chain.ProcessBlock(btcutil.NewBlock(&Block100000), + timeSource, btcchain.BFNone) if err != nil { t.Errorf("Unable to process block: %v", err) return diff --git a/example_test.go b/example_test.go index d23c9476..08f62991 100644 --- a/example_test.go +++ b/example_test.go @@ -45,10 +45,16 @@ func ExampleBlockChain_ProcessBlock() { // the main bitcoin network and ignore notifications. chain := btcchain.New(db, &btcnet.MainNetParams, nil) + // Create a new median time source that is required by the upcoming + // call to ProcessBlock. Ordinarily this would also add time values + // obtained from other peers on the network so the local time is + // adjusted to be in agreement with other peers. + timeSource := btcchain.NewMedianTime() + // Process a block. For this example, we are going to intentionally // cause an error by trying to process the genesis block which already // exists. - isOrphan, err := chain.ProcessBlock(genesisBlock, btcchain.BFNone) + isOrphan, err := chain.ProcessBlock(genesisBlock, timeSource, btcchain.BFNone) if err != nil { fmt.Printf("Failed to process block: %v\n", err) return diff --git a/mediantime.go b/mediantime.go new file mode 100644 index 00000000..b11fcd34 --- /dev/null +++ b/mediantime.go @@ -0,0 +1,217 @@ +// Copyright (c) 2013-2014 Conformal Systems LLC. +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package btcchain + +import ( + "math" + "sort" + "sync" + "time" +) + +const ( + // maxAllowedOffsetSeconds is the maximum number of seconds in either + // direction that local clock will be adjusted. When the median time + // of the network is outside of this range, no offset will be applied. + maxAllowedOffsetSecs = 70 * 60 // 1 hour 10 minutes + + // similarTimeSecs is the number of seconds in either direction from the + // local clock that is used to determine that it is likley wrong and + // hence to show a warning. + similarTimeSecs = 5 * 60 // 5 minutes +) + +var ( + // maxMedianTimeEntries is the maximum number of entries allowed in the + // median time data. This is a variable as opposed to a constant so the + // test code can modify it. + maxMedianTimeEntries = 200 +) + +// MedianTimeSource provides a mechanism to add several time samples which are +// used to determine a median time which is then used as an offset to the local +// clock. +type MedianTimeSource interface { + // AdjustedTime returns the current time adjusted by the median time + // offset as calculated from the time samples added by AddTimeSample. + AdjustedTime() time.Time + + // AddTimeSample adds a time sample that is used when determining the + // median time of the added samples. + AddTimeSample(id string, timeVal time.Time) + + // Offset returns the number of seconds to adjust the local clock based + // upon the median of the time samples added by AddTimeData. + Offset() time.Duration +} + +// int64Sorter implements sort.Interface to allow a slice of 64-bit integers to +// be sorted. +type int64Sorter []int64 + +// Len returns the number of 64-bit integers in the slice. It is part of the +// sort.Interface implementation. +func (s int64Sorter) Len() int { + return len(s) +} + +// Swap swaps the 64-bit integers at the passed indices. It is part of the +// sort.Interface implementation. +func (s int64Sorter) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +// Less returns whether the 64-bit integer with index i should sort before the +// 64-bit integer with index j. It is part of the sort.Interface +// implementation. +func (s int64Sorter) Less(i, j int) bool { + return s[i] < s[j] +} + +// medianTime provides an implementation of the MedianTimeSource interface. +// It is limited to maxMedianTimeEntries includes the same buggy behavior as +// the time offset mechanism in Bitcoin Core. This is necessary because it is +// used in the consensus code. +type medianTime struct { + mtx sync.Mutex + knownIDs map[string]struct{} + offsets []int64 + offsetSecs int64 + invalidTimeChecked bool +} + +// Ensure the medianTime type implements the MedianTimeSource interface. +var _ MedianTimeSource = (*medianTime)(nil) + +// AdjustedTime returns the current time adjusted by the median time offset as +// calculated from the time samples added by AddTimeSample. +// +// This function is safe for concurrent access and is part of the +// MedianTimeSource interface implementation. +func (m *medianTime) AdjustedTime() time.Time { + m.mtx.Lock() + defer m.mtx.Unlock() + + // Limit the adjusted time to 1 second precision. + now := time.Unix(time.Now().Unix(), 0) + return now.Add(time.Duration(m.offsetSecs) * time.Second) +} + +// AddTimeSample adds a time sample that is used when determining the median +// time of the added samples. +// +// This function is safe for concurrent access and is part of the +// MedianTimeSource interface implementation. +func (m *medianTime) AddTimeSample(sourceID string, timeVal time.Time) { + m.mtx.Lock() + defer m.mtx.Unlock() + + // Don't add time data from the same source. + if _, exists := m.knownIDs[sourceID]; exists { + return + } + m.knownIDs[sourceID] = struct{}{} + + // Truncate the provided offset to seconds and append it to the slice + // of offsets while respecting the maximum number of allowed entries by + // replacing the oldest entry with the new entry once the maximum number + // of entries is reached. + offsetSecs := int64(time.Now().Sub(timeVal).Seconds()) + numOffsets := len(m.offsets) + if numOffsets == maxMedianTimeEntries && maxMedianTimeEntries > 0 { + m.offsets = m.offsets[1:] + numOffsets-- + } + m.offsets = append(m.offsets, offsetSecs) + numOffsets++ + + // Sort the offsets so the median can be obtained as needed later. + sortedOffsets := make([]int64, numOffsets) + copy(sortedOffsets, m.offsets) + sort.Sort(int64Sorter(sortedOffsets)) + + offsetDuration := time.Duration(offsetSecs) * time.Second + log.Debugf("Added time sample of %v (total: %v)", offsetDuration, + numOffsets) + + // NOTE: The following code intentionally has a bug to mirror the + // buggy behavior in Bitcoin Core since the median time is used in the + // consensus rules. + // + // In particular, the offset is only updated when the number of entries + // is odd, but the max number of entries is 200, an even number. Thus, + // the offset will never be updated again once the max number of entries + // is reached. + + // The median offset is only updated when there are enough offsets and + // the number of offsets is odd so the middle value is the true median. + // Thus, there is nothing to do when those conditions are not met. + if numOffsets < 5 || numOffsets&0x01 != 1 { + return + } + + // At this point the number of offsets in the list is odd, so the + // middle value of the sorted offsets is the median. + median := sortedOffsets[numOffsets/2] + + // Set the new offset when the median offset is within the allowed + // offset range. + if math.Abs(float64(median)) < maxAllowedOffsetSecs { + m.offsetSecs = median + } else { + // The median offset of all added time data is larger than the + // maximum allowed offset, so don't use an offset. This + // effectively limits how far the local clock can be skewed. + m.offsetSecs = 0 + + if !m.invalidTimeChecked { + m.invalidTimeChecked = true + + // Find if any time samples have a time that is close + // to the local time. + var remoteHasCloseTime bool + for _, offset := range sortedOffsets { + if math.Abs(float64(offset)) < similarTimeSecs { + remoteHasCloseTime = true + break + } + } + + // Warn if none of the time samples are close. + if !remoteHasCloseTime { + log.Warnf("Please check your date and time " + + "are correct! btcd will not work " + + "properly with an invalid time") + } + } + } + + medianDuration := time.Duration(m.offsetSecs) * time.Second + log.Debugf("New time offset: %v", medianDuration) +} + +// Offset returns the number of seconds to adjust the local clock based upon the +// median of the time samples added by AddTimeData. +// +// This function is safe for concurrent access and is part of the +// MedianTimeSource interface implementation. +func (m *medianTime) Offset() time.Duration { + m.mtx.Lock() + defer m.mtx.Unlock() + + return time.Duration(m.offsetSecs) * time.Second +} + +// NewMedianTime returns a new instance of concurrency-safe implementation of +// the MedianTimeSource interface. The returned implementation contains the +// rules necessary for proper time handling in the chain consensus rules and +// expects the time samples to be added from the timestamp field of the version +// message received from remote peers that successfully connect and negotiate. +func NewMedianTime() MedianTimeSource { + return &medianTime{ + knownIDs: make(map[string]struct{}), + offsets: make([]int64, 0, maxMedianTimeEntries), + } +} diff --git a/process.go b/process.go index 55927afd..d6162590 100644 --- a/process.go +++ b/process.go @@ -114,7 +114,7 @@ func (b *BlockChain) processOrphans(hash *btcwire.ShaHash, flags BehaviorFlags) // It returns a bool which indicates whether or not the block is an orphan and // any errors that occurred during processing. The returned bool is only valid // when the error is nil. -func (b *BlockChain) ProcessBlock(block *btcutil.Block, flags BehaviorFlags) (bool, error) { +func (b *BlockChain) ProcessBlock(block *btcutil.Block, timeSource MedianTimeSource, flags BehaviorFlags) (bool, error) { fastAdd := flags&BFFastAdd == BFFastAdd dryRun := flags&BFDryRun == BFDryRun @@ -141,7 +141,7 @@ func (b *BlockChain) ProcessBlock(block *btcutil.Block, flags BehaviorFlags) (bo } // Perform preliminary sanity checks on the block and its transactions. - err = checkBlockSanity(block, b.netParams.PowLimit, flags) + err = checkBlockSanity(block, b.netParams.PowLimit, timeSource, flags) if err != nil { return false, err } diff --git a/reorganization_test.go b/reorganization_test.go index d6baa87b..1acaf23b 100644 --- a/reorganization_test.go +++ b/reorganization_test.go @@ -58,9 +58,10 @@ func TestReorganization(t *testing.T) { chain.DisableCheckpoints(true) btcchain.TstSetCoinbaseMaturity(1) + timeSource := btcchain.NewMedianTime() expectedOrphans := map[int]struct{}{5: struct{}{}, 6: struct{}{}} for i := 1; i < len(blocks); i++ { - isOrphan, err := chain.ProcessBlock(blocks[i], btcchain.BFNone) + isOrphan, err := chain.ProcessBlock(blocks[i], timeSource, btcchain.BFNone) if err != nil { t.Errorf("ProcessBlock fail on block %v: %v\n", i, err) return diff --git a/validate.go b/validate.go index 5f4bccc1..04cb1787 100644 --- a/validate.go +++ b/validate.go @@ -423,7 +423,7 @@ func CountP2SHSigOps(tx *btcutil.Tx, isCoinBaseTx bool, txStore TxStore) (int, e // // The flags do not modify the behavior of this function directly, however they // are needed to pass along to checkProofOfWork. -func checkBlockSanity(block *btcutil.Block, powLimit *big.Int, flags BehaviorFlags) error { +func checkBlockSanity(block *btcutil.Block, powLimit *big.Int, timeSource MedianTimeSource, flags BehaviorFlags) error { // A block must have at least one transaction. msgBlock := block.MsgBlock() numTx := len(msgBlock.Transactions) @@ -469,7 +469,8 @@ func checkBlockSanity(block *btcutil.Block, powLimit *big.Int, flags BehaviorFla } // Ensure the block time is not too far in the future. - maxTimestamp := time.Now().Add(time.Second * MaxTimeOffsetSeconds) + maxTimestamp := timeSource.AdjustedTime().Add(time.Second * + MaxTimeOffsetSeconds) if header.Timestamp.After(maxTimestamp) { str := fmt.Sprintf("block timestamp of %v is too far in the "+ "future", header.Timestamp) @@ -551,8 +552,8 @@ func checkBlockSanity(block *btcutil.Block, powLimit *big.Int, flags BehaviorFla // CheckBlockSanity performs some preliminary checks on a block to ensure it is // sane before continuing with block processing. These checks are context free. -func CheckBlockSanity(block *btcutil.Block, powLimit *big.Int) error { - return checkBlockSanity(block, powLimit, BFNone) +func CheckBlockSanity(block *btcutil.Block, powLimit *big.Int, timeSource MedianTimeSource) error { + return checkBlockSanity(block, powLimit, timeSource, BFNone) } // checkSerializedHeight checks if the signature script in the passed diff --git a/validate_test.go b/validate_test.go index 7aa7f1e2..aa91333b 100644 --- a/validate_test.go +++ b/validate_test.go @@ -41,10 +41,13 @@ func TestCheckConnectBlock(t *testing.T) { } } +// TestCheckBlockSanity tests the CheckBlockSanity function to ensure it works +// as expected. func TestCheckBlockSanity(t *testing.T) { powLimit := btcnet.MainNetParams.PowLimit block := btcutil.NewBlock(&Block100000) - err := btcchain.CheckBlockSanity(block, powLimit) + timeSource := btcchain.NewMedianTime() + err := btcchain.CheckBlockSanity(block, powLimit, timeSource) if err != nil { t.Errorf("CheckBlockSanity: %v", err) } @@ -53,7 +56,7 @@ func TestCheckBlockSanity(t *testing.T) { // second fails. timestamp := block.MsgBlock().Header.Timestamp block.MsgBlock().Header.Timestamp = timestamp.Add(time.Nanosecond) - err = btcchain.CheckBlockSanity(block, powLimit) + err = btcchain.CheckBlockSanity(block, powLimit, timeSource) if err == nil { t.Errorf("CheckBlockSanity: error is nil when it shouldn't be") }