Skip to content
Closed
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
46 changes: 46 additions & 0 deletions allways/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
75 changes: 75 additions & 0 deletions allways/cli/preflight.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions allways/cli/swap_commands/resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions allways/cli/swap_commands/swap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1055,6 +1056,9 @@ def swap_now_command(
console.print('[yellow]Swap paused. Resume with: alw swap post-tx <tx_hash>[/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
Expand Down
56 changes: 56 additions & 0 deletions tests/test_chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
122 changes: 122 additions & 0 deletions tests/test_preflight_send_runway.py
Original file line number Diff line number Diff line change
@@ -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()