commit 1cd76f829c7fb9d1ef8a8f434c2e565ecc0dfbb7
parent beb30a8860b54178cf665532f43a0d51da20274c
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 29 Jul 2026 14:59:00 +0200
Fix network time replay edge cases
Diffstat:
3 files changed, 98 insertions(+), 2 deletions(-)
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -2047,7 +2047,7 @@ async fn process_hello(
network,
known_peer,
remote_addr,
- &PeerStatus::new(hello.height, hello.tip_hash.clone()),
+ &PeerStatus::with_time(hello.height, hello.tip_hash.clone(), hello.time_ms),
)
.await;
if request_snapshot {
@@ -3010,6 +3010,60 @@ mod tests {
}
#[tokio::test]
+ async fn hello_records_remote_clock_observation() {
+ let alice = Wallet::from_seed("hello-clock-alice");
+ let allocations = allocations(std::slice::from_ref(&alice), 1_000);
+ let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations)));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())),
+ listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ node_id: super::new_node_id(),
+ sessions: tokio::sync::Mutex::new(BTreeMap::new()),
+ tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
+ metrics: super::P2pMetricsCounters::default(),
+ }),
+ };
+ let remote_time_ms = crate::app::now_ms().saturating_add(60_000);
+ let hello = ProtocolHello {
+ protocol_version: PROTOCOL_VERSION,
+ network_id: NETWORK_ID.to_string(),
+ genesis_hash: network
+ .inner
+ .node
+ .lock()
+ .await
+ .ledger()
+ .genesis_hash()
+ .to_string(),
+ listen_addr: Some("127.0.0.1:9545".to_string()),
+ node_id: None,
+ height: 0,
+ tip_hash: "tip".to_string(),
+ time_ms: remote_time_ms,
+ };
+
+ let mut known_peer = None;
+ super::process_hello(
+ &network,
+ "127.0.0.1:9545".parse().unwrap(),
+ &mut known_peer,
+ hello,
+ )
+ .await
+ .unwrap();
+
+ let peers = network.inner.peers.lock().await.list();
+ let peer = peers
+ .iter()
+ .find(|peer| peer.address == "127.0.0.1:9545")
+ .unwrap();
+ assert!(peer.last_clock_offset_ms.unwrap() > 30_000);
+ assert_eq!(peer.last_clock_offset_accepted, Some(true));
+ }
+
+ #[tokio::test]
async fn setup_placeholder_accepts_remote_genesis_and_adopts_snapshot() {
let local_wallet = Wallet::from_seed("setup-placeholder-local");
let local_ledger = Ledger::new(BTreeMap::new(), 1);
diff --git a/src/domain.rs b/src/domain.rs
@@ -1232,6 +1232,10 @@ impl Ledger {
Self::from_snapshot_at(snapshot, unix_now_ms())
}
+ pub fn from_persisted_snapshot(snapshot: ChainSnapshot) -> Result<Self> {
+ Self::from_snapshot_at(snapshot, u64::MAX)
+ }
+
pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> {
Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms)
}
diff --git a/src/main.rs b/src/main.rs
@@ -211,7 +211,7 @@ async fn initialize_ledger(
);
}
let height = snapshot_height(&snapshot);
- let ledger = Ledger::from_snapshot(snapshot).with_context(|| {
+ let ledger = Ledger::from_persisted_snapshot(snapshot).with_context(|| {
format!(
"failed to load chain database {}",
chain_store.path().display()
@@ -1038,6 +1038,44 @@ mod tests {
}
#[tokio::test]
+ async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() {
+ let dir = tempdir().unwrap();
+ let chain_path = dir.path().join("chain.sqlite3");
+ let store = SqliteChainStore::open(&chain_path).unwrap();
+ let persisted_wallet = Wallet::from_seed("persisted-future-chain-owner");
+ let mut persisted = ledger_with_one_spendable_iuna(&persisted_wallet);
+ let burn = persisted.build_burn(&persisted_wallet, 1, 0).unwrap();
+ persisted.submit_transaction(burn).unwrap();
+ let future_tip_ms = iuna::app::now_ms().saturating_add(VDF_TARGET_BLOCK_MS);
+ let future_block = persisted
+ .mine_next_block(&persisted_wallet, future_tip_ms)
+ .unwrap();
+ let mut snapshot = persisted.snapshot();
+ snapshot.blocks.push(future_block);
+ assert!(
+ Ledger::from_snapshot(snapshot.clone())
+ .unwrap_err()
+ .to_string()
+ .contains("too far in the future")
+ );
+ store.save(&snapshot).unwrap();
+ let fresh_wallet = Wallet::from_seed("fresh-start-wallet");
+ let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
+ .unwrap()
+ .unwrap();
+
+ let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ .await
+ .unwrap();
+
+ assert_eq!(resumed.status().height, 1);
+ assert_eq!(
+ resumed.status().tip_hash,
+ snapshot.blocks.last().unwrap().hash
+ );
+ }
+
+ #[tokio::test]
async fn persisted_chain_satisfies_join_mode_without_contacting_peer() {
let dir = tempdir().unwrap();
let chain_path = dir.path().join("chain.sqlite3");