From 8ef0a88bf0b966e2445db18c77a5a8751afb1734 Mon Sep 17 00:00:00 2001 From: Asti1982 <65121113+Asti1982@users.noreply.github.com> Date: Tue, 12 May 2026 21:45:32 +0200 Subject: [PATCH] fix: preserve cross-node nonce owner --- .../src/cross_node_replay_defense.py | 11 ++++- .../tests/test_cross_node_replay_defense.py | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/bounties/issue-2296/src/cross_node_replay_defense.py b/bounties/issue-2296/src/cross_node_replay_defense.py index da85ae0f4..2938ac42f 100644 --- a/bounties/issue-2296/src/cross_node_replay_defense.py +++ b/bounties/issue-2296/src/cross_node_replay_defense.py @@ -302,14 +302,21 @@ def store_used_cross_node_nonce( expires_at = now_ts + CROSS_NODE_NONCE_TTL try: - conn.execute( + cleanup_expired_nonces(conn, now_ts) + + cursor = conn.execute( """ - INSERT OR REPLACE INTO cross_node_nonces + INSERT OR IGNORE INTO cross_node_nonces (nonce, miner_id, node_id, first_seen, expires_at, attestation_hash, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?) """, (nonce, miner_id, NODE_ID, now_ts, expires_at, attestation_hash, now_ts) ) + if cursor.rowcount == 0: + conn.commit() + log.warning(f"Nonce {nonce[:16]}... already tracked; preserving original owner") + return False + conn.commit() log.debug(f"Stored nonce {nonce[:16]}... for miner {miner_id}") diff --git a/bounties/issue-2296/tests/test_cross_node_replay_defense.py b/bounties/issue-2296/tests/test_cross_node_replay_defense.py index 3840a164f..44f22e075 100644 --- a/bounties/issue-2296/tests/test_cross_node_replay_defense.py +++ b/bounties/issue-2296/tests/test_cross_node_replay_defense.py @@ -571,6 +571,53 @@ def test_miner_binding_invariant(self, test_db): assert valid is False assert error == "nonce_belongs_to_different_miner" + def test_store_preserves_first_active_nonce_owner(self, test_db): + """Storing an already-active nonce must not replace its first owner.""" + nonce = "nonce_first_owner_invariant" + + with patch('cross_node_replay_defense.NODE_ID', "node-original"): + first_result = store_used_cross_node_nonce(test_db, nonce, "miner_original") + + with patch('cross_node_replay_defense.NODE_ID', "node-replay"): + second_result = store_used_cross_node_nonce(test_db, nonce, "miner_replay") + + row = test_db.execute( + """ + SELECT miner_id, node_id + FROM cross_node_nonces + WHERE nonce = ? + """, + (nonce,), + ).fetchone() + + assert first_result is True + assert second_result is False + assert row == ("miner_original", "node-original") + + def test_store_can_reuse_nonce_after_expiration(self, test_db): + """Expired records are cleaned before a fresh nonce owner is stored.""" + nonce = "nonce_expired_then_reused" + past_time = 1700000000 + future_time = past_time + CROSS_NODE_NONCE_TTL + 100 + + with patch('cross_node_replay_defense.NODE_ID', "node-old"): + store_used_cross_node_nonce(test_db, nonce, "miner_old", now_ts=past_time) + + with patch('cross_node_replay_defense.NODE_ID', "node-new"): + result = store_used_cross_node_nonce(test_db, nonce, "miner_new", now_ts=future_time) + + row = test_db.execute( + """ + SELECT miner_id, node_id + FROM cross_node_nonces + WHERE nonce = ? + """, + (nonce,), + ).fetchone() + + assert result is True + assert row == ("miner_new", "node-new") + # ============================================================================= # Regression Tests