diff --git a/allways/validator/forward.py b/allways/validator/forward.py index 1bc586c..628efc3 100644 --- a/allways/validator/forward.py +++ b/allways/validator/forward.py @@ -530,6 +530,10 @@ def extend_fulfilled_near_timeout(self: Validator) -> None: if tx_info is None: continue # dest tx invisible or below canonical payout — neither tier qualifies + if not self.swap_verifier.is_dest_tx_fresh(swap, tx_info): + bt.logging.debug(f'{ctx}: dest tx failed replay check for extension') + continue + # A finalize this step may have just pushed the deadline out. Mirror # the reservation-side gate in try_extend_reservation: once the swap # is no longer near timeout, skip challenge/propose so the next diff --git a/tests/test_forward.py b/tests/test_forward.py index 1cdcb8f..eef15b5 100644 --- a/tests/test_forward.py +++ b/tests/test_forward.py @@ -74,7 +74,10 @@ def make_validator(swap: Swap, block: int, finalized_target, tx_info=_UNSET): optimistic_extensions=ext, chain_providers={'btc': provider}, contract_client=contract_client, - swap_verifier=SimpleNamespace(fee_divisor=100), + swap_verifier=SimpleNamespace( + fee_divisor=100, + is_dest_tx_fresh=lambda _swap, _tx: True, + ), ) @@ -124,6 +127,16 @@ def test_verifies_canonical_payout_and_miner_sender(self): assert call.kwargs['expected_amount'] != 1 assert call.kwargs['expected_amount'] > 0 + def test_skips_extension_when_dest_tx_fails_replay_check(self): + swap = make_fulfilled_swap() + v = make_validator(swap, block=PROPOSE_BLOCK, finalized_target=None) + v.swap_verifier.is_dest_tx_fresh = MagicMock(return_value=False) + + extend_fulfilled_near_timeout(v) + + v.optimistic_extensions.maybe_propose_timeout.assert_not_called() + v.optimistic_extensions.maybe_challenge_timeout.assert_not_called() + def test_skips_extension_when_dest_tx_fails_canonical_check(self): # Provider returns None when the dest tx doesn't match the canonical # amount or expected sender. The extension path must then skip propose