Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions allways/validator/axon_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
18 changes: 17 additions & 1 deletion tests/test_axon_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
Loading