From 3724c99582d453ac70f55a8b0f16290d23d2bb46 Mon Sep 17 00:00:00 2001 From: anderdc Date: Tue, 9 Jun 2026 11:38:48 -0500 Subject: [PATCH] perf(validator): defer source-balance lookup until after cheap reserve rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BTC source-balance check is an uncached external Esplora/gomaestro HTTP call, and it ran before the commitment/slippage/already-reserved/ cooldown rejections — so every reserve request that was going to be rejected anyway still forced one external API call. Under a reserve spam burst this is a per-request amplification vector (one upstream call each, even for doomed requests) that burns provider quota and backs up the axon threadpool. Move the balance lookup to the last gate before vote_reserve, after all the cheap in-process/substrate rejections. Spam destined for those now rejects without ever touching Esplora. The TAO (substrate) balance read still serialises under axon_lock and the BTC read stays lock-free, both unchanged — just relocated. Tradeoff: the balance call now sits between the already-reserved check and the vote, so a concurrent request could reserve the miner first. That race costs at most one doomed vote_reserve, which the contract rejects atomically, so the early-reject guarantee is unchanged. --- allways/validator/axon_handlers.py | 38 ++++++++++++++++++++---------- tests/test_axon_handlers.py | 18 +++++++++++++- 2 files changed, 42 insertions(+), 14 deletions(-) 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