diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index 26293e9..8d1b0d7 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -19,6 +19,7 @@ MAX_EXTENSIONS_PER_RESERVATION, NETUID_FINNEY, TAO_TO_RAO, + USER_POST_TX_CUSHION_BLOCKS, ) from allways.contract_client import AllwaysContractClient, ContractError, is_contract_rejection @@ -36,6 +37,33 @@ def blocks_to_minutes_str(blocks: int) -> str: return f'~{blocks * SECONDS_PER_BLOCK / 60:.0f} min' +def safe_reservation_remaining(reserved_until: int, current_block: int) -> Optional[int]: + """Return remaining reservation blocks if safe to broadcast confirm, else ``None``. + + Prints a user-facing reason when ``None`` is returned. Caller handles any + state cleanup (e.g. ``clear_pending_swap()``) and CLI exit. + + Validators refuse propose_extend_reservation once + ``current + CHALLENGE_WINDOW_BLOCKS >= reserved_until`` — confirms + broadcast inside that window have no extension rescue path. On tao→btc + the source TAO is sent to the miner before the swap row exists, so a + failed reservation is an unrecoverable loss. + """ + remaining = reserved_until - current_block + if remaining <= 0: + console.print('[red]Reservation has expired.[/red]') + return None + if remaining <= USER_POST_TX_CUSHION_BLOCKS: + console.print( + f'[red]Reservation has only {remaining} blocks remaining — inside the ' + f'{USER_POST_TX_CUSHION_BLOCKS}-block safety cushion. Validators may not ' + f'be able to extend it in time, and a failed reservation on tao→btc loses ' + f'the source funds.[/red]' + ) + return None + return remaining + + SWAP_STATUS_COLORS = { SwapStatus.ACTIVE: 'yellow', SwapStatus.FULFILLED: 'blue', diff --git a/allways/cli/swap_commands/post_tx.py b/allways/cli/swap_commands/post_tx.py index 2429b73..a0f38c8 100644 --- a/allways/cli/swap_commands/post_tx.py +++ b/allways/cli/swap_commands/post_tx.py @@ -16,6 +16,7 @@ mark_pending_swap_tx_sent, print_contract_error, resolve_source_tx_block, + safe_reservation_remaining, ) from allways.cli.swap_commands.swap import from_smallest_unit, poll_for_swap_creation, sign_and_broadcast_confirm from allways.constants import NETUID_FINNEY @@ -69,13 +70,12 @@ def post_tx_command(tx_hash: str, tx_block: int): print_contract_error('Failed to read reservation status', e) return - if reserved_until <= current_block: + remaining = safe_reservation_remaining(reserved_until, current_block) + if remaining is None: clear_pending_swap() - console.print('[red]Reservation has expired.[/red]') console.print('[dim]Run `alw swap now` to start a new swap.[/dim]') return - remaining = reserved_until - current_block human_amount = from_smallest_unit(state.from_amount, state.from_chain) console.print('\n[bold]Pending Swap[/bold]\n') diff --git a/allways/cli/swap_commands/resume.py b/allways/cli/swap_commands/resume.py index b0bf179..b98f23d 100644 --- a/allways/cli/swap_commands/resume.py +++ b/allways/cli/swap_commands/resume.py @@ -21,6 +21,7 @@ loading, mark_pending_swap_tx_sent, resolve_source_tx_block, + safe_reservation_remaining, ) from allways.cli.swap_commands.swap import ( from_smallest_unit, @@ -132,30 +133,28 @@ def resume_reservation_command(from_tx_hash_opt: Optional[str], auto_send: bool, except ContractError: pass - # Check reservation status — if expired, there's nothing to resume + # Check reservation status — if expired or inside the safety cushion, there's nothing to resume try: with loading('Reading reservation status...'): reserved_until = client.get_miner_reserved_until(state.miner_hotkey) current_block = subtensor.get_current_block() - reservation_active = reserved_until > current_block except ContractError as e: console.print(f'[red]Failed to read reservation status: {e}[/red]') return - if not reservation_active: - # Reservation is cleared either on expiry or when vote_initiate succeeds. + remaining = safe_reservation_remaining(reserved_until, current_block) + if remaining is None: + # Reservation is cleared on expiry or when vote_initiate succeeds. # A silent initiate means the swap is already in flight or has completed — # surface both possibilities rather than assuming expiry. clear_pending_swap() - console.print('\n[yellow]Reservation is no longer active.[/yellow]') console.print( - '[dim]Either the reservation expired, or your swap already initiated and may be in progress ' - 'or completed. Check with: alw view active-swaps[/dim]\n' + '[dim]Your swap may already have initiated and be in progress or completed. ' + 'Check with: alw view active-swaps[/dim]\n' ) console.print('[dim]Start a new swap with: alw swap now[/dim]') return - remaining = reserved_until - current_block console.print(f'\n[green]Reservation still active ({blocks_to_minutes_str(remaining)} left)[/green]') # Set up chain provider diff --git a/allways/constants.py b/allways/constants.py index c838c29..2e88bd8 100644 --- a/allways/constants.py +++ b/allways/constants.py @@ -115,6 +115,14 @@ MAX_EXTENSIONS_PER_RESERVATION = 2 MAX_EXTENSIONS_PER_SWAP = 2 +# User-side analogue of MINER_TIMEOUT_CUSHION_BLOCKS: refuse to broadcast a +# source-tx confirm when remaining reservation runway is inside this window. +# Validators won't propose_extend_reservation once current + CHALLENGE_WINDOW +# >= reserved_until, so a confirm submitted inside that range has no +# extension rescue path. On tao→btc that means losing the source TAO — +# reservations have no escrow and no slash-to-user on failure. +USER_POST_TX_CUSHION_BLOCKS = CHALLENGE_WINDOW_BLOCKS + # ─── Protocol Fee ────────────────────────────────────────── # Hardcoded 1% — matches the contract's immutable FEE_DIVISOR. FEE_DIVISOR = 100 diff --git a/tests/test_safe_reservation_remaining.py b/tests/test_safe_reservation_remaining.py new file mode 100644 index 0000000..f39a6c2 --- /dev/null +++ b/tests/test_safe_reservation_remaining.py @@ -0,0 +1,46 @@ +"""safe_reservation_remaining: user-side cushion before broadcasting confirm. + +Mirrors the miner-side timeout cushion. The threshold lives in +``USER_POST_TX_CUSHION_BLOCKS`` (= ``CHALLENGE_WINDOW_BLOCKS``) and gates the +``alw swap post-tx`` / ``alw swap resume-reservation`` flows so a user can't +ship a confirm into a window where validators can no longer propose a +reservation extension. +""" + +from allways.cli.swap_commands.helpers import safe_reservation_remaining +from allways.constants import USER_POST_TX_CUSHION_BLOCKS + + +class TestSafeReservationRemaining: + def test_returns_remaining_when_well_above_cushion(self): + # 30 blocks of runway, well clear of the 8-block cushion. + assert safe_reservation_remaining(reserved_until=1_000, current_block=970) == 30 + + def test_returns_none_at_zero_remaining(self): + assert safe_reservation_remaining(reserved_until=1_000, current_block=1_000) is None + + def test_returns_none_when_already_expired(self): + assert safe_reservation_remaining(reserved_until=1_000, current_block=1_005) is None + + def test_returns_none_at_cushion_boundary(self): + # remaining == cushion is unsafe — validators refuse propose_extend + # once current + CHALLENGE_WINDOW >= reserved_until. + rem_eq_cushion = USER_POST_TX_CUSHION_BLOCKS + assert ( + safe_reservation_remaining( + reserved_until=1_000, + current_block=1_000 - rem_eq_cushion, + ) + is None + ) + + def test_returns_remaining_one_block_outside_cushion(self): + # remaining == cushion + 1 is the first safe value. + rem_just_safe = USER_POST_TX_CUSHION_BLOCKS + 1 + assert ( + safe_reservation_remaining( + reserved_until=1_000, + current_block=1_000 - rem_just_safe, + ) + == rem_just_safe + )