From 74c310c5861f67027111abc153b8e131b3c31e72 Mon Sep 17 00:00:00 2001 From: eureka0928 Date: Wed, 8 Apr 2026 03:42:12 +0200 Subject: [PATCH] Add local swap history with SQLite persistence - Save swap receipts to ~/.allways/swap_history.db when swaps reach terminal state (completed/timed_out) or are initiated in non-interactive mode - alw view swap auto-falls back to local history when swap is resolved on-chain, instead of showing "Swap resolved" with no details - alw view history shows all past swaps with --status, --limit, --swap filters - alw view history --stats shows aggregate summary (volume, success rate) - History captured from all touchpoints: swap now, swap resume, post-tx, and view swap --watch --- allways/cli/swap_commands/history.py | 113 ++++++++++++++ allways/cli/swap_commands/post_tx.py | 3 + allways/cli/swap_commands/resume.py | 3 + allways/cli/swap_commands/swap.py | 5 +- allways/cli/swap_commands/view.py | 225 +++++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 allways/cli/swap_commands/history.py diff --git a/allways/cli/swap_commands/history.py b/allways/cli/swap_commands/history.py new file mode 100644 index 0000000..31b2b62 --- /dev/null +++ b/allways/cli/swap_commands/history.py @@ -0,0 +1,113 @@ +"""Local swap history — SQLite persistence for completed/timed-out swaps.""" + +import sqlite3 +import time +from typing import Optional + +from allways.classes import Swap +from allways.cli.swap_commands.helpers import ALLWAYS_DIR + +HISTORY_DB = ALLWAYS_DIR / 'swap_history.db' + +_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS swap_history ( + swap_id INTEGER PRIMARY KEY, + status INTEGER NOT NULL, + source_chain TEXT NOT NULL, + dest_chain TEXT NOT NULL, + source_amount INTEGER NOT NULL, + dest_amount INTEGER NOT NULL, + tao_amount INTEGER NOT NULL, + rate TEXT NOT NULL DEFAULT '', + user_source_address TEXT NOT NULL DEFAULT '', + user_dest_address TEXT NOT NULL DEFAULT '', + miner_hotkey TEXT NOT NULL DEFAULT '', + source_tx_hash TEXT NOT NULL DEFAULT '', + dest_tx_hash TEXT NOT NULL DEFAULT '', + initiated_block INTEGER NOT NULL DEFAULT 0, + fulfilled_block INTEGER NOT NULL DEFAULT 0, + completed_block INTEGER NOT NULL DEFAULT 0, + timeout_block INTEGER NOT NULL DEFAULT 0, + saved_at REAL NOT NULL +) +""" + + +def _get_conn() -> sqlite3.Connection: + ALLWAYS_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(HISTORY_DB) + conn.execute('PRAGMA journal_mode=WAL') + conn.row_factory = sqlite3.Row + conn.execute(_CREATE_TABLE) + return conn + + +def save_swap(swap: Swap) -> None: + """Save a resolved swap to local history. Overwrites if swap_id already exists.""" + conn = _get_conn() + try: + conn.execute( + """ + INSERT OR REPLACE INTO swap_history ( + swap_id, status, source_chain, dest_chain, + source_amount, dest_amount, tao_amount, rate, + user_source_address, user_dest_address, miner_hotkey, + source_tx_hash, dest_tx_hash, + initiated_block, fulfilled_block, completed_block, timeout_block, + saved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + swap.id, + int(swap.status), + swap.source_chain, + swap.dest_chain, + swap.source_amount, + swap.dest_amount, + swap.tao_amount, + swap.rate, + swap.user_source_address, + swap.user_dest_address, + swap.miner_hotkey, + swap.source_tx_hash, + swap.dest_tx_hash, + swap.initiated_block, + swap.fulfilled_block, + swap.completed_block, + swap.timeout_block, + time.time(), + ), + ) + conn.commit() + finally: + conn.close() + + +def load_history( + status: Optional[int] = None, + limit: int = 50, +) -> list[dict]: + """Load swap history rows. Returns list of dicts, newest first.""" + conn = _get_conn() + try: + query = 'SELECT * FROM swap_history' + params: list = [] + if status is not None: + query += ' WHERE status = ?' + params.append(status) + query += ' ORDER BY saved_at DESC LIMIT ?' + params.append(limit) + rows = conn.execute(query, params).fetchall() + return [dict(row) for row in rows] + finally: + conn.close() + + +def load_swap(swap_id: int) -> Optional[dict]: + """Load a single swap from history by ID.""" + conn = _get_conn() + try: + row = conn.execute('SELECT * FROM swap_history WHERE swap_id = ?', (swap_id,)).fetchone() + return dict(row) if row else None + finally: + conn.close() diff --git a/allways/cli/swap_commands/post_tx.py b/allways/cli/swap_commands/post_tx.py index 58283df..962ffef 100644 --- a/allways/cli/swap_commands/post_tx.py +++ b/allways/cli/swap_commands/post_tx.py @@ -122,6 +122,9 @@ def post_tx_command(tx_hash: str, netuid: int): clear_pending_swap() console.print(f'\n[green bold]Swap initiated! ID: {swap_id}[/green bold]') console.print(f'[dim]Track with: alw view swap {swap_id}[/dim]\n') + from allways.cli.swap_commands.view import save_initiated_swap_to_history + + save_initiated_swap_to_history(client, swap_id) else: console.print('[yellow]Votes submitted but swap not yet on-chain. Check: alw view swaps[/yellow]') console.print('[dim]State file kept — you can retry this command.[/dim]\n') diff --git a/allways/cli/swap_commands/resume.py b/allways/cli/swap_commands/resume.py index 6c7f117..7db50b8 100644 --- a/allways/cli/swap_commands/resume.py +++ b/allways/cli/swap_commands/resume.py @@ -198,6 +198,9 @@ def resume_command(source_tx_hash_opt: Optional[str], skip_confirm: bool, netuid console.print(f'\n[green bold]Swap initiated! ID: {swap_id}[/green bold]') if skip_confirm: + from allways.cli.swap_commands.view import save_initiated_swap_to_history + + save_initiated_swap_to_history(client, swap_id) return from allways.cli.swap_commands.view import watch_swap diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 17c360e..17b1b65 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -884,8 +884,11 @@ def swap_now_command( clear_pending_swap() console.print(f'\n[green bold]Swap initiated! ID: {swap_id}[/green bold]') - # In non-interactive mode, just print the ID and exit (let caller handle watching) + # In non-interactive mode, save to history and exit if skip_confirm: + from allways.cli.swap_commands.view import save_initiated_swap_to_history + + save_initiated_swap_to_history(client, swap_id) return # Watch swap through lifecycle diff --git a/allways/cli/swap_commands/view.py b/allways/cli/swap_commands/view.py index 62de64e..8bc4c4c 100644 --- a/allways/cli/swap_commands/view.py +++ b/allways/cli/swap_commands/view.py @@ -1,5 +1,6 @@ """alw view - View swaps, miners, and rates.""" +import datetime import time from dataclasses import replace @@ -320,6 +321,15 @@ def view_swap(swap_id: int, watch: bool): return if not swap: + # Check local history before giving up + from allways.cli.swap_commands.history import load_swap as load_history_swap + + record = load_history_swap(swap_id) + if record: + console.print(f'\n[dim]Swap {swap_id} resolved on-chain. Showing local history:[/dim]') + _display_history_detail(record) + return + try: next_id = client.get_next_swap_id() except ContractError: @@ -343,6 +353,30 @@ def view_swap(swap_id: int, watch: bool): watch_swap(client, swap_id, swap) +def save_to_history(swap): + """Save a swap to local history (best-effort, never raises). + + Called at terminal states (COMPLETED/TIMED_OUT) and also at initiation + for non-interactive flows. Uses INSERT OR REPLACE so later updates win. + """ + try: + from allways.cli.swap_commands.history import save_swap + + save_swap(swap) + except Exception: + pass + + +def save_initiated_swap_to_history(client, swap_id: int): + """Fetch a just-initiated swap and save to history (best-effort).""" + try: + swap = client.get_swap(swap_id) + if swap: + save_to_history(swap) + except Exception: + pass + + def watch_swap(client, swap_id: int, swap=None): """Poll and display a swap until it reaches a terminal state. @@ -361,6 +395,7 @@ def watch_swap(client, swap_id: int, swap=None): terminal = (SwapStatus.COMPLETED, SwapStatus.TIMED_OUT) if swap.status in terminal: + save_to_history(swap) _display_swap(swap) return swap @@ -394,11 +429,13 @@ def _render(s, chain_info=True, watching=True): status=SwapStatus.COMPLETED, completed_block=last_swap.fulfilled_block or last_swap.initiated_block, ) + save_to_history(final) live.update(_render(final, chain_info=False, watching=False)) return final last_swap = swap live.update(_render(swap)) if swap.status in terminal: + save_to_history(swap) live.update(_render(swap, watching=False)) return swap except KeyboardInterrupt: @@ -406,6 +443,194 @@ def _render(s, chain_info=True, watching=True): return None +@view_group.command('history') +@click.option('--swap', 'swap_id', default=None, type=int, help='Show full detail for a specific swap ID') +@click.option( + '--status', 'status_filter', default=None, type=click.Choice(['completed', 'timed_out']), help='Filter by status' +) +@click.option('--limit', 'limit', default=50, type=int, help='Max number of swaps to show') +@click.option('--stats', is_flag=True, help='Show aggregate summary after the table') +def view_history(swap_id: int, status_filter: str, limit: int, stats: bool): + """View local swap history. + + [dim]Shows completed and timed-out swaps saved from previous swap sessions. + History is stored locally at ~/.allways/swap_history.db.[/dim] + + [dim]Examples: + $ alw view history + $ alw view history --swap 42 + $ alw view history --status completed + $ alw view history --stats[/dim] + """ + from allways.cli.swap_commands.history import load_history, load_swap + + if swap_id is not None: + record = load_swap(swap_id) + if not record: + console.print(f'[yellow]Swap {swap_id} not found in local history.[/yellow]') + return + _display_history_detail(record) + return + + status_int = None + if status_filter == 'completed': + status_int = int(SwapStatus.COMPLETED) + elif status_filter == 'timed_out': + status_int = int(SwapStatus.TIMED_OUT) + + records = load_history(status=status_int, limit=limit) + if not records: + console.print('\n[yellow]No swap history yet.[/yellow]\n') + return + + table = Table(show_header=True, title='Swap History') + table.add_column('ID', style='cyan') + table.add_column('Direction', style='white') + table.add_column('Sent', style='red') + table.add_column('Received', style='green') + table.add_column('Status', style='white') + table.add_column('Date', style='dim') + + for r in records: + src_chain = r['source_chain'] + dst_chain = r['dest_chain'] + src_dec = get_chain(src_chain).decimals + dst_dec = get_chain(dst_chain).decimals + human_src = r['source_amount'] / (10**src_dec) + human_dst = r['dest_amount'] / (10**dst_dec) + + status_val = SwapStatus(r['status']) + status_labels = { + SwapStatus.COMPLETED: '[green]Done[/green]', + SwapStatus.TIMED_OUT: '[red]Timeout[/red]', + SwapStatus.ACTIVE: '[yellow]Active[/yellow]', + SwapStatus.FULFILLED: '[blue]Fulfilled[/blue]', + } + status_label = status_labels.get(status_val, str(status_val.name)) + + date_str = datetime.datetime.fromtimestamp(r['saved_at'], tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M') + + table.add_row( + str(r['swap_id']), + f'{src_chain.upper()} -> {dst_chain.upper()}', + f'{human_src:.8f} {src_chain.upper()}', + f'{human_dst:.8f} {dst_chain.upper()}', + status_label, + date_str, + ) + + console.print() + console.print(table) + + if stats and records: + _display_history_stats(records) + + console.print() + + +def _display_history_stats(records: list[dict]): + """Display aggregate summary for a set of history records.""" + completed = sum(1 for r in records if r['status'] == int(SwapStatus.COMPLETED)) + timed_out = sum(1 for r in records if r['status'] == int(SwapStatus.TIMED_OUT)) + total = len(records) + + # Aggregate volumes per chain + sent_by_chain: dict[str, int] = {} + received_by_chain: dict[str, int] = {} + for r in records: + src = r['source_chain'] + dst = r['dest_chain'] + sent_by_chain[src] = sent_by_chain.get(src, 0) + r['source_amount'] + if r['status'] == int(SwapStatus.COMPLETED): + received_by_chain[dst] = received_by_chain.get(dst, 0) + r['dest_amount'] + + console.print() + console.print('[bold]Summary[/bold]') + + stats_table = Table(show_header=False, box=None, padding=(0, 2)) + stats_table.add_column(style='cyan') + stats_table.add_column(style='white') + + pct = f'{completed / total * 100:.0f}%' if total > 0 else '—' + stats_table.add_row('Total Swaps', str(total)) + stats_table.add_row('Completed', f'{completed} ({pct})') + if timed_out > 0: + stats_table.add_row('Timed Out', str(timed_out)) + + for chain_id, amount in sorted(sent_by_chain.items()): + decimals = get_chain(chain_id).decimals + human = amount / (10**decimals) + stats_table.add_row(f'Total Sent {chain_id.upper()}', f'{human:.8f} {chain_id.upper()}') + + for chain_id, amount in sorted(received_by_chain.items()): + decimals = get_chain(chain_id).decimals + human = amount / (10**decimals) + stats_table.add_row(f'Total Received {chain_id.upper()}', f'{human:.8f} {chain_id.upper()}') + + console.print(stats_table) + + +def _display_history_detail(r: dict): + """Display full detail for a single swap history record.""" + src_chain = r['source_chain'] + dst_chain = r['dest_chain'] + src_dec = get_chain(src_chain).decimals + dst_dec = get_chain(dst_chain).decimals + human_src = r['source_amount'] / (10**src_dec) + human_dst = r['dest_amount'] / (10**dst_dec) + + # Rate is stored as canonical (TAO per 1 non-TAO). Display as non-TAO per 1 TAO (priced in TAO). + rate_str = r['rate'] + try: + rate_val = float(rate_str) + if rate_val > 0: + non_tao = dst_chain if src_chain == 'tao' else src_chain + inverse_rate = 1.0 / rate_val + rate_display = f'{inverse_rate:.8f} {non_tao.upper()}/TAO' + else: + rate_display = rate_str + except (ValueError, ZeroDivisionError): + rate_display = rate_str + + status_val = SwapStatus(r['status']) + status_label = 'Completed' if status_val == SwapStatus.COMPLETED else 'Timed Out' + + saved_at = datetime.datetime.fromtimestamp(r['saved_at'], tz=datetime.timezone.utc).strftime( + '%Y-%m-%d %H:%M:%S UTC' + ) + + console.print() + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column(style='cyan') + table.add_column(style='white') + + table.add_row('Swap ID', str(r['swap_id'])) + table.add_row( + 'Status', + f'[green]{status_label}[/green]' if status_val == SwapStatus.COMPLETED else f'[red]{status_label}[/red]', + ) + table.add_row('Direction', f'{src_chain.upper()} -> {dst_chain.upper()}') + table.add_row('', '') + table.add_row('Source Amount', f'{human_src:.8f} {src_chain.upper()}') + table.add_row('Dest Amount', f'{human_dst:.8f} {dst_chain.upper()}') + table.add_row('Rate Applied', rate_display) + table.add_row('', '') + table.add_row('Source TX', r['source_tx_hash'] or '—') + table.add_row('Dest TX', r['dest_tx_hash'] or '—') + table.add_row('', '') + table.add_row('User Source Addr', r['user_source_address'] or '—') + table.add_row('User Dest Addr', r['user_dest_address'] or '—') + table.add_row('Miner Hotkey', r['miner_hotkey'] or '—') + table.add_row('', '') + table.add_row('Initiated Block', f'{r["initiated_block"]:,}' if r['initiated_block'] else '—') + table.add_row('Fulfilled Block', f'{r["fulfilled_block"]:,}' if r['fulfilled_block'] else '—') + table.add_row('Completed Block', f'{r["completed_block"]:,}' if r['completed_block'] else '—') + table.add_row('Timestamp', saved_at) + + console.print(table) + console.print() + + @view_group.command('contract') def view_contract(): """View contract parameters.