diff --git a/.gitignore b/.gitignore index 9e84aac..ebe488d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ venv/ +.venv/ allways-venv/ .env *.env diff --git a/allways/cli/swap_commands/__init__.py b/allways/cli/swap_commands/__init__.py index be68520..adb42eb 100644 --- a/allways/cli/swap_commands/__init__.py +++ b/allways/cli/swap_commands/__init__.py @@ -4,6 +4,8 @@ from allways.cli.swap_commands.miner_commands import miner_group from allways.cli.swap_commands.pair import post_pair from allways.cli.swap_commands.post_tx import post_tx_command +from allways.cli.swap_commands.quote import quote_command +from allways.cli.swap_commands.resume import resume_command from allways.cli.swap_commands.status import status_command from allways.cli.swap_commands.swap import swap_group from allways.cli.swap_commands.view import view_group @@ -11,8 +13,10 @@ # Register post under the miner group miner_group.add_command(post_pair, 'post') -# Register post-tx under the swap group +# Register post-tx, quote, resume under the swap group swap_group.add_command(post_tx_command, 'post-tx') +swap_group.add_command(quote_command, 'quote') +swap_group.add_command(resume_command, 'resume') def register_commands(cli): diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index 2a6f154..06a293d 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -9,7 +9,7 @@ import bittensor as bt from rich.console import Console -from allways.classes import SwapStatus +from allways.classes import MinerPair, SwapStatus from allways.commitments import parse_commitment_data, read_miner_commitment, read_miner_commitments # noqa: F401 from allways.constants import CONTRACT_ADDRESS as DEFAULT_CONTRACT_ADDRESS from allways.constants import NETUID_FINNEY, TAO_TO_RAO @@ -207,3 +207,32 @@ def load_pending_swap() -> Optional[PendingSwapState]: def clear_pending_swap() -> None: """Remove the pending swap state file.""" PENDING_SWAP_FILE.unlink(missing_ok=True) + + +def find_matching_miners(all_pairs, source_chain: str, dest_chain: str): + """Filter and normalize miner pairs for a given swap direction (bilateral matching). + + Handles both direct matches and reverse-direction pairs (using counter_rate for the + reverse direction). Returns list of MinerPair with source/dest matching the requested direction. + """ + matching = [] + for p in all_pairs: + if p.source_chain == source_chain and p.dest_chain == dest_chain: + if p.rate > 0: + matching.append(p) + elif p.source_chain == dest_chain and p.dest_chain == source_chain: + rev_rate, rev_rate_str = p.get_rate_for_direction(source_chain) + if rev_rate > 0: + matching.append( + MinerPair( + uid=p.uid, + hotkey=p.hotkey, + source_chain=p.dest_chain, + source_address=p.dest_address, + dest_chain=p.source_chain, + dest_address=p.source_address, + rate=rev_rate, + rate_str=rev_rate_str, + ) + ) + return matching diff --git a/allways/cli/swap_commands/quote.py b/allways/cli/swap_commands/quote.py new file mode 100644 index 0000000..d831f65 --- /dev/null +++ b/allways/cli/swap_commands/quote.py @@ -0,0 +1,123 @@ +"""alw swap quote - Preview rates and estimated receive amounts before swapping.""" + +from decimal import Decimal + +import rich_click as click +from rich.table import Table + +from allways.chains import SUPPORTED_CHAINS, canonical_pair, get_chain +from allways.cli.swap_commands.helpers import ( + console, + find_matching_miners, + from_rao, + get_cli_context, + loading, + read_miner_commitments, +) +from allways.constants import DEFAULT_FEE_DIVISOR +from allways.contract_client import ContractError +from allways.utils.rate import apply_fee_deduction, calculate_dest_amount + + +@click.command('quote') +@click.option('--from', 'source_chain', required=True, type=str, help='Source chain (e.g. btc, tao)') +@click.option('--to', 'dest_chain', required=True, type=str, help='Destination chain (e.g. btc, tao)') +@click.option('--amount', required=True, type=float, help='Amount to send in source chain units') +def quote_command(source_chain: str, dest_chain: str, amount: float): + """Preview rates and estimated receive amounts for a swap. + + \b + Shows all available miners, their rates, and what you would receive + after fees — without committing to a swap. + + \b + Examples: + alw swap quote --from btc --to tao --amount 0.1 + alw swap quote --from tao --to btc --amount 50 + """ + source_chain = source_chain.lower() + dest_chain = dest_chain.lower() + + if source_chain not in SUPPORTED_CHAINS: + console.print(f'[red]Unknown source chain: {source_chain}[/red]') + return + if dest_chain not in SUPPORTED_CHAINS: + console.print(f'[red]Unknown destination chain: {dest_chain}[/red]') + return + if source_chain == dest_chain: + console.print('[red]Source and destination chains must be different[/red]') + return + if amount <= 0: + console.print('[red]Amount must be positive[/red]') + return + + config, _, subtensor, client = get_cli_context(need_wallet=False) + netuid = config['netuid'] + + # Convert to smallest units + src_chain_def = get_chain(source_chain) + source_amount = int(Decimal(str(amount)) * (10**src_chain_def.decimals)) + + # Read fee divisor + try: + fee_divisor = client.get_fee_divisor() or DEFAULT_FEE_DIVISOR + except ContractError: + fee_divisor = DEFAULT_FEE_DIVISOR + + fee_pct = 100 / fee_divisor + + # Find available miners + with loading('Reading rates...'): + all_pairs = read_miner_commitments(subtensor, netuid) + matching = find_matching_miners(all_pairs, source_chain, dest_chain) + + available = [] + for pair in matching: + try: + is_active = client.get_miner_active_flag(pair.hotkey) + has_swap = client.get_miner_has_active_swap(pair.hotkey) + collateral = client.get_miner_collateral(pair.hotkey) + if is_active and not has_swap and collateral > 0: + available.append((pair, collateral)) + except ContractError: + continue + + if not available: + console.print('[yellow]No active miners available for this pair[/yellow]\n') + return + + available.sort(key=lambda x: x[0].rate, reverse=True) + + # Calculate amounts per miner + canon_src, canon_dest = canonical_pair(source_chain, dest_chain) + is_reverse = source_chain != canon_src + canon_dest_decimals = get_chain(canon_dest).decimals + canon_src_decimals = get_chain(canon_src).decimals + dst_chain_def = get_chain(dest_chain) + + console.print(f'\n[bold]Quote: {amount} {source_chain.upper()} -> {dest_chain.upper()}[/bold]\n') + + table = Table(show_header=True) + table.add_column('#', style='dim') + table.add_column('UID', style='cyan') + table.add_column(f'Rate ({dest_chain.upper()}/{source_chain.upper()})', style='green') + table.add_column('You Receive', style='bold green') + table.add_column('Collateral', style='yellow') + + for idx, (pair, collateral) in enumerate(available, 1): + dest_amount = calculate_dest_amount( + source_amount, pair.rate_str, is_reverse, canon_dest_decimals, canon_src_decimals + ) + user_receives = apply_fee_deduction(dest_amount, fee_divisor) + human_receives = user_receives / (10**dst_chain_def.decimals) + + table.add_row( + str(idx), + str(pair.uid), + f'{pair.rate:g}', + f'{human_receives:.8f} {dest_chain.upper()}', + f'{from_rao(collateral):.4f} TAO', + ) + + console.print(table) + console.print(f' [dim](after {fee_pct:g}% fee)[/dim]\n') diff --git a/allways/cli/swap_commands/resume.py b/allways/cli/swap_commands/resume.py new file mode 100644 index 0000000..6c7f117 --- /dev/null +++ b/allways/cli/swap_commands/resume.py @@ -0,0 +1,210 @@ +"""alw swap resume - Recover an interrupted swap flow.""" + +import os +import time +from typing import Optional + +import rich_click as click +from rich.panel import Panel + +from allways.chain_providers import create_chain_providers +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.swap_commands.helpers import ( + SECONDS_PER_BLOCK, + clear_pending_swap, + console, + get_cli_context, + load_pending_swap, +) +from allways.cli.swap_commands.swap import ( + _from_smallest_unit, + _poll_for_swap_with_progress, + sign_and_broadcast_confirm, +) +from allways.contract_client import ContractError + + +@click.command('resume') +@click.option('--source-tx-hash', 'source_tx_hash_opt', default=None, help='Source tx hash (skip fund sending)') +@click.option('--yes', 'skip_confirm', is_flag=True, help='Skip confirmation prompts') +@click.option('--netuid', default=None, type=int, help='Subnet UID') +def resume_command(source_tx_hash_opt: Optional[str], skip_confirm: bool, netuid: int): + """Resume an interrupted swap from where it left off. + + \b + Picks up a pending swap that has an active reservation — submits the + source transaction hash and confirms with validators. If the reservation + has expired, guides the user to start fresh with `alw swap now`. + + \b + Interactive mode: + alw swap resume + + \b + Non-interactive mode (for scripting/agents): + alw swap resume --source-tx-hash abc123... --yes + """ + state = load_pending_swap() + if not state: + console.print('[yellow]No pending swap found.[/yellow]') + console.print('[dim]Start a new swap with: alw swap now[/dim]') + return + + config, wallet, subtensor, client = get_cli_context() + if netuid is None: + netuid = int(config.get('netuid', state.netuid)) + + # Check if system is halted + try: + if client.get_halted(): + console.print('[red]System is halted — no swaps can be processed. Please try again later.[/red]') + return + except ContractError: + pass + + # Display pending swap summary + elapsed_min = (time.time() - state.created_at) / 60 + human_send = _from_smallest_unit(state.source_amount, state.source_chain) + human_receive = _from_smallest_unit(state.user_receives, state.dest_chain) + send_label = f'{human_send} {state.source_chain.upper()}' + + summary = ( + f' Direction: {state.source_chain.upper()} -> {state.dest_chain.upper()}\n' + f' Miner: UID {state.miner_uid}\n' + f' Send: {send_label}\n' + f' Receive: ~{human_receive:.8f} {state.dest_chain.upper()}\n' + f' Send to: {state.miner_source_address}\n' + f' Started: {elapsed_min:.0f} min ago' + ) + console.print() + console.print(Panel(summary, title='[bold]Pending Swap[/bold]', expand=False)) + + # Check if swap is already on-chain (cheap bool check before expensive scan) + try: + if client.get_miner_has_active_swap(state.miner_hotkey): + for swap in client.get_miner_active_swaps(state.miner_hotkey): + is_ours = ( + swap.user_source_address == state.user_source_address + or swap.user_dest_address == state.receive_address + ) + if is_ours: + clear_pending_swap() + console.print(f'\n[green]Swap already on-chain! ID: {swap.id}[/green]') + if not skip_confirm: + from allways.cli.swap_commands.view import watch_swap + + final = watch_swap(client, swap.id) + if final and final.status == SwapStatus.COMPLETED: + from allways.cli.swap_commands.swap import _display_receipt + + _display_receipt(final) + return + except ContractError: + pass + + # Check reservation status — if expired, there's nothing to resume + try: + 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: + clear_pending_swap() + console.print('\n[red]Reservation has expired.[/red]') + 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 (~{remaining * SECONDS_PER_BLOCK // 60} min left)[/green]') + + # Set up chain provider + if 'BTC_MODE' not in os.environ: + os.environ['BTC_MODE'] = 'lightweight' + chain_providers = create_chain_providers(subtensor=subtensor) + provider = chain_providers.get(state.source_chain) + if not provider: + console.print(f'[red]No chain provider for {state.source_chain}[/red]') + return + + source_key = wallet.coldkey if state.source_chain == 'tao' else None + + # Discover validators before prompting for tx hash (fail early) + validator_axons = discover_validators(subtensor, netuid, contract_client=client) + if not validator_axons: + console.print('[red]No validators found on metagraph[/red]') + return + + ephemeral_wallet = get_ephemeral_wallet() + + # Prompt for source tx hash if not provided + if not source_tx_hash_opt: + console.print(f'\n Send [green]{send_label}[/green] to: [cyan]{state.miner_source_address}[/cyan]\n') + source_tx_hash_opt = click.prompt('Enter transaction hash after sending (or "skip" to exit)', default='') + if not source_tx_hash_opt or source_tx_hash_opt.lower() == 'skip': + console.print('[yellow]Swap paused. Resume later with: alw swap resume[/yellow]') + return + + source_tx_hash = source_tx_hash_opt.strip() + + console.print('\n[dim]Confirming with validators...[/dim]') + accepted, queued = sign_and_broadcast_confirm( + provider, + state.user_source_address, + source_key, + source_tx_hash, + state.miner_hotkey, + state.receive_address, + validator_axons, + ephemeral_wallet, + source_chain=state.source_chain, + dest_chain=state.dest_chain, + ) + + if accepted == 0: + console.print('[yellow]No validators accepted. You can retry: alw swap resume[/yellow]') + return + + all_queued = queued > 0 and queued == accepted + if all_queued: + chain_def = get_chain(state.source_chain) + est_min = chain_def.min_confirmations * chain_def.seconds_per_block / 60 + console.print( + f'\n Waiting for [bold]{chain_def.min_confirmations} {state.source_chain.upper()}[/bold]' + f' confirmation(s) (~{est_min:.0f} min)...' + ) + console.print('\n [dim]You can safely exit (Ctrl+C) — validators will continue processing.[/dim]') + + max_polls = 600 if all_queued else 60 + try: + swap_id = _poll_for_swap_with_progress(client, state.miner_hotkey, state.source_chain, max_polls) + except KeyboardInterrupt: + clear_pending_swap() + console.print('\n\n[green]Your swap is still being processed by validators.[/green]') + console.print('[dim]Once initiated, watch with: alw view swap --watch[/dim]\n') + return + + if swap_id is None: + clear_pending_swap() + console.print('\n[yellow]Swap not yet initiated. Validators may still be waiting for confirmations.[/yellow]') + console.print('[dim]Check back with: alw view swaps[/dim]\n') + return + + clear_pending_swap() + console.print(f'\n[green bold]Swap initiated! ID: {swap_id}[/green bold]') + + if skip_confirm: + return + + from allways.cli.swap_commands.view import watch_swap + + final_swap = watch_swap(client, swap_id) + + if final_swap and final_swap.status == SwapStatus.COMPLETED: + from allways.cli.swap_commands.swap import _display_receipt + + _display_receipt(final_swap)