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
28 changes: 28 additions & 0 deletions allways/cli/swap_commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions allways/cli/swap_commands/post_tx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
15 changes: 7 additions & 8 deletions allways/cli/swap_commands/resume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions allways/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions tests/test_safe_reservation_remaining.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading