diff --git a/allways/validator/axon_handlers.py b/allways/validator/axon_handlers.py index 9be19257..3e53f437 100644 --- a/allways/validator/axon_handlers.py +++ b/allways/validator/axon_handlers.py @@ -332,18 +332,6 @@ async def handle_swap_reserve( reject_synapse(synapse, 'Invalid source address proof', ctx) return synapse - # A TAO source reads balance over the shared substrate websocket, so it - # must serialise under axon_lock; a BTC source is HTTP and stays lock-free - # to avoid stalling the forward loop behind a slow Esplora call. - if provider.uses_substrate: - with validator.axon_lock: - balance = provider.get_balance(synapse.from_address) - else: - balance = provider.get_balance(synapse.from_address) - if balance < synapse.from_amount: - reject_synapse(synapse, 'Insufficient source balance', ctx) - return synapse - # Pure-local crypto — compute the request hash outside the lock as a cheap pre-check. from_addr_bytes = synapse.from_address.encode('utf-8') miner_bytes = bytes.fromhex(Keypair(ss58_address=miner).public_key.hex()) @@ -359,7 +347,11 @@ async def handle_swap_reserve( ) ) - # Everything below touches substrate (commitment read, contract reads, vote). + # Substrate early-reject checks (commitment / slippage / already-reserved / + # cooldown) run BEFORE the source-balance lookup. The balance call is the + # only external dependency on this path — for a BTC source it is an uncached + # Esplora HTTP request — so doing it last means spam destined for any of these + # cheap rejections never reaches it, capping per-request amplification. with validator.axon_lock: commitment = load_swap_commitment(validator, miner) if commitment is None: @@ -466,6 +458,26 @@ async def handle_swap_reserve( ) return synapse + # Source balance is the most expensive gate, so it runs last — only after a + # request has cleared every cheap rejection. A TAO source reads balance over + # the shared substrate websocket, so it must serialise under axon_lock; a BTC + # source is HTTP and stays lock-free to avoid stalling the forward loop behind + # a slow Esplora call. + if provider.uses_substrate: + with validator.axon_lock: + balance = provider.get_balance(synapse.from_address) + else: + balance = provider.get_balance(synapse.from_address) + if balance < synapse.from_amount: + reject_synapse(synapse, 'Insufficient source balance', ctx) + return synapse + + # Submit the reserve vote. The contract is the atomic gate; the handler + # checks above are best-effort early-rejects. Moving the balance lookup + # ahead of the vote opens a small window in which a concurrent request could + # reserve this miner first — that race costs at most one doomed vote_reserve, + # which the contract rejects, so the early-reject guarantee is unchanged. + with validator.axon_lock: bt.logging.info( f'{ctx}: preflight ok — collateral={collateral} reserved_until={reserved_until} ' f'cur_block={cur_block} → submitting vote_reserve' diff --git a/tests/test_axon_handlers.py b/tests/test_axon_handlers.py index cd74fc8c..fa98f229 100644 --- a/tests/test_axon_handlers.py +++ b/tests/test_axon_handlers.py @@ -908,6 +908,9 @@ def test_provider_uses_substrate_flags(self): def test_tao_source_balance_check_holds_axon_lock(self): """A TAO-sourced reserve must acquire axon_lock around get_balance.""" + from allways.utils.rate import derive_tao_leg + from allways.validator.axon_handlers import recompute_reserve_amounts + validator = make_reserve_validator() lock = validator.axon_lock @@ -926,8 +929,21 @@ def _get_balance(_addr): tao.get_balance.side_effect = _get_balance validator.axon_chain_providers = {'tao': tao, 'btc': MagicMock()} + # The balance lookup now runs after the quote checks, so the request must + # carry a self-consistent quote to reach it. Derive the amounts from the + # same functions the handler uses so it passes exactly. commitment = make_commitment(from_chain='tao', to_chain='btc') - synapse = make_reserve_synapse(from_chain='tao', to_chain='btc', from_address='5user') + from_amount = _RESERVE_TAO_AMOUNT + to_amount = recompute_reserve_amounts(commitment, 'tao', 'btc', from_amount) + tao_amount = derive_tao_leg('tao', from_amount, 'btc', to_amount) + synapse = make_reserve_synapse( + from_chain='tao', + to_chain='btc', + from_address='5user', + from_amount=from_amount, + to_amount=to_amount, + tao_amount=tao_amount, + ) run_reserve_handler(validator, synapse, commitment=commitment) assert held.get('locked') is True