diff --git a/allways/chains.py b/allways/chains.py index 56dc6452..b4340f2f 100644 --- a/allways/chains.py +++ b/allways/chains.py @@ -95,3 +95,49 @@ def compute_extension_target( # at MAX_EXTENSION_BLOCKS (lib.rs:670, :1090), so a deadline anchor blows # the cap whenever propose fires before the deadline. return current_subnet_block + blocks_needed + + +# Status returned by ``classify_send_runway`` — see that function for semantics. +RUNWAY_OK = 'ok' +RUNWAY_EXTENSION_REQUIRED = 'extension_required' +RUNWAY_TOO_SHORT = 'too_short' + +# Subtensor blocks set aside for the user's source tx to propagate to +# validators' RPC view before the extension propose flow can pick it up. +# ~1 minute covers typical Blockstream/Esplora indexing lag. +SEND_PROPAGATION_BUFFER_BLOCKS = 5 + + +def classify_send_runway( + from_chain_id: str, + current_subnet_block: int, + reserved_until_block: int, + extend_threshold_blocks: int, +) -> tuple[str, int]: + """Classify whether broadcasting the source tx now is safe. + + The validator extension flow (optimistic_extensions.py) needs at least + ``extend_threshold_blocks`` of runway before the deadline to land a + propose tx and let its challenge window elapse — without that, the + propose is mechanically doomed and the reservation will expire. The + user's tx also needs a ~1 min propagation buffer so the validator can + actually see it before proposing. + + Returns ``(status, remaining_blocks)`` where status is one of: + * ``RUNWAY_OK`` — full confirmation window fits in remaining TTL. + No validator extension needed; broadcast freely. + * ``RUNWAY_EXTENSION_REQUIRED`` — confirmations won't fit in TTL, + but there is enough runway for validators to auto-extend once + the tx is visible. Caller should warn but may proceed. + * ``RUNWAY_TOO_SHORT`` — TTL is below the extension floor; even + the auto-extension cannot rescue this broadcast. Caller should + refuse to send. + """ + remaining = reserved_until_block - current_subnet_block + confirmation_subnet_blocks = confirmations_to_subtensor_blocks(from_chain_id) + + if remaining < extend_threshold_blocks + SEND_PROPAGATION_BUFFER_BLOCKS: + return (RUNWAY_TOO_SHORT, remaining) + if remaining < confirmation_subnet_blocks + SEND_PROPAGATION_BUFFER_BLOCKS: + return (RUNWAY_EXTENSION_REQUIRED, remaining) + return (RUNWAY_OK, remaining) diff --git a/allways/cli/preflight.py b/allways/cli/preflight.py new file mode 100644 index 00000000..0f1db96f --- /dev/null +++ b/allways/cli/preflight.py @@ -0,0 +1,75 @@ +"""Pre-broadcast safety gate for source funds. + +Sits outside ``swap_commands/`` so importing it does not trigger that +package's eager command-registration ``__init__.py`` (which pulls in +bittensor via helpers). That keeps the wiring unit-testable without the +whole CLI stack. swap.py and resume.py both import the public +``preflight_send_runway`` and call it after the user has approved the +send but before any funds leave the wallet. +""" + +import click +from rich.console import Console + +from allways.chains import ( + RUNWAY_EXTENSION_REQUIRED, + RUNWAY_TOO_SHORT, + classify_send_runway, + get_chain, +) +from allways.constants import EXTEND_THRESHOLD_BLOCKS + +# Local Console rather than helpers.console: helpers.py imports bittensor at +# module load and would defeat the whole point of keeping this module light. +# Both Consoles target stdout — output ordering is unaffected. +_console = Console() + +_SECONDS_PER_BLOCK = 12 + + +def _blocks_to_minutes_str(blocks: int) -> str: + return f'~{blocks * _SECONDS_PER_BLOCK / 60:.0f} min' + + +def preflight_send_runway( + subtensor, + from_chain: str, + reserved_until_block: int, + skip_confirm: bool, +) -> bool: + """Refuse / hard-warn before broadcasting source funds into a doomed reservation. + + Re-reads the current subtensor block (the user may have idled at the + summary panel) and classifies the remaining TTL against the validator + auto-extension floor. Returns True if the caller should proceed with + the broadcast, False if the caller should abort. See + ``classify_send_runway`` in allways/chains.py for the categories. + """ + current_block = subtensor.get_current_block() + status, remaining = classify_send_runway(from_chain, current_block, reserved_until_block, EXTEND_THRESHOLD_BLOCKS) + if status == RUNWAY_TOO_SHORT: + chain = get_chain(from_chain) + confs_min = chain.min_confirmations * chain.seconds_per_block // 60 + _console.print( + f'\n[red]Refusing to send: only {_blocks_to_minutes_str(max(0, remaining))} ' + f'left on the reservation — below the {EXTEND_THRESHOLD_BLOCKS}-block ' + f'floor needed for validators to auto-extend. {chain.min_confirmations} ' + f'{from_chain.upper()} confirmation(s) take ~{confs_min} min, so the ' + f'reservation will expire before your tx confirms and the swap will fail.[/red]' + ) + _console.print('[yellow]Start fresh with a new reservation: [cyan]alw swap now[/cyan][/yellow]') + return False + if status == RUNWAY_EXTENSION_REQUIRED: + chain = get_chain(from_chain) + confs_min = chain.min_confirmations * chain.seconds_per_block // 60 + _console.print( + f'\n[yellow]Reservation has {_blocks_to_minutes_str(remaining)} left, less ' + f'than the ~{confs_min} min needed for {chain.min_confirmations} ' + f'{from_chain.upper()} confirmation(s). Validators will auto-extend once ' + f'your tx is visible — but if they miss the window, the swap will expire ' + f'before confirmation and your funds may be stranded.[/yellow]' + ) + if not skip_confirm and not click.confirm(' Send anyway?', default=False): + _console.print('[yellow]Cancelled. Start fresh with: alw swap now[/yellow]') + return False + return True diff --git a/allways/cli/swap_commands/resume.py b/allways/cli/swap_commands/resume.py index b0bf179a..420bdcfd 100644 --- a/allways/cli/swap_commands/resume.py +++ b/allways/cli/swap_commands/resume.py @@ -11,6 +11,7 @@ from allways.chains import get_chain from allways.classes import SwapStatus from allways.cli.dendrite_lite import discover_validators, get_ephemeral_wallet +from allways.cli.preflight import preflight_send_runway from allways.cli.swap_commands.helpers import ( blocks_to_minutes_str, clear_pending_swap, @@ -187,6 +188,8 @@ def resume_reservation_command(from_tx_hash_opt: Optional[str], auto_send: bool, from_tx_hash_opt = saved_tx used_saved_tx = True elif auto_send: + if not preflight_send_runway(subtensor, state.from_chain, reserved_until, skip_confirm): + return console.print(f'\n[dim]Sending {send_label} to {state.miner_from_address}...[/dim]') if state.from_chain == 'tao': send_result = send_tao_transfer(wallet, subtensor, state.miner_from_address, state.from_amount) diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 37729845..156c8d4b 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -15,6 +15,7 @@ from allways.classes import SwapStatus from allways.cli.dendrite_lite import broadcast_synapse, discover_validators, get_ephemeral_wallet from allways.cli.help import StyledGroup +from allways.cli.preflight import preflight_send_runway from allways.cli.swap_commands.helpers import ( PendingSwapState, blocks_to_minutes_str, @@ -1055,6 +1056,9 @@ def swap_now_command( console.print('[yellow]Swap paused. Resume with: alw swap post-tx [/yellow]') return + if not preflight_send_runway(subtensor, from_chain, reserved_until, skip_confirm): + return + console.print(f'\n[dim]Step 2/3: Sending {from_chain.upper()}...[/dim]') from_tx_hash = None diff --git a/tests/test_chains.py b/tests/test_chains.py index b456ced6..0c0f7bb1 100644 --- a/tests/test_chains.py +++ b/tests/test_chains.py @@ -5,12 +5,18 @@ from allways.chains import ( CHAIN_BTC, CHAIN_TAO, + RUNWAY_EXTENSION_REQUIRED, + RUNWAY_OK, + RUNWAY_TOO_SHORT, + SEND_PROPAGATION_BUFFER_BLOCKS, canonical_pair, + classify_send_runway, compute_extension_target, confirmations_to_subtensor_blocks, get_chain, ) from allways.constants import ( + EXTEND_THRESHOLD_BLOCKS, EXTENSION_BUCKET_BLOCKS, MAX_EXTENSION_BLOCKS, ) @@ -103,3 +109,53 @@ def test_result_is_bucket_aligned(self): def test_unsupported_chain_raises(self): with pytest.raises(KeyError): compute_extension_target('eth', 0, 1000) + + +class TestClassifySendRunway: + # BTC: 2 confs * 600s/12 = 100 subtensor blocks needed for confirmation. + # EXTEND_THRESHOLD_BLOCKS is sized for one validator forward step plus the + # challenge window — below that, the auto-extension propose tx is doomed. + + def test_full_ttl_is_ok(self): + # Fresh 50-block reservation against BTC's 100-block confirmation window: + # confirmation can't fit, so EXTENSION_REQUIRED is expected — not OK. + status, remaining = classify_send_runway('btc', 0, 50, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_EXTENSION_REQUIRED + assert remaining == 50 + + def test_tao_full_ttl_is_ok(self): + # TAO needs only 6 subtensor blocks for confirmation. A 50-block + # reservation comfortably fits — this is the OK path. + status, remaining = classify_send_runway('tao', 0, 50, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_OK + assert remaining == 50 + + def test_below_extension_floor_is_too_short(self): + # Remaining = floor - 1: validators can't propose+challenge before deadline. + floor = EXTEND_THRESHOLD_BLOCKS + SEND_PROPAGATION_BUFFER_BLOCKS + status, remaining = classify_send_runway('btc', 0, floor - 1, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_TOO_SHORT + assert remaining == floor - 1 + + def test_at_extension_floor_is_extension_required(self): + # Exactly at the floor: extension can fire, but confirmation won't fit. + floor = EXTEND_THRESHOLD_BLOCKS + SEND_PROPAGATION_BUFFER_BLOCKS + status, _ = classify_send_runway('btc', 0, floor, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_EXTENSION_REQUIRED + + def test_zero_remaining_is_too_short(self): + status, remaining = classify_send_runway('btc', 100, 100, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_TOO_SHORT + assert remaining == 0 + + def test_negative_remaining_is_too_short(self): + # Reservation already expired — must hard-refuse. + status, remaining = classify_send_runway('btc', 200, 150, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_TOO_SHORT + assert remaining == -50 + + def test_btc_with_long_ttl_is_ok(self): + # BTC needs ~100 subtensor blocks for 2 confs + 5-block propagation buffer: + # a 200-block reservation clears that easily. + status, _ = classify_send_runway('btc', 0, 200, EXTEND_THRESHOLD_BLOCKS) + assert status == RUNWAY_OK diff --git a/tests/test_preflight_send_runway.py b/tests/test_preflight_send_runway.py new file mode 100644 index 00000000..3f189ad7 --- /dev/null +++ b/tests/test_preflight_send_runway.py @@ -0,0 +1,122 @@ +"""Tests for ``preflight_send_runway`` — the CLI gate that refuses to broadcast +source funds into a reservation that's already too short for confirmations to +land (with or without the validator auto-extension flow). + +The classifier itself is unit-tested in test_chains.py; these tests cover the +wiring: re-reading current_block at send time, branch routing +(too_short / extension_required / ok), and the skip_confirm contract. +""" + +from unittest.mock import MagicMock, patch + +from allways.cli.preflight import preflight_send_runway + + +def make_subtensor(current_block: int) -> MagicMock: + """Mock subtensor whose ``get_current_block`` returns the given height.""" + sub = MagicMock() + sub.get_current_block.return_value = current_block + return sub + + +class TestPreflightSendRunway: + def test_ok_runway_passes_silently(self): + # TAO needs ~6 subtensor blocks for confirmation; 200 blocks left is + # comfortably in the OK band — should return True without prompting + # AND without printing anything (covers "no new friction on the + # happy-path swap" — equivalent to the manual smoke test). + sub = make_subtensor(current_block=1000) + with ( + patch('allways.cli.preflight.click.confirm') as confirm, + patch('allways.cli.preflight._console') as console_mock, + ): + result = preflight_send_runway( + subtensor=sub, + from_chain='tao', + reserved_until_block=1200, + skip_confirm=False, + ) + assert result is True + confirm.assert_not_called() + console_mock.print.assert_not_called() + + def test_too_short_hard_refuses_without_prompt(self): + # Reservation only 5 blocks out — well below the extension floor. + # Must return False AND must not have asked the user anything. + sub = make_subtensor(current_block=1000) + with patch('allways.cli.preflight.click.confirm') as confirm: + result = preflight_send_runway( + subtensor=sub, + from_chain='btc', + reserved_until_block=1005, + skip_confirm=False, + ) + assert result is False + confirm.assert_not_called() + + def test_too_short_refuses_in_skip_confirm_mode_too(self): + # --yes must not silently override a doomed broadcast. + sub = make_subtensor(current_block=1000) + result = preflight_send_runway( + subtensor=sub, + from_chain='btc', + reserved_until_block=1005, + skip_confirm=True, + ) + assert result is False + + def test_extension_required_prompts_user_in_interactive_mode(self): + # BTC needs ~100 subtensor blocks for confirmation. 40 blocks left is + # above the extension floor but below the confirmation window — should + # warn and ask. Confirming yes returns True. + sub = make_subtensor(current_block=1000) + with patch('allways.cli.preflight.click.confirm', return_value=True) as confirm: + result = preflight_send_runway( + subtensor=sub, + from_chain='btc', + reserved_until_block=1040, + skip_confirm=False, + ) + assert result is True + confirm.assert_called_once() + + def test_extension_required_user_declines(self): + # Same band, user says no — must abort. + sub = make_subtensor(current_block=1000) + with patch('allways.cli.preflight.click.confirm', return_value=False) as confirm: + result = preflight_send_runway( + subtensor=sub, + from_chain='btc', + reserved_until_block=1040, + skip_confirm=False, + ) + assert result is False + confirm.assert_called_once() + + def test_extension_required_skip_confirm_passes_through(self): + # In --yes mode the extension-required band must not block — the + # validator auto-extension can still rescue this and scripted callers + # need a deterministic exit, not a hung prompt. + sub = make_subtensor(current_block=1000) + with patch('allways.cli.preflight.click.confirm') as confirm: + result = preflight_send_runway( + subtensor=sub, + from_chain='btc', + reserved_until_block=1040, + skip_confirm=True, + ) + assert result is True + confirm.assert_not_called() + + def test_reads_current_block_at_send_time(self): + # The user may idle at the summary panel — the gate must re-read + # current_block instead of trusting whatever was captured at reserve + # time. Verifies subtensor is actually queried. + sub = make_subtensor(current_block=1000) + preflight_send_runway( + subtensor=sub, + from_chain='tao', + reserved_until_block=1200, + skip_confirm=True, + ) + sub.get_current_block.assert_called_once()