Skip to content
Merged
16 changes: 16 additions & 0 deletions allways/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ def get_chain(chain_id: str) -> ChainDefinition:
return SUPPORTED_CHAINS[chain_id]


def canonical_pair(chain_a: str, chain_b: str) -> tuple:
"""Return (source, dest) in canonical order for consistent commitment storage.

Determines the rate unit: rate is always 'dest per 1 source' in this ordering.

Ordering rules:
1. If TAO is in the pair, TAO is always dest — rates are denominated in TAO.
2. Otherwise, alphabetical — deterministic fallback for non-TAO pairs (e.g. BTC-ETH).
"""
if chain_b == 'tao':
return (chain_a, chain_b)
if chain_a == 'tao':
return (chain_b, chain_a)
return (chain_a, chain_b) if chain_a < chain_b else (chain_b, chain_a)


def confirmations_to_subtensor_blocks(chain_id: str) -> int:
"""How many subtensor blocks a chain's min_confirmations take."""
chain = get_chain(chain_id)
Expand Down
19 changes: 16 additions & 3 deletions allways/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,29 @@ class ReservationStatus(IntEnum):

@dataclass
class MinerPair:
"""A miner's posted exchange pair from on-chain commitments."""
"""A miner's posted exchange pair from on-chain commitments.

After normalization, source_chain/dest_chain are in canonical order.
rate is for source→dest swaps, counter_rate is for dest→source swaps.
Both rates use the same unit: 'dest per 1 source' in canonical order.
"""

uid: int
hotkey: str
source_chain: str
source_address: str
dest_chain: str
dest_address: str
rate: float # TAO per 1 non-TAO asset — for display/sorting
rate_str: str = '' # Raw string from commitment — used for precise dest_amount calculation
rate: float # source→dest rate — for display/sorting
rate_str: str = '' # Raw string — for precise dest_amount calculation
counter_rate: float = 0.0 # dest→source rate (same unit as rate)
counter_rate_str: str = '' # Raw string — for precise dest_amount calculation

def get_rate_for_direction(self, swap_source_chain: str) -> tuple:
"""Return (rate, rate_str) for the given swap direction."""
if swap_source_chain == self.source_chain:
return self.rate, self.rate_str
return self.counter_rate, self.counter_rate_str


@dataclass
Expand Down
18 changes: 17 additions & 1 deletion allways/cli/swap_commands/miner_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,23 @@ def miner_status(hotkey: str):

if pair:
console.print('[bold]Committed Pair[/bold]\n')
console.print(f' {pair.source_chain.upper()}/{pair.dest_chain.upper()} @ [green]{pair.rate:g}[/green]')
src_up, dst_up = pair.source_chain.upper(), pair.dest_chain.upper()
fwd_disabled = pair.rate == 0
ctr_disabled = pair.counter_rate == 0
if fwd_disabled or ctr_disabled or pair.rate_str != pair.counter_rate_str:
console.print(f' {src_up} ↔ {dst_up}')
if fwd_disabled:
console.print(f' {src_up} → {dst_up}: [yellow]not supported[/yellow]')
else:
console.print(f' {src_up} → {dst_up}: [green]send 1 {src_up}, get {pair.rate:g} {dst_up}[/green]')
if ctr_disabled:
console.print(f' {dst_up} → {src_up}: [yellow]not supported[/yellow]')
else:
console.print(
f' {dst_up} → {src_up}: [green]send {pair.counter_rate:g} {dst_up}, get 1 {src_up}[/green]'
)
else:
console.print(f' {src_up} ↔ {dst_up} @ [green]{pair.rate:g}[/green]')
console.print(f' Source address: [dim]{pair.source_address}[/dim]')
console.print(f' Dest address: [dim]{pair.dest_address}[/dim]')
else:
Expand Down
96 changes: 71 additions & 25 deletions allways/cli/swap_commands/pair.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import rich_click as click

from allways.chains import SUPPORTED_CHAINS
from allways.chains import SUPPORTED_CHAINS, canonical_pair
from allways.cli.swap_commands.helpers import console, get_cli_context, loading
from allways.constants import COMMITMENT_VERSION


def _prompt_chain(label: str, exclude: str | None = None) -> str:
"""Prompt the user to pick a chain from SUPPORTED_CHAINS."""
chains = [c for c in SUPPORTED_CHAINS if c != exclude]
if len(chains) == 1:
console.print(f'{label}: [cyan]{chains[0]}[/cyan]')
return chains[0]
choices = ', '.join(chains)
while True:
value = click.prompt(f'{label} ({choices})').strip().lower()
Expand All @@ -19,13 +22,21 @@ def _prompt_chain(label: str, exclude: str | None = None) -> str:
console.print(f'[red]Invalid: {reason}. Choose from: {choices}[/red]')


def _prompt_rate() -> float:
"""Prompt for a positive rate."""
def _prompt_rates(canon_src: str, canon_dest: str) -> tuple:
"""Prompt for direction-specific rates. Counter rate defaults to forward; 0 = direction not supported."""
src_up, dst_up = canon_src.upper(), canon_dest.upper()
while True:
value = click.prompt('Rate (TAO per 1 non-TAO asset)', type=float)
if value > 0:
return value
fwd = click.prompt(f'Rate for {src_up} -> {dst_up} ({dst_up} per 1 {src_up})', type=float)
if fwd > 0:
break
console.print('[red]Rate must be positive[/red]')
rev = click.prompt(
f'Rate for {dst_up} -> {src_up} ({dst_up} per 1 {src_up}, 0 = not supported)', type=float, default=fwd
)
if rev < 0:
console.print('[red]Rate cannot be negative, using 0 (not supported)[/red]')
rev = 0.0
return fwd, rev


@click.command('pair')
Expand All @@ -34,13 +45,15 @@ def _prompt_rate() -> float:
@click.argument('dst_chain', required=False, default=None, type=str)
@click.argument('dst_addr', required=False, default=None, type=str)
@click.argument('rate', required=False, default=None, type=float)
@click.argument('counter_rate', required=False, default=None, type=float)
@click.option('--yes', '-y', is_flag=True, help='Skip confirmation prompt')
def post_pair(
src_chain: str | None,
src_addr: str | None,
dst_chain: str | None,
dst_addr: str | None,
rate: float | None,
counter_rate: float | None,
yes: bool,
):
"""Post a trading pair to chain via commitment.
Expand All @@ -50,20 +63,22 @@ def post_pair(

\b
Arguments:
SRC_CHAIN Source chain ID (e.g. btc, tao)
SRC_ADDR Your receiving address on source chain
DST_CHAIN Destination chain ID (e.g. tao, btc)
DST_ADDR Your sending address on destination chain
RATE TAO per 1 non-TAO asset (e.g. 345 means 1 BTC = 345 TAO)
SRC_CHAIN Source chain ID (e.g. btc, tao)
SRC_ADDR Your receiving address on source chain
DST_CHAIN Destination chain ID (e.g. tao, btc)
DST_ADDR Your sending address on destination chain
RATE source→dest rate (e.g. TAO per 1 BTC for btc-tao pair)
COUNTER_RATE dest→source rate (optional, defaults to RATE)

\b
Examples:
alw miner post (interactive wizard)
alw miner post btc bc1q...abc tao 5Cxyz...def 345 (all at once)
alw miner post (interactive wizard)
alw miner post btc bc1q...abc tao 5Cxyz...def 340 350 (direction-specific rates)
alw miner post btc bc1q...abc tao 5Cxyz...def 345 (same rate both ways)
"""
# --- Prompt for any missing arguments ---
if src_chain is None:
src_chain = _prompt_chain('Source chain')
src_chain = _prompt_chain('Source chain (you receive on this chain)')
else:
src_chain = src_chain.lower()
if src_chain not in SUPPORTED_CHAINS:
Expand All @@ -75,7 +90,7 @@ def post_pair(
src_addr = click.prompt(f'Your receiving address on {SUPPORTED_CHAINS[src_chain].name}')

if dst_chain is None:
dst_chain = _prompt_chain('Destination chain', exclude=src_chain)
dst_chain = _prompt_chain('Destination chain (you send on this chain)', exclude=src_chain)
else:
dst_chain = dst_chain.lower()
if dst_chain not in SUPPORTED_CHAINS:
Expand All @@ -89,32 +104,64 @@ def post_pair(
if dst_addr is None:
dst_addr = click.prompt(f'Your sending address on {SUPPORTED_CHAINS[dst_chain].name}')

canon_src, canon_dest = canonical_pair(src_chain, dst_chain)
rates_from_args = rate is not None

if rate is None:
rate = _prompt_rate()
rate, counter_rate = _prompt_rates(canon_src, canon_dest)
elif rate <= 0:
console.print('[red]Rate must be positive[/red]')
return
else:
if counter_rate is None:
counter_rate = rate
elif counter_rate < 0:
console.print('[red]Rate cannot be negative[/red]')
return

# Normalize to canonical direction: non-TAO → TAO.
# Rate is always "TAO per 1 non-TAO asset" regardless of direction.
if src_chain == 'tao' and dst_chain != 'tao':
console.print('[dim]Normalizing pair direction to canonical form (non-TAO -> TAO).[/dim]')
# Normalize to canonical direction.
# Positional args: RATE = user's source→dest, so swap rates to match canonical order.
# Interactive prompts: already asked in canonical order, no rate swap needed.
if src_chain != canon_src:
console.print(f'[dim]Normalizing pair direction to canonical form ({canon_src} -> {canon_dest}).[/dim]')
src_chain, dst_chain = dst_chain, src_chain
src_addr, dst_addr = dst_addr, src_addr
if rates_from_args:
rate, counter_rate = counter_rate, rate

config, wallet, subtensor, _ = get_cli_context(need_client=False)
netuid = config['netuid']

rate_str = f'{rate:g}'
commitment_data = f'v{COMMITMENT_VERSION}:{src_chain}:{src_addr}:{dst_chain}:{dst_addr}:{rate_str}'
counter_rate_str = f'{counter_rate:g}'
commitment_data = (
f'v{COMMITMENT_VERSION}:{src_chain}:{src_addr}:{dst_chain}:{dst_addr}:{rate_str}:{counter_rate_str}'
)

data_bytes = commitment_data.encode('utf-8')
if len(data_bytes) > 128:
console.print(
f'[red]Commitment too long ({len(data_bytes)} bytes, max 128). '
f'Try a shorter address format (e.g. P2WPKH instead of P2TR).[/red]'
)
return

non_tao = src_chain if src_chain != 'tao' else dst_chain
non_tao_ticker = non_tao.upper()
src_up, dst_up = src_chain.upper(), dst_chain.upper()

console.print('\n[bold]Posting trading pair commitment[/bold]\n')
console.print(f' Source: [cyan]{SUPPORTED_CHAINS[src_chain].name}[/cyan] ({src_addr})')
console.print(f' Destination: [cyan]{SUPPORTED_CHAINS[dst_chain].name}[/cyan] ({dst_addr})')
console.print(f' Rate: [green]1 {non_tao_ticker} = {rate:g} TAO[/green]')
if rate == counter_rate and rate > 0:
console.print(f' Rate: [green]send 1 {src_up}, get {rate:g} {dst_up} (both directions)[/green]')
else:
if rate > 0:
console.print(f' {src_up} → {dst_up}: [green]send 1 {src_up}, get {rate:g} {dst_up}[/green]')
else:
console.print(f' {src_up} → {dst_up}: [yellow]not supported[/yellow]')
if counter_rate > 0:
console.print(f' {dst_up} → {src_up}: [green]send {counter_rate:g} {dst_up}, get 1 {src_up}[/green]')
else:
console.print(f' {dst_up} → {src_up}: [yellow]not supported[/yellow]')
console.print(f' Netuid: {netuid}')
console.print(f' Data: [dim]{commitment_data}[/dim]\n')

Expand All @@ -123,7 +170,6 @@ def post_pair(
return

try:
data_bytes = commitment_data.encode('utf-8')
with loading('Submitting commitment...'):
call = subtensor.substrate.compose_call(
call_module='Commitments',
Expand Down
9 changes: 8 additions & 1 deletion allways/cli/swap_commands/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ def status_command(netuid: int):
my_pairs = [p for p in pairs if p.hotkey == hotkey]
if my_pairs:
for p in my_pairs:
table.add_row('Miner Pair', f'{p.source_chain.upper()}/{p.dest_chain.upper()} @ {p.rate:g}')
src_up, dst_up = p.source_chain.upper(), p.dest_chain.upper()
if p.rate > 0 and p.counter_rate > 0 and p.rate_str != p.counter_rate_str:
rate_display = f'{src_up}→{dst_up}: {p.rate:g} | {dst_up}→{src_up}: {p.counter_rate:g}'
elif p.rate > 0:
rate_display = f'{p.rate:g}'
else:
rate_display = f'{p.counter_rate:g}'
table.add_row('Miner Pair', f'{src_up} ↔ {dst_up} @ {rate_display}')
except ContractError:
table.add_row('Miner Status', '[dim]unable to read[/dim]')

Expand Down
47 changes: 25 additions & 22 deletions allways/cli/swap_commands/swap.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from rich.table import Table

from allways.chain_providers import create_chain_providers
from allways.chains import CHAIN_TAO, SUPPORTED_CHAINS, get_chain
from allways.chains import SUPPORTED_CHAINS, canonical_pair, get_chain
from allways.classes import MinerPair, SwapStatus
from allways.cli.dendrite_lite import broadcast_synapse, discover_validators, get_ephemeral_wallet
from allways.cli.swap_commands.helpers import (
Expand Down Expand Up @@ -538,20 +538,23 @@ def swap_now_command(
matching_pairs = []
for p in all_pairs:
if p.source_chain == source_chain and p.dest_chain == dest_chain:
matching_pairs.append(p)
if p.rate > 0:
matching_pairs.append(p)
elif p.source_chain == dest_chain and p.dest_chain == source_chain:
matching_pairs.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=p.rate,
rate_str=p.rate_str,
rev_rate, rev_rate_str = p.get_rate_for_direction(source_chain)
if rev_rate > 0:
matching_pairs.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,
)
)
)

if not matching_pairs:
console.print('[yellow]No miners found for this pair[/yellow]\n')
Expand Down Expand Up @@ -588,9 +591,11 @@ def swap_now_command(
console.print(table)

# Step 3: Select miner (default to best rate)
non_tao = dest_chain if source_chain == 'tao' else source_chain
canon_src, canon_dest = canonical_pair(source_chain, dest_chain)
best_pair = available_miners[0][0]
console.print(f'\n Best rate: 1 {non_tao.upper()} = {best_pair.rate:g} TAO (Miner UID {best_pair.uid})')
console.print(
f'\n Best rate: send 1 {source_chain.upper()}, get {best_pair.rate:g} {dest_chain.upper()} (Miner UID {best_pair.uid})'
)

if auto_select or len(available_miners) == 1:
selected_pair, selected_collateral = available_miners[0]
Expand All @@ -610,14 +615,13 @@ def swap_now_command(
return

source_amount = _to_smallest_unit(amount, source_chain)
source_is_tao = source_chain == 'tao'
asset_decimals = get_chain(non_tao).decimals
is_reverse = source_chain != canon_src
dest_amount = calculate_dest_amount(
source_amount,
selected_pair.rate_str,
source_is_tao,
CHAIN_TAO.decimals,
asset_decimals,
is_reverse,
get_chain(canon_dest).decimals,
get_chain(canon_src).decimals,
)

# Show estimated receive inline
Expand Down Expand Up @@ -704,13 +708,12 @@ def swap_now_command(

# Step 7: Confirm summary
fee_in_dest = dest_amount - user_receives
non_tao_ticker = non_tao.upper()

summary = (
f' Send: [red]{amount} {source_chain.upper()}[/red]\n'
f' Receive: [green]{_from_smallest_unit(user_receives, dest_chain):.8f} {dest_chain.upper()}[/green]\n'
f' Fee: {fee_percent:g}% ({_from_smallest_unit(fee_in_dest, dest_chain):.8f} {dest_chain.upper()})\n'
f' Rate: 1 {non_tao_ticker} = {selected_pair.rate:g} TAO\n'
f' Rate: send 1 {source_chain.upper()}, get {selected_pair.rate:g} {dest_chain.upper()}\n'
f' Miner: UID {selected_pair.uid}\n'
f' To: {receive_address}'
)
Expand Down
Loading
Loading