diff --git a/allways/validator/chain_verification.py b/allways/validator/chain_verification.py index c9ecdb9..6cdb3cf 100644 --- a/allways/validator/chain_verification.py +++ b/allways/validator/chain_verification.py @@ -63,6 +63,10 @@ def observe_initiation(self, swap: Swap, current_block: int = 0) -> None: except Exception: tip = None if tip and tip > 0: + # If fulfillment landed before a late snapshot (RPC retry), cap the + # lower bound so an honest payout is not rejected as a replay. + if swap.to_tx_block and swap.to_tx_block > 0: + tip = min(tip, swap.to_tx_block) self.dest_tip_at_init[swap.id] = tip if self.state_store is not None: # Same fail-open discipline as the RPC call above: a sqlite diff --git a/tests/test_chain_verification.py b/tests/test_chain_verification.py index 7e32752..64d354e 100644 --- a/tests/test_chain_verification.py +++ b/tests/test_chain_verification.py @@ -92,6 +92,19 @@ def test_failed_snapshot_leaves_no_entry_so_retry_is_possible(self): assert v.dest_tip_at_init[7] == 850_500 + def test_late_snapshot_capped_when_payout_already_on_chain(self): + btc = MagicMock() + btc.get_current_block_height.side_effect = [None, 850_500] + v = SwapVerifier(chain_providers={'btc': btc}) + swap = make_swap(swap_id=7, to_chain='btc') + swap.to_tx_block = 850_100 + + v.observe_initiation(swap) + v.observe_initiation(swap) + + assert v.dest_tip_at_init[7] == 850_100 + assert v.is_dest_tx_fresh(swap, tx_at(850_100)) is True + def test_rpc_raises_treated_as_failure(self): btc = MagicMock() btc.get_current_block_height.side_effect = RuntimeError('boom')