From d1a38e819c9ab59f30c1c5a3c813fd93a6868371 Mon Sep 17 00:00:00 2001 From: anderdc Date: Mon, 8 Jun 2026 18:03:44 -0500 Subject: [PATCH] fix(validator): fast-reject reservations while halted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract blocks vote_reserve during a halt (SystemHalted revert), but the validator currently still runs the full handler and submits the doomed extrinsic. During a halt that turns into a retry storm: miners re-request, each failed reserve burns a round-trip, and the writes contend for the hotkey's nonce/write path — starving confirm/timeout votes for in-flight swaps. Check bounds_cache.halted() at the top of handle_swap_reserve and reject immediately, before any provider/substrate work or extrinsic submission. halted() fails open, so an RPC blip falls through to the contract's own rejection rather than refusing a valid reserve. --- allways/validator/axon_handlers.py | 7 +++++++ tests/test_axon_handlers.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/allways/validator/axon_handlers.py b/allways/validator/axon_handlers.py index e064bbf..9be1925 100644 --- a/allways/validator/axon_handlers.py +++ b/allways/validator/axon_handlers.py @@ -304,6 +304,13 @@ async def handle_swap_reserve( ) try: + # Halt blocks reservations contract-side; fast-reject here so a halt + # can't flood doomed vote_reserve extrinsics that starve confirm/timeout + # votes. halted() fails open, so an RPC blip falls through to the contract. + if validator.bounds_cache.halted(): + reject_synapse(synapse, 'System is halted — reservations paused', ctx) + return synapse + # Cheap, local checks BEFORE axon_lock — invalid signatures, missing fields, # and bad direction are rejected without serializing on the substrate websocket. if not synapse.from_address or not synapse.from_address_proof: diff --git a/tests/test_axon_handlers.py b/tests/test_axon_handlers.py index df18de1..cd74fc8 100644 --- a/tests/test_axon_handlers.py +++ b/tests/test_axon_handlers.py @@ -588,6 +588,7 @@ def make_reserve_validator( validator.bounds_cache.min_collateral.return_value = 0 validator.bounds_cache.min_swap_amount.return_value = 0 validator.bounds_cache.max_swap_amount.return_value = 0 + validator.bounds_cache.halted.return_value = False validator.wallet = MagicMock() return validator @@ -994,3 +995,22 @@ def test_handle_miner_activate_rejects_sentinel_commitment(self): assert mock_read.call_args.kwargs['min_swap_rao'] == 500_000_000 assert mock_read.call_args.kwargs['max_swap_rao'] == 5_000_000_000 validator.axon_contract_client.vote_activate.assert_not_called() + + +class TestHaltFastReject: + """A halted system rejects reservations without submitting any extrinsic.""" + + def test_halted_rejects_without_voting(self): + validator = make_reserve_validator() + validator.bounds_cache.halted.return_value = True + result = run_reserve_handler(validator, make_reserve_synapse()) + assert result.accepted is False + assert 'halt' in (result.rejection_reason or '').lower() + validator.axon_contract_client.vote_reserve.assert_not_called() + + def test_halted_short_circuits_before_substrate_work(self): + validator = make_reserve_validator() + validator.bounds_cache.halted.return_value = True + with patch('allways.validator.axon_handlers.read_miner_commitment') as read_cmt: + asyncio.run(handle_swap_reserve(validator, make_reserve_synapse())) + read_cmt.assert_not_called()