Repository: bitcoin/bitcoin (language: cpp)

Issue:
AddTimeData will never update nTimeOffset past 199 samples
Reading the function AddTimeData, I came to notice two things:
- The structure vTimeOffsets contains up to 200 elements, after which any new element added to it will not increase its size, replacing the oldest element.
- The condition to update nTimeOffset includes checking whether the number of elements in vTimeOffsets is odd, which will never happen after there are 200 elements.

This will lead to nTimeOffset not updating past 199 calls to AddTimeData, remaining forever stuck in whatever value it had then. I don't believe this is the inteded behaviour. I would suggest removing the oddness check, since vTimeOffsets is capable of returning the median value of an even list according to the median method of the CMedianFilter type.

In current master, the offending code is in src/timedata.cpp, line 52, columns 34-64:

``` c++
if (vTimeOffsets.size() >= 5 && vTimeOffsets.size() % 2 == 1)
```

This should better be:

``` c++
if (vTimeOffsets.size() >= 5)
```



Discussion / hints:
According to @gmaxwell this is unintended behavior, but protects against worse attacks:

> but in this case the 'bug' is protective against some attacks (and may actually explain why we've never seen the one I'm thinking of exploited, which had surprised me…).
> So I think we should hold off on fixing this and just clean it up as a result of some timing cleanup the strengthens it in a number of other ways. I should have some time to work on this soon.

(see discussion in #4526)

#4526 does not explain the potential attacks that warrant not fixing this bug.  While this bug remains in place, a node can become convinced by its peers that its time is off by 70 minutes, and stay that way until restarted.

> protective against some attacks.

What are they? Can't seem to find any details on it.

@dgenr8 the problem is that the naive ways to fix this bug would make it even easier to convince a node it has the wrong time offset _at any time_. At least now the 'vulnerability' is limited to a window after startup.

This bug should be fixed, but as a by-effect of rewriting the time offset determination code.

It's not clear to me which is worse.  Attacks aside, a slow or fast system clock isn't getting corrected dynamically as designed.  Maybe it would be better to use "one chronometer" for now.

@dgenr8 Making running systems trivially vulnerable to attack is worse. I shouldn't have to point this out.

The purpose of the time correction is primarily to fix small time zone / dst error or slight mis-setting. Even the crappy oscillators commonly shipped in Bitcoin do not frequently drift fast enough to cause problems for Bitcoins operation (which are tolerant of up to ~2 hours of offset) if set correctly just once.

I can't help wondering, if we just used the system clock, and this scheme were suggested, would it be shot down for taking a local problem and making it vulnerable to remote attack?

And I'd better not fix my clock.  bitcoind will keep applying the wrong adjustment until restart.

I'd happily ACK a patch that added an optional switch that disabled remote adjustment and added warnings if all your peers disagreed by more than a few minutes. There was one I had floating around previously that queried local NTP and disabled adjustment if NTP thought itself to be in sync (technique used in gnunet)

> I can't help wondering, if we just used the system clock, and this scheme were suggested, would it be shot down for taking a local problem and making it vulnerable to remote attack?

Probably. Personally I'd prefer to have clock sync as just a local problem. A mechanism such as this should be used to warn, not correct. If your time is wrong, fix it. Wrong clocks cause all kinds of issues on a system.

(this excludes drift over time due to oscillators which is outside the user's control, of course, but correcting for that requires a more advanced algorithm and as said by @gmaxwell is not required in our case as we don't need time to be very precise...)

Is it worth modifying bitcoind or bitcoin-qt to display an alert if the system clock is off (compared to a reliable NTP server)?

How do you find a reliable NTP server?

Perhaps NTP Pool? Cycles randomly between public NTP servers every hour. http://www.pool.ntp.org/en/use.html

We're really not going to rely on a centralized service in the reference
client; even if it is just one that rotates between others. Adding optional
NTP support that can be configured to use your own server (and perhaps
defaults to one on localhost, if available) would be possible, but is
perhaps overkill. We have ~hours wiggle room in local clock variation
before there is really a problem...

All of the platforms on which core runs have well supported time sync clients, which can be configured 
to use any server.

As said many times, we have no need for NTP-level precision. Let's not keep bringing that back into the discussion.

Giving a warning if the time is significantly different from all peers should be enough. The reason for this check is mostly to find out if people have misconfigured their time zone.

Since we cannot agree on what is best, I suggest we leave the computer operator to decide. Let the computer trust its own clock, and disable the network time offset altogether. If the computer is setup to sync with NTP, good, if not, then good too. I think it is a good idea to bail if the machine has an offset of more than, say five or ten minutes with respect to the median time of peers it is attempting to connect to.

These are some potential attacks:
- Malicious NTP server can isolate the machine and serve a separate version of the blockchain (already a risk currently, if enough nodes agree on the bad time, regardless of network offset calculation)
- Malicious NTP server can deny the machine a connection no the Bitcoin network (offset too large, a risk with current scheme too)
- Offset could make machine reject a block (this is already a risk on current scheme)
- Offset may prevent the machine from connecting to the Bitcoin network (already a risk)

There surely are some attack vectors that I have not considered. But these that I could come up with are already affecting the current client, even with all the network offset calculation stuff. We could make the code simpler and still run the same level of risk.

A side effect of this bug is that a node that has a drifting clock can will keep on drifting with no warning shown until bitcoind is restarted.

Node starts up at reasonaly correct time.
Collects 199 time offsets from other peers.
Local clock drifts. bitcoind silently keeps on using local time + previously calculated network adjustment, not at all compensating for it's local clock drift or even giving a warning/error when the difference gets too large.

The above is an attempt to address the concerns raised here by adding an option to trust the local clock source, and rework warning conditions somewhat. Please review if you are interested.

-trustlocalclock: Why not just return 0 in `GetTimeOffset()`? And warn about the peers offset being higher?

But in general, I like -trustlocalclock. It allows the user to choose the "take one" part of the "Never go to the sea..." comment in https://github.com/bitcoin/bitcoin/blob/master/src/timedata.cpp#L21.
Of course when using NTP, you could still be in bad network partition. Or your other time source could be compromised etc. But you can at least select such option.

Related PR: #6545

"We're using SPV peer's time to compute our network offset P2P #6071" is another time correction related issue.

Another issue:
https://github.com/bitcoin/bitcoin/pull/7573#issuecomment-202973041

> But the implementation has an odd non-monotonicity. E.g. if you have 5 peers claiming ten seconds off, then six more connect claiming 80 minutes. You'll apply a correction of 0 instead of 10 seconds. Perhaps better to just fix in another PR, since it was an existing misbehavior (though more likely to be triggered when the time is cut down to small numbers).

I wonder if it wouldn't be better to remove the use of GetAdjustedTime. While it works well to cover some cases of misconfigured clocks, it fails to detect others.

Then, replace it with a stronger warning mechanism that also triggers when the time is off by more than ~10 minutes? Currently it only triggers after 70 minutes.

Maybe even add setting (off by default) that shuts down the node if outbound nodes report a different time than ours?

Edit: This is probably roughly the idea from https://github.com/bitcoin/bitcoin/issues/4521#issuecomment-54685781

Fix patch:
diff --git a/src/Makefile.am b/src/Makefile.am
index 1f55bfbafda66..43d487c6dc7cd 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -233,6 +233,7 @@ BITCOIN_CORE_H = \
   node/peerman_args.h \
   node/protocol_version.h \
   node/psbt.h \
+  node/timeoffsets.h \
   node/transaction.h \
   node/txreconciliation.h \
   node/utxo_snapshot.h \
@@ -283,7 +284,6 @@ BITCOIN_CORE_H = \
   support/lockedpool.h \
   sync.h \
   threadsafety.h \
-  timedata.h \
   torcontrol.h \
   txdb.h \
   txmempool.h \
@@ -435,6 +435,7 @@ libbitcoin_node_a_SOURCES = \
   node/minisketchwrapper.cpp \
   node/peerman_args.cpp \
   node/psbt.cpp \
+  node/timeoffsets.cpp \
   node/transaction.cpp \
   node/txreconciliation.cpp \
   node/utxo_snapshot.cpp \
@@ -462,7 +463,6 @@ libbitcoin_node_a_SOURCES = \
   rpc/txoutproof.cpp \
   script/sigcache.cpp \
   signet.cpp \
-  timedata.cpp \
   torcontrol.cpp \
   txdb.cpp \
   txmempool.cpp \
diff --git a/src/addrman_impl.h b/src/addrman_impl.h
index 867c894d01ded..dd7f7b318f9b2 100644
--- a/src/addrman_impl.h
+++ b/src/addrman_impl.h
@@ -11,7 +11,6 @@
 #include <protocol.h>
 #include <serialize.h>
 #include <sync.h>
-#include <timedata.h>
 #include <uint256.h>
 #include <util/time.h>
 
diff --git a/src/init.cpp b/src/init.cpp
index 0aa04755cbeba..bc49a14c6c6de 100644
--- a/src/init.cpp
+++ b/src/init.cpp
@@ -67,7 +67,6 @@
 #include <scheduler.h>
 #include <script/sigcache.h>
 #include <sync.h>
-#include <timedata.h>
 #include <torcontrol.h>
 #include <txdb.h>
 #include <txmempool.h>
@@ -523,7 +522,6 @@ void SetupServerArgs(ArgsManager& argsman)
     argsman.AddArg("-maxconnections=<n>", strprintf("Maintain at most <n> automatic connections to peers (default: %u). This limit does not apply to connections manually added via -addnode or the addnode RPC, which have a separate limit of %u.", DEFAULT_MAX_PEER_CONNECTIONS, MAX_ADDNODE_CONNECTIONS), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
     argsman.AddArg("-maxreceivebuffer=<n>", strprintf("Maximum per-connection receive buffer, <n>*1000 bytes (default: %u)", DEFAULT_MAXRECEIVEBUFFER), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
     argsman.AddArg("-maxsendbuffer=<n>", strprintf("Maximum per-connection memory usage for the send buffer, <n>*1000 bytes (default: %u)", DEFAULT_MAXSENDBUFFER), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
-    argsman.AddArg("-maxtimeadjustment", strprintf("Maximum allowed median peer time offset adjustment. Local perspective of time may be influenced by outbound peers forward or backward by this amount (default: %u seconds).", DEFAULT_MAX_TIME_ADJUSTMENT), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
     argsman.AddArg("-maxuploadtarget=<n>", strprintf("Tries to keep outbound traffic under the given target per 24h. Limit does not apply to peers with 'download' permission or blocks created within past week. 0 = no limit (default: %s). Optional suffix units [k|K|m|M|g|G|t|T] (default: M). Lowercase is 1000 base while uppercase is 1024 base", DEFAULT_MAX_UPLOAD_TARGET), ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
 #if HAVE_SOCKADDR_UN
     argsman.AddArg("-onion=<ip:port|path>", "Use separate SOCKS5 proxy to reach peers via Tor onion services, set -noonion to disable (default: -proxy). May be a local file path prefixed with 'unix:'.", ArgsManager::ALLOW_ANY, OptionsCategory::CONNECTION);
diff --git a/src/net.cpp b/src/net.cpp
index e388f05b037ad..a1154b60ebdc4 100644
--- a/src/net.cpp
+++ b/src/net.cpp
@@ -599,7 +599,6 @@ void CNode::CopyStats(CNodeStats& stats)
     X(m_last_tx_time);
     X(m_last_block_time);
     X(m_connected);
-    X(nTimeOffset);
     X(m_addr_name);
     X(nVersion);
     {
diff --git a/src/net.h b/src/net.h
index 46d9422695263..f1911fa2ceda5 100644
--- a/src/net.h
+++ b/src/net.h
@@ -191,7 +191,6 @@ class CNodeStats
     std::chrono::seconds m_last_tx_time;
     std::chrono::seconds m_last_block_time;
     std::chrono::seconds m_connected;
-    int64_t nTimeOffset;
     std::string m_addr_name;
     int nVersion;
     std::string cleanSubVer;
@@ -703,7 +702,6 @@ class CNode
     std::atomic<std::chrono::seconds> m_last_recv{0s};
     //! Unix epoch time at peer connection
     const std::chrono::seconds m_connected;
-    std::atomic<int64_t> nTimeOffset{0};
     // Address of this peer
     const CAddress addr;
     // Bind address of our side of the connection
diff --git a/src/net_processing.cpp b/src/net_processing.cpp
index 6996af38cbd97..42e085ccef90a 100644
--- a/src/net_processing.cpp
+++ b/src/net_processing.cpp
@@ -23,6 +23,7 @@
 #include <netbase.h>
 #include <netmessagemaker.h>
 #include <node/blockstorage.h>
+#include <node/timeoffsets.h>
 #include <node/txreconciliation.h>
 #include <policy/fees.h>
 #include <policy/policy.h>
@@ -34,7 +35,6 @@
 #include <scheduler.h>
 #include <streams.h>
 #include <sync.h>
-#include <timedata.h>
 #include <tinyformat.h>
 #include <txmempool.h>
 #include <txorphanage.h>
@@ -390,6 +390,10 @@ struct Peer {
     /** Whether this peer wants invs or headers (when possible) for block announcements */
     bool m_prefers_headers GUARDED_BY(NetEventsInterface::g_msgproc_mutex){false};
 
+    /** Time offset computed during the version handshake based on the
+     * timestamp the peer sent in the version message. */
+    std::atomic<std::chrono::seconds> m_time_offset{0s};
+
     explicit Peer(NodeId id, ServiceFlags our_services)
         : m_id{id}
         , m_our_services{our_services}
@@ -513,7 +517,7 @@ class PeerManagerImpl final : public PeerManager
     std::optional<std::string> FetchBlock(NodeId peer_id, const CBlockIndex& block_index) override
         EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
     bool GetNodeStateStats(NodeId nodeid, CNodeStateStats& stats) const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
-    bool IgnoresIncomingTxs() override { return m_opts.ignore_incoming_txs; }
+    PeerManagerInfo GetInfo() const override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
     void SendPings() override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
     void RelayTransaction(const uint256& txid, const uint256& wtxid) override EXCLUSIVE_LOCKS_REQUIRED(!m_peer_mutex);
     void SetBestBlock(int height, std::chrono::seconds time) override
@@ -749,6 +753,8 @@ class PeerManagerImpl final : public PeerManager
     /** Next time to check for stale tip */
     std::chrono::seconds m_stale_tip_check_time GUARDED_BY(cs_main){0s};
 
+    TimeOffsets m_outbound_time_offsets;
+
     const Options m_opts;
 
     bool RejectIncomingTxs(const CNode& peer) const;
@@ -1792,10 +1798,19 @@ bool PeerManagerImpl::GetNodeStateStats(NodeId nodeid, CNodeStateStats& stats) c
             stats.presync_height = peer->m_headers_sync->GetPresyncHeight();
         }
     }
+    stats.time_offset = peer->m_time_offset;
 
     return true;
 }
 
+PeerManagerInfo PeerManagerImpl::GetInfo() const
+{
+    return PeerManagerInfo{
+        .median_outbound_time_offset = m_outbound_time_offsets.Median(),
+        .ignores_incoming_txs = m_opts.ignore_incoming_txs,
+    };
+}
+
 void PeerManagerImpl::AddToCompactExtraTransactions(const CTransactionRef& tx)
 {
     if (m_opts.max_extra_txs <= 0)
@@ -3666,12 +3681,12 @@ void PeerManagerImpl::ProcessMessage(CNode& pfrom, const std::string& msg_type,
                   peer->m_starting_height, addrMe.ToStringAddrPort(), fRelay, pfrom.GetId(),
                   remoteAddr, (mapped_as ? strprintf(", mapped_as=%d", mapped_as) : ""));
 
-        int64_t nTimeOffset = nTime - GetTime();
-        pfrom.nTimeOffset = nTimeOffset;
+        peer->m_time_offset = NodeSeconds{std::chrono::seconds{nTime}} - Now<NodeSeconds>();
         if (!pfrom.IsInboundConn()) {
             // Don't use timedata samples from inbound peers to make it
-            // harder for others to tamper with our adjusted time.
-            AddTimeData(pfrom.addr, nTimeOffset);
+            // harder for others to create false warnings about our clock being out of sync.
+            m_outbound_time_offsets.Add(peer->m_time_offset);
+            m_outbound_time_offsets.WarnIfOutOfSync();
         }
 
         // If the peer is old enough to have the old alert system, send it the final alert.
diff --git a/src/net_processing.h b/src/net_processing.h
index f8d7a8f5115e1..85e399d948ba8 100644
--- a/src/net_processing.h
+++ b/src/net_processing.h
@@ -9,6 +9,8 @@
 #include <net.h>
 #include <validationinterface.h>
 
+#include <chrono>
+
 class AddrMan;
 class CChainParams;
 class CTxMemPool;
@@ -41,6 +43,12 @@ struct CNodeStateStats {
     bool m_addr_relay_enabled{false};
     ServiceFlags their_services;
     int64_t presync_height{-1};
+    std::chrono::seconds time_offset{0};
+};
+
+struct PeerManagerInfo {
+    std::chrono::seconds median_outbound_time_offset{0s};
+    bool ignores_incoming_txs{false};
 };
 
 class PeerManager : public CValidationInterface, public NetEventsInterface
@@ -83,8 +91,8 @@ class PeerManager : public CValidationInterface, public NetEventsInterface
     /** Get statistics from node state */
     virtual bool GetNodeStateStats(NodeId nodeid, CNodeStateStats& stats) const = 0;
 
-    /** Whether this node ignores txs received over p2p. */
-    virtual bool IgnoresIncomingTxs() = 0;
+    /** Get peer manager info. */
+    virtual PeerManagerInfo GetInfo() const = 0;
 
     /** Relay transaction to all peers. */
     virtual void RelayTransaction(const uint256& txid, const uint256& wtxid) = 0;
diff --git a/src/node/timeoffsets.cpp b/src/node/timeoffsets.cpp
new file mode 100644
index 0000000000000..62f527be8a66f
--- /dev/null
+++ b/src/node/timeoffsets.cpp
@@ -0,0 +1,69 @@
+// Copyright (c) 2024-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <logging.h>
+#include <node/interface_ui.h>
+#include <node/timeoffsets.h>
+#include <sync.h>
+#include <tinyformat.h>
+#include <util/time.h>
+#include <util/translation.h>
+#include <warnings.h>
+
+#include <algorithm>
+#include <chrono>
+#include <cstdint>
+#include <deque>
+#include <limits>
+#include <optional>
+
+using namespace std::chrono_literals;
+
+void TimeOffsets::Add(std::chrono::seconds offset)
+{
+    LOCK(m_mutex);
+
+    if (m_offsets.size() >= MAX_SIZE) {
+        m_offsets.pop_front();
+    }
+    m_offsets.push_back(offset);
+    LogDebug(BCLog::NET, "Added time offset %+ds, total samples %d\n",
+             Ticks<std::chrono::seconds>(offset), m_offsets.size());
+}
+
+std::chrono::seconds TimeOffsets::Median() const
+{
+    LOCK(m_mutex);
+
+    // Only calculate the median if we have 5 or more offsets
+    if (m_offsets.size() < 5) return 0s;
+
+    auto sorted_copy = m_offsets;
+    std::sort(sorted_copy.begin(), sorted_copy.end());
+    return sorted_copy[sorted_copy.size() / 2];  // approximate median is good enough, keep it simple
+}
+
+bool TimeOffsets::WarnIfOutOfSync() const
+{
+    // when median == std::numeric_limits<int64_t>::min(), calling std::chrono::abs is UB
+    auto median{std::max(Median(), std::chrono::seconds(std::numeric_limits<int64_t>::min() + 1))};
+    if (std::chrono::abs(median) <= WARN_THRESHOLD) {
+        SetMedianTimeOffsetWarning(std::nullopt);
+        uiInterface.NotifyAlertChanged();
+        return false;
+    }
+
+    bilingual_str msg{strprintf(_(
+        "Your computer's date and time appear to be more than %d minutes out of sync with the network, "
+        "this may lead to consensus failure. After you've confirmed your computer's clock, this message "
+        "should no longer appear when you restart your node. Without a restart, it should stop showing "
+        "automatically after you've connected to a sufficient number of new outbound peers, which may "
+        "take some time. You can inspect the `timeoffset` field of the `getpeerinfo` and `getnetworkinfo` "
+        "RPC methods to get more info."
+    ), Ticks<std::chrono::minutes>(WARN_THRESHOLD))};
+    LogWarning("%s\n", msg.original);
+    SetMedianTimeOffsetWarning(msg);
+    uiInterface.NotifyAlertChanged();
+    return true;
+}
diff --git a/src/node/timeoffsets.h b/src/node/timeoffsets.h
new file mode 100644
index 0000000000000..2b12584e125cc
--- /dev/null
+++ b/src/node/timeoffsets.h
@@ -0,0 +1,39 @@
+// Copyright (c) 2024-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#ifndef BITCOIN_NODE_TIMEOFFSETS_H
+#define BITCOIN_NODE_TIMEOFFSETS_H
+
+#include <sync.h>
+
+#include <chrono>
+#include <cstddef>
+#include <deque>
+
+class TimeOffsets
+{
+    //! Maximum number of timeoffsets stored.
+    static constexpr size_t MAX_SIZE{50};
+    //! Minimum difference between system and network time for a warning to be raised.
+    static constexpr std::chrono::minutes WARN_THRESHOLD{10};
+
+    mutable Mutex m_mutex;
+    /** The observed time differences between our local clock and those of our outbound peers. A
+     * positive offset means our peer's clock is ahead of our local clock. */
+    std::deque<std::chrono::seconds> m_offsets GUARDED_BY(m_mutex){};
+
+public:
+    /** Add a new time offset sample. */
+    void Add(std::chrono::seconds offset) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
+
+    /** Compute and return the median of the collected time offset samples. The median is returned
+     * as 0 when there are less than 5 samples. */
+    std::chrono::seconds Median() const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
+
+    /** Raise warnings if the median time offset exceeds the warnings threshold. Returns true if
+     * warnings were raised. */
+    bool WarnIfOutOfSync() const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);
+};
+
+#endif // BITCOIN_NODE_TIMEOFFSETS_H
diff --git a/src/qt/guiutil.cpp b/src/qt/guiutil.cpp
index 084b9a6615881..5f5e002db715b 100644
--- a/src/qt/guiutil.cpp
+++ b/src/qt/guiutil.cpp
@@ -771,9 +771,9 @@ QString formatPingTime(std::chrono::microseconds ping_time)
         QObject::tr("%1 ms").arg(QString::number((int)(count_microseconds(ping_time) / 1000), 10));
 }
 
-QString formatTimeOffset(int64_t nTimeOffset)
+QString formatTimeOffset(int64_t time_offset)
 {
-  return QObject::tr("%1 s").arg(QString::number((int)nTimeOffset, 10));
+  return QObject::tr("%1 s").arg(QString::number((int)time_offset, 10));
 }
 
 QString formatNiceTimeOffset(qint64 secs)
diff --git a/src/qt/guiutil.h b/src/qt/guiutil.h
index 7f06fdfe37d8b..3e28e545574a4 100644
--- a/src/qt/guiutil.h
+++ b/src/qt/guiutil.h
@@ -240,8 +240,8 @@ namespace GUIUtil
     /** Format a CNodeStats.m_last_ping_time into a user-readable string or display N/A, if 0 */
     QString formatPingTime(std::chrono::microseconds ping_time);
 
-    /** Format a CNodeCombinedStats.nTimeOffset into a user-readable string */
-    QString formatTimeOffset(int64_t nTimeOffset);
+    /** Format a CNodeStateStats.time_offset into a user-readable string */
+    QString formatTimeOffset(int64_t time_offset);
 
     QString formatNiceTimeOffset(qint64 secs);
 
diff --git a/src/qt/rpcconsole.cpp b/src/qt/rpcconsole.cpp
index d2b184ebdf5bd..bafef62945a60 100644
--- a/src/qt/rpcconsole.cpp
+++ b/src/qt/rpcconsole.cpp
@@ -23,6 +23,7 @@
 #include <rpc/server.h>
 #include <util/strencodings.h>
 #include <util/string.h>
+#include <util/time.h>
 #include <util/threadnames.h>
 
 #include <univalue.h>
@@ -1196,7 +1197,6 @@ void RPCConsole::updateDetailWidget()
     ui->peerBytesRecv->setText(GUIUtil::formatBytes(stats->nodeStats.nRecvBytes));
     ui->peerPingTime->setText(GUIUtil::formatPingTime(stats->nodeStats.m_last_ping_time));
     ui->peerMinPing->setText(GUIUtil::formatPingTime(stats->nodeStats.m_min_ping_time));
-    ui->timeoffset->setText(GUIUtil::formatTimeOffset(stats->nodeStats.nTimeOffset));
     if (stats->nodeStats.nVersion) {
         ui->peerVersion->setText(QString::number(stats->nodeStats.nVersion));
     }
@@ -1228,6 +1228,7 @@ void RPCConsole::updateDetailWidget()
     // This check fails for example if the lock was busy and
     // nodeStateStats couldn't be fetched.
     if (stats->fNodeStateStatsAvailable) {
+        ui->timeoffset->setText(GUIUtil::formatTimeOffset(Ticks<std::chrono::seconds>(stats->nodeStateStats.time_offset)));
         ui->peerServices->setText(GUIUtil::formatServicesStr(stats->nodeStateStats.their_services));
         // Sync height is init to -1
         if (stats->nodeStateStats.nSyncHeight > -1) {
diff --git a/src/rpc/net.cpp b/src/rpc/net.cpp
index 3fa2b18495314..e3d50917a5b6f 100644
--- a/src/rpc/net.cpp
+++ b/src/rpc/net.cpp
@@ -23,7 +23,6 @@
 #include <rpc/server_util.h>
 #include <rpc/util.h>
 #include <sync.h>
-#include <timedata.h>
 #include <util/chaintype.h>
 #include <util/strencodings.h>
 #include <util/string.h>
@@ -239,7 +238,7 @@ static RPCHelpMan getpeerinfo()
         obj.pushKV("bytessent", stats.nSendBytes);
         obj.pushKV("bytesrecv", stats.nRecvBytes);
         obj.pushKV("conntime", count_seconds(stats.m_connected));
-        obj.pushKV("timeoffset", stats.nTimeOffset);
+        obj.pushKV("timeoffset", Ticks<std::chrono::seconds>(statestats.time_offset));
         if (stats.m_last_ping_time > 0us) {
             obj.pushKV("pingtime", Ticks<SecondsDouble>(stats.m_last_ping_time));
         }
@@ -679,9 +678,10 @@ static RPCHelpMan getnetworkinfo()
         obj.pushKV("localservicesnames", GetServicesNames(services));
     }
     if (node.peerman) {
-        obj.pushKV("localrelay", !node.peerman->IgnoresIncomingTxs());
+        auto peerman_info{node.peerman->GetInfo()};
+        obj.pushKV("localrelay", !peerman_info.ignores_incoming_txs);
+        obj.pushKV("timeoffset", Ticks<std::chrono::seconds>(peerman_info.median_outbound_time_offset));
     }
-    obj.pushKV("timeoffset",    GetTimeOffset());
     if (node.connman) {
         obj.pushKV("networkactive", node.connman->GetNetworkActive());
         obj.pushKV("connections", node.connman->GetNodeCount(ConnectionDirection::Both));
diff --git a/src/timedata.cpp b/src/timedata.cpp
deleted file mode 100644
index d948de976f88c..0000000000000
--- a/src/timedata.cpp
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (c) 2014-2022 The Bitcoin Core developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-#if defined(HAVE_CONFIG_H)
-#include <config/bitcoin-config.h>
-#endif
-
-#include <timedata.h>
-
-#include <common/args.h>
-#include <logging.h>
-#include <netaddress.h>
-#include <node/interface_ui.h>
-#include <sync.h>
-#include <tinyformat.h>
-#include <util/translation.h>
-#include <warnings.h>
-
-static GlobalMutex g_timeoffset_mutex;
-static int64_t nTimeOffset GUARDED_BY(g_timeoffset_mutex) = 0;
-
-/**
- * "Never go to sea with two chronometers; take one or three."
- * Our three time sources are:
- *  - System clock
- *  - Median of other nodes clocks
- *  - The user (asking the user to fix the system clock if the first two disagree)
- */
-int64_t GetTimeOffset()
-{
-    LOCK(g_timeoffset_mutex);
-    return nTimeOffset;
-}
-
-#define BITCOIN_TIMEDATA_MAX_SAMPLES 200
-
-static std::set<CNetAddr> g_sources;
-static CMedianFilter<int64_t> g_time_offsets{BITCOIN_TIMEDATA_MAX_SAMPLES, 0};
-static bool g_warning_emitted;
-
-void AddTimeData(const CNetAddr& ip, int64_t nOffsetSample)
-{
-    LOCK(g_timeoffset_mutex);
-    // Ignore duplicates
-    if (g_sources.size() == BITCOIN_TIMEDATA_MAX_SAMPLES)
-        return;
-    if (!g_sources.insert(ip).second)
-        return;
-
-    // Add data
-    g_time_offsets.input(nOffsetSample);
-    LogPrint(BCLog::NET, "added time data, samples %d, offset %+d (%+d minutes)\n", g_time_offsets.size(), nOffsetSample, nOffsetSample / 60);
-
-    // There is a known issue here (see issue #4521):
-    //
-    // - The structure g_time_offsets contains up to 200 elements, after which
-    // any new element added to it will not increase its size, replacing the
-    // oldest element.
-    //
-    // - The condition to update nTimeOffset includes checking whether the
-    // number of elements in g_time_offsets is odd, which will never happen after
-    // there are 200 elements.
-    //
-    // But in this case the 'bug' is protective against some attacks, and may
-    // actually explain why we've never seen attacks which manipulate the
-    // clock offset.
-    //
-    // So we should hold off on fixing this and clean it up as part of
-    // a timing cleanup that strengthens it in a number of other ways.
-    //
-    if (g_time_offsets.size() >= 5 && g_time_offsets.size() % 2 == 1) {
-        int64_t nMedian = g_time_offsets.median();
-        std::vector<int64_t> vSorted = g_time_offsets.sorted();
-        // Only let other nodes change our time by so much
-        int64_t max_adjustment = std::max<int64_t>(0, gArgs.GetIntArg("-maxtimeadjustment", DEFAULT_MAX_TIME_ADJUSTMENT));
-        if (nMedian >= -max_adjustment && nMedian <= max_adjustment) {
-            nTimeOffset = nMedian;
-        } else {
-            nTimeOffset = 0;
-
-            if (!g_warning_emitted) {
-                // If nobody has a time different than ours but within 5 minutes of ours, give a warning
-                bool fMatch = false;
-                for (const int64_t nOffset : vSorted) {
-                    if (nOffset != 0 && nOffset > -5 * 60 && nOffset < 5 * 60) fMatch = true;
-                }
-
-                if (!fMatch) {
-                    g_warning_emitted = true;
-                    bilingual_str strMessage = strprintf(_("Please check that your computer's date and time are correct! If your clock is wrong, %s will not work properly."), PACKAGE_NAME);
-                    SetMiscWarning(strMessage);
-                    uiInterface.ThreadSafeMessageBox(strMessage, "", CClientUIInterface::MSG_WARNING);
-                }
-            }
-        }
-
-        if (LogAcceptCategory(BCLog::NET, BCLog::Level::Debug)) {
-            std::string log_message{"time data samples: "};
-            for (const int64_t n : vSorted) {
-                log_message += strprintf("%+d  ", n);
-            }
-            log_message += strprintf("|  median offset = %+d  (%+d minutes)", nTimeOffset, nTimeOffset / 60);
-            LogPrint(BCLog::NET, "%s\n", log_message);
-        }
-    }
-}
-
-void TestOnlyResetTimeData()
-{
-    LOCK(g_timeoffset_mutex);
-    nTimeOffset = 0;
-    g_sources.clear();
-    g_time_offsets = CMedianFilter<int64_t>{BITCOIN_TIMEDATA_MAX_SAMPLES, 0};
-    g_warning_emitted = false;
-}
diff --git a/src/timedata.h b/src/timedata.h
deleted file mode 100644
index 3e76f80452d30..0000000000000
--- a/src/timedata.h
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (c) 2014-2022 The Bitcoin Core developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-#ifndef BITCOIN_TIMEDATA_H
-#define BITCOIN_TIMEDATA_H
-
-#include <algorithm>
-#include <cassert>
-#include <cstdint>
-#include <vector>
-
-static const int64_t DEFAULT_MAX_TIME_ADJUSTMENT = 70 * 60;
-
-class CNetAddr;
-
-/**
- * Median filter over a stream of values.
- * Returns the median of the last N numbers
- */
-template <typename T>
-class CMedianFilter
-{
-private:
-    std::vector<T> vValues;
-    std::vector<T> vSorted;
-    unsigned int nSize;
-
-public:
-    CMedianFilter(unsigned int _size, T initial_value) : nSize(_size)
-    {
-        vValues.reserve(_size);
-        vValues.push_back(initial_value);
-        vSorted = vValues;
-    }
-
-    void input(T value)
-    {
-        if (vValues.size() == nSize) {
-            vValues.erase(vValues.begin());
-        }
-        vValues.push_back(value);
-
-        vSorted.resize(vValues.size());
-        std::copy(vValues.begin(), vValues.end(), vSorted.begin());
-        std::sort(vSorted.begin(), vSorted.end());
-    }
-
-    T median() const
-    {
-        int vSortedSize = vSorted.size();
-        assert(vSortedSize > 0);
-        if (vSortedSize & 1) // Odd number of elements
-        {
-            return vSorted[vSortedSize / 2];
-        } else // Even number of elements
-        {
-            return (vSorted[vSortedSize / 2 - 1] + vSorted[vSortedSize / 2]) / 2;
-        }
-    }
-
-    int size() const
-    {
-        return vValues.size();
-    }
-
-    std::vector<T> sorted() const
-    {
-        return vSorted;
-    }
-};
-
-/** Functions to keep track of adjusted P2P time */
-int64_t GetTimeOffset();
-void AddTimeData(const CNetAddr& ip, int64_t nTime);
-
-/**
- * Reset the internal state of GetTimeOffset() and AddTimeData().
- */
-void TestOnlyResetTimeData();
-
-#endif // BITCOIN_TIMEDATA_H
diff --git a/src/warnings.cpp b/src/warnings.cpp
index 84b021dad54ce..d55eecc48deea 100644
--- a/src/warnings.cpp
+++ b/src/warnings.cpp
@@ -14,11 +14,13 @@
 #include <util/string.h>
 #include <util/translation.h>
 
+#include <optional>
 #include <vector>
 
 static GlobalMutex g_warnings_mutex;
 static bilingual_str g_misc_warnings GUARDED_BY(g_warnings_mutex);
 static bool fLargeWorkInvalidChainFound GUARDED_BY(g_warnings_mutex) = false;
+static std::optional<bilingual_str> g_timeoffset_warning GUARDED_BY(g_warnings_mutex){};
 
 void SetMiscWarning(const bilingual_str& warning)
 {
@@ -32,6 +34,11 @@ void SetfLargeWorkInvalidChainFound(bool flag)
     fLargeWorkInvalidChainFound = flag;
 }
 
+void SetMedianTimeOffsetWarning(std::optional<bilingual_str> warning)
+{
+    LOCK(g_warnings_mutex);
+    g_timeoffset_warning = warning;
+}
 bilingual_str GetWarnings(bool verbose)
 {
     bilingual_str warnings_concise;
@@ -56,6 +63,10 @@ bilingual_str GetWarnings(bool verbose)
         warnings_verbose.emplace_back(warnings_concise);
     }
 
+    if (g_timeoffset_warning) {
+        warnings_verbose.emplace_back(g_timeoffset_warning.value());
+    }
+
     if (verbose) {
         return Join(warnings_verbose, Untranslated("<hr />"));
     }
diff --git a/src/warnings.h b/src/warnings.h
index b21e2ea2b8a35..866bce6246f1c 100644
--- a/src/warnings.h
+++ b/src/warnings.h
@@ -6,12 +6,15 @@
 #ifndef BITCOIN_WARNINGS_H
 #define BITCOIN_WARNINGS_H
 
+#include <optional>
 #include <string>
 
 struct bilingual_str;
 
 void SetMiscWarning(const bilingual_str& warning);
 void SetfLargeWorkInvalidChainFound(bool flag);
+/** Pass std::nullopt to disable the warning */
+void SetMedianTimeOffsetWarning(std::optional<bilingual_str> warning);
 /** Format a string that describes several potential problems detected by the core.
  * @param[in] verbose bool
  * - if true, get all warnings separated by <hr />


Test patch:
diff --git a/src/Makefile.test.include b/src/Makefile.test.include
index 9f9bdbbd0cd4b..c20b531032b37 100644
--- a/src/Makefile.test.include
+++ b/src/Makefile.test.include
@@ -151,7 +151,7 @@ BITCOIN_TESTS =\
   test/streams_tests.cpp \
   test/sync_tests.cpp \
   test/system_tests.cpp \
-  test/timedata_tests.cpp \
+  test/timeoffsets_tests.cpp \
   test/torcontrol_tests.cpp \
   test/transaction_tests.cpp \
   test/translation_tests.cpp \
@@ -381,7 +381,7 @@ test_fuzz_fuzz_SOURCES = \
  test/fuzz/string.cpp \
  test/fuzz/strprintf.cpp \
  test/fuzz/system.cpp \
- test/fuzz/timedata.cpp \
+ test/fuzz/timeoffsets.cpp \
  test/fuzz/torcontrol.cpp \
  test/fuzz/transaction.cpp \
  test/fuzz/tx_in.cpp \
diff --git a/src/test/denialofservice_tests.cpp b/src/test/denialofservice_tests.cpp
index 0fef8c59069e1..5e9ae78681261 100644
--- a/src/test/denialofservice_tests.cpp
+++ b/src/test/denialofservice_tests.cpp
@@ -16,7 +16,6 @@
 #include <test/util/net.h>
 #include <test/util/random.h>
 #include <test/util/setup_common.h>
-#include <timedata.h>
 #include <util/string.h>
 #include <util/time.h>
 #include <validation.h>
@@ -72,7 +71,6 @@ BOOST_AUTO_TEST_CASE(outbound_slow_chain_eviction)
         /*local_services=*/ServiceFlags(NODE_NETWORK | NODE_WITNESS),
         /*version=*/PROTOCOL_VERSION,
         /*relay_txs=*/true);
-    TestOnlyResetTimeData();
 
     // This test requires that we have a chain with non-zero work.
     {
diff --git a/src/test/fuzz/timedata.cpp b/src/test/fuzz/timedata.cpp
deleted file mode 100644
index f5d005296bbd2..0000000000000
--- a/src/test/fuzz/timedata.cpp
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (c) 2020-2021 The Bitcoin Core developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-
-#include <test/fuzz/FuzzedDataProvider.h>
-#include <test/fuzz/fuzz.h>
-#include <test/fuzz/util.h>
-#include <timedata.h>
-
-#include <cstdint>
-#include <string>
-#include <vector>
-
-FUZZ_TARGET(timedata)
-{
-    FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size());
-    const unsigned int max_size = fuzzed_data_provider.ConsumeIntegralInRange<unsigned int>(0, 1000);
-    // A max_size of 0 implies no limit, so cap the max number of insertions to avoid timeouts
-    auto max_to_insert = fuzzed_data_provider.ConsumeIntegralInRange<int>(0, 4000);
-    // Divide by 2 to avoid signed integer overflow in .median()
-    const int64_t initial_value = fuzzed_data_provider.ConsumeIntegral<int64_t>() / 2;
-    CMedianFilter<int64_t> median_filter{max_size, initial_value};
-    while (fuzzed_data_provider.remaining_bytes() > 0 && --max_to_insert >= 0) {
-        (void)median_filter.median();
-        assert(median_filter.size() > 0);
-        assert(static_cast<size_t>(median_filter.size()) == median_filter.sorted().size());
-        assert(static_cast<unsigned int>(median_filter.size()) <= max_size || max_size == 0);
-        // Divide by 2 to avoid signed integer overflow in .median()
-        median_filter.input(fuzzed_data_provider.ConsumeIntegral<int64_t>() / 2);
-    }
-}
diff --git a/src/test/fuzz/timeoffsets.cpp b/src/test/fuzz/timeoffsets.cpp
new file mode 100644
index 0000000000000..019337a94a544
--- /dev/null
+++ b/src/test/fuzz/timeoffsets.cpp
@@ -0,0 +1,28 @@
+// Copyright (c) 2024-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+#include <node/timeoffsets.h>
+#include <test/fuzz/FuzzedDataProvider.h>
+#include <test/fuzz/fuzz.h>
+#include <test/util/setup_common.h>
+
+#include <chrono>
+#include <cstdint>
+#include <functional>
+
+void initialize_timeoffsets()
+{
+    static const auto testing_setup = MakeNoLogFileContext<>(ChainType::MAIN);
+}
+
+FUZZ_TARGET(timeoffsets, .init = initialize_timeoffsets)
+{
+    FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size());
+    TimeOffsets offsets{};
+    LIMITED_WHILE(fuzzed_data_provider.remaining_bytes() > 0, 4'000) {
+        (void)offsets.Median();
+        offsets.Add(std::chrono::seconds{fuzzed_data_provider.ConsumeIntegral<std::chrono::seconds::rep>()});
+        offsets.WarnIfOutOfSync();
+    }
+}
diff --git a/src/test/net_tests.cpp b/src/test/net_tests.cpp
index 70a8279f049a4..b9dff9661016b 100644
--- a/src/test/net_tests.cpp
+++ b/src/test/net_tests.cpp
@@ -19,7 +19,6 @@
 #include <test/util/random.h>
 #include <test/util/setup_common.h>
 #include <test/util/validation.h>
-#include <timedata.h>
 #include <util/strencodings.h>
 #include <util/string.h>
 #include <validation.h>
@@ -902,10 +901,6 @@ BOOST_AUTO_TEST_CASE(initial_advertise_from_version_message)
     chainman.ResetIbd();
     m_node.args->ForceSetArg("-capturemessages", "0");
     m_node.args->ForceSetArg("-bind", "");
-    // PeerManager::ProcessMessage() calls AddTimeData() which changes the internal state
-    // in timedata.cpp and later confuses the test "timedata_tests/addtimedata". Thus reset
-    // that state as it was before our test was run.
-    TestOnlyResetTimeData();
 }
 
 
diff --git a/src/test/timedata_tests.cpp b/src/test/timedata_tests.cpp
deleted file mode 100644
index 2814dbf4c06a7..0000000000000
--- a/src/test/timedata_tests.cpp
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (c) 2011-2021 The Bitcoin Core developers
-// Distributed under the MIT software license, see the accompanying
-// file COPYING or http://www.opensource.org/licenses/mit-license.php.
-//
-
-#include <netaddress.h>
-#include <noui.h>
-#include <test/util/logging.h>
-#include <test/util/setup_common.h>
-#include <timedata.h>
-#include <util/string.h>
-#include <util/translation.h>
-#include <warnings.h>
-
-#include <string>
-
-#include <boost/test/unit_test.hpp>
-
-BOOST_FIXTURE_TEST_SUITE(timedata_tests, BasicTestingSetup)
-
-BOOST_AUTO_TEST_CASE(util_MedianFilter)
-{
-    CMedianFilter<int> filter(5, 15);
-
-    BOOST_CHECK_EQUAL(filter.median(), 15);
-
-    filter.input(20); // [15 20]
-    BOOST_CHECK_EQUAL(filter.median(), 17);
-
-    filter.input(30); // [15 20 30]
-    BOOST_CHECK_EQUAL(filter.median(), 20);
-
-    filter.input(3); // [3 15 20 30]
-    BOOST_CHECK_EQUAL(filter.median(), 17);
-
-    filter.input(7); // [3 7 15 20 30]
-    BOOST_CHECK_EQUAL(filter.median(), 15);
-
-    filter.input(18); // [3 7 18 20 30]
-    BOOST_CHECK_EQUAL(filter.median(), 18);
-
-    filter.input(0); // [0 3 7 18 30]
-    BOOST_CHECK_EQUAL(filter.median(), 7);
-}
-
-static void MultiAddTimeData(int n, int64_t offset)
-{
-    static int cnt = 0;
-    for (int i = 0; i < n; ++i) {
-        CNetAddr addr;
-        addr.SetInternal(ToString(++cnt));
-        AddTimeData(addr, offset);
-    }
-}
-
-
-BOOST_AUTO_TEST_CASE(addtimedata)
-{
-    BOOST_CHECK_EQUAL(GetTimeOffset(), 0);
-
-    //Part 1: Add large offsets to test a warning message that our clock may be wrong.
-    MultiAddTimeData(3, DEFAULT_MAX_TIME_ADJUSTMENT + 1);
-    // Filter size is 1 + 3 = 4: It is always initialized with a single element (offset 0)
-
-    {
-        ASSERT_DEBUG_LOG("Please check that your computer's date and time are correct!");
-        MultiAddTimeData(1, DEFAULT_MAX_TIME_ADJUSTMENT + 1); //filter size 5
-    }
-
-    BOOST_CHECK(GetWarnings(true).original.find("clock is wrong") != std::string::npos);
-
-    // nTimeOffset is not changed if the median of offsets exceeds DEFAULT_MAX_TIME_ADJUSTMENT
-    BOOST_CHECK_EQUAL(GetTimeOffset(), 0);
-
-    // Part 2: Test positive and negative medians by adding more offsets
-    MultiAddTimeData(4, 100); // filter size 9
-    BOOST_CHECK_EQUAL(GetTimeOffset(), 100);
-    MultiAddTimeData(10, -100); //filter size 19
-    BOOST_CHECK_EQUAL(GetTimeOffset(), -100);
-
-    // Part 3: Test behaviour when filter has reached maximum number of offsets
-    const int MAX_SAMPLES = 200;
-    int nfill = (MAX_SAMPLES - 3 - 19) / 2; //89
-    MultiAddTimeData(nfill, 100);
-    MultiAddTimeData(nfill, -100); //filter size MAX_SAMPLES - 3
-    BOOST_CHECK_EQUAL(GetTimeOffset(), -100);
-
-    MultiAddTimeData(2, 100);
-    //filter size MAX_SAMPLES -1, median is the initial 0 offset
-    //since we added same number of positive/negative offsets
-
-    BOOST_CHECK_EQUAL(GetTimeOffset(), 0);
-
-    // After the number of offsets has reached MAX_SAMPLES -1 (=199), nTimeOffset will never change
-    // because it is only updated when the number of elements in the filter becomes odd. It was decided
-    // not to fix this because it prevents possible attacks. See the comment in AddTimeData() or issue #4521
-    // for a more detailed explanation.
-    MultiAddTimeData(2, 100); // filter median is 100 now, but nTimeOffset will not change
-    // We want this test to end with nTimeOffset==0, otherwise subsequent tests of the suite will fail.
-    BOOST_CHECK_EQUAL(GetTimeOffset(), 0);
-
-    TestOnlyResetTimeData();
-}
-
-BOOST_AUTO_TEST_SUITE_END()
diff --git a/src/test/timeoffsets_tests.cpp b/src/test/timeoffsets_tests.cpp
new file mode 100644
index 0000000000000..008f1a9db9595
--- /dev/null
+++ b/src/test/timeoffsets_tests.cpp
@@ -0,0 +1,69 @@
+// Copyright (c) 2024-present The Bitcoin Core developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+//
+
+#include <node/timeoffsets.h>
+#include <test/util/setup_common.h>
+
+#include <boost/test/unit_test.hpp>
+
+#include <chrono>
+#include <vector>
+
+using namespace std::chrono_literals;
+
+static void AddMulti(TimeOffsets& offsets, const std::vector<std::chrono::seconds>& to_add)
+{
+    for (auto offset : to_add) {
+        offsets.Add(offset);
+    }
+}
+
+BOOST_FIXTURE_TEST_SUITE(timeoffsets_tests, BasicTestingSetup)
+
+BOOST_AUTO_TEST_CASE(timeoffsets)
+{
+    TimeOffsets offsets{};
+    BOOST_CHECK(offsets.Median() == 0s);
+
+    AddMulti(offsets, {{0s, -1s, -2s, -3s}});
+    // median should be zero for < 5 offsets
+    BOOST_CHECK(offsets.Median() == 0s);
+
+    offsets.Add(-4s);
+    // we now have 5 offsets: [-4, -3, -2, -1, 0]
+    BOOST_CHECK(offsets.Median() == -2s);
+
+    AddMulti(offsets, {4, 5s});
+    // we now have 9 offsets: [-4, -3, -2, -1, 0, 5, 5, 5, 5]
+    BOOST_CHECK(offsets.Median() == 0s);
+
+    AddMulti(offsets, {41, 10s});
+    // the TimeOffsets is now at capacity with 50 offsets, oldest offsets is discarded for any additional offset
+    BOOST_CHECK(offsets.Median() == 10s);
+
+    AddMulti(offsets, {25, 15s});
+    // we now have 25 offsets of 10s followed by 25 offsets of 15s
+    BOOST_CHECK(offsets.Median() == 15s);
+}
+
+static bool IsWarningRaised(const std::vector<std::chrono::seconds>& check_offsets)
+{
+    TimeOffsets offsets{};
+    AddMulti(offsets, check_offsets);
+    return offsets.WarnIfOutOfSync();
+}
+
+
+BOOST_AUTO_TEST_CASE(timeoffsets_warning)
+{
+    BOOST_CHECK(IsWarningRaised({{-60min, -40min, -30min, 0min, 10min}}));
+    BOOST_CHECK(IsWarningRaised({5, 11min}));
+
+    BOOST_CHECK(!IsWarningRaised({4, 60min}));
+    BOOST_CHECK(!IsWarningRaised({100, 3min}));
+}
+
+
+BOOST_AUTO_TEST_SUITE_END()
diff --git a/test/functional/feature_maxtipage.py b/test/functional/feature_maxtipage.py
index 51f37ef1e01ad..a1774a539534f 100755
--- a/test/functional/feature_maxtipage.py
+++ b/test/functional/feature_maxtipage.py
@@ -43,6 +43,10 @@ def test_maxtipage(self, maxtipage, set_parameter=True, test_deltas=True):
         self.generate(node_miner, 1)
         assert_equal(node_ibd.getblockchaininfo()['initialblockdownload'], False)
 
+        # reset time to system time so we don't have a time offset with the ibd node the next
+        # time we connect to it, ensuring TimeOffsets::WarnIfOutOfSync() doesn't output to stderr
+        node_miner.setmocktime(0)
+
     def run_test(self):
         self.log.info("Test IBD with maximum tip age of 24 hours (default).")
         self.test_maxtipage(DEFAULT_MAX_TIP_AGE, set_parameter=False)