Skip to content
Merged
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
35 changes: 34 additions & 1 deletion allways/cli/swap_commands/swap.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
)
from allways.cli.validator_rejections import RejectionInfo, render_and_aggregate
from allways.commitments import read_miner_commitments
from allways.constants import FEE_DIVISOR, NETUID_FINNEY
from allways.constants import FEE_DIVISOR, NETUID_FINNEY, RESERVE_SLIPPAGE_DEFAULT_BPS, RESERVE_SLIPPAGE_MAX_BPS
from allways.contract_client import ContractError
from allways.synapses import SwapConfirmSynapse, SwapReserveSynapse
from allways.utils.proofs import reserve_proof_message, swap_proof_message
Expand Down Expand Up @@ -188,6 +188,7 @@ def broadcast_reserve_with_retry(
netuid: int,
skip_confirm: bool = False,
max_retries: int = 2,
slippage_bps: int = RESERVE_SLIPPAGE_DEFAULT_BPS,
):
"""Reserve miner via multi-validator consensus with retry.

Expand Down Expand Up @@ -239,6 +240,7 @@ def broadcast_reserve_with_retry(
block_anchor=current_block,
from_chain=from_chain,
to_chain=to_chain,
slippage_bps=slippage_bps,
)

with loading(f'Broadcasting reservation to {len(validator_axons)} validators...'):
Expand Down Expand Up @@ -567,6 +569,17 @@ def swap_group():
@click.option('--from-tx-hash', 'from_tx_hash_opt', default=None, help='Source tx hash (skip fund sending)')
@click.option('--auto', 'auto_select', is_flag=True, help='Auto-select best rate miner')
@click.option('--yes', 'skip_confirm', is_flag=True, help='Skip confirmation prompts')
@click.option(
'--slippage',
'slippage',
type=float,
default=2.0,
help=(
'Maximum rate slippage as a percent (e.g. 2.0 means 2%) between your quote and '
"the reservation. The reservation is rejected if the miner's current rate would "
'give you more than this percentage less than your quoted amount. Default: 2.0%.'
),
)
@click.option(
'--btc-fee-rate',
'btc_fee_rate_opt',
Expand All @@ -588,6 +601,7 @@ def swap_now_command(
from_tx_hash_opt: Optional[str],
auto_select: bool,
skip_confirm: bool,
slippage: float,
btc_fee_rate_opt: Optional[int],
):
"""Guided interactive swap - step by step.
Expand Down Expand Up @@ -821,6 +835,24 @@ def swap_now_command(
f' (after {preview_fee_pct:g}% fee)'
)

# Convert slippage percent to bps and apply the ceiling clamp.
slippage_bps = round(slippage * 100)
if slippage_bps > RESERVE_SLIPPAGE_MAX_BPS:
console.print(
f'[yellow]Warning: --slippage {slippage}% exceeds the protocol ceiling '
f'({RESERVE_SLIPPAGE_MAX_BPS // 100}%) — will be capped.[/yellow]'
)
slippage_bps = RESERVE_SLIPPAGE_MAX_BPS
if slippage > 10.0:
console.print(
f'[yellow]Warning: slippage is set to {slippage}%, which is unusually high. '
'Consider a lower value to protect against adverse rate moves.[/yellow]'
)
console.print(
f" Slippage {slippage:g}% — reservation is rejected if the miner's rate has moved "
f'more than {slippage:g}% below your quote.'
)

tao_amount = derive_tao_leg(from_chain, from_amount, to_chain, to_amount)

# Validate against contract min/max swap bounds + selected miner's
Expand Down Expand Up @@ -985,6 +1017,7 @@ def swap_now_command(
from_key,
netuid,
skip_confirm=skip_confirm,
slippage_bps=slippage_bps,
)
if result is None:
return
Expand Down
9 changes: 9 additions & 0 deletions allways/cli/validator_rejections.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ def _ctx_get(ctx: dict, key: str, fallback: str = '?') -> str:
'address.'
),
),
(
'rate_moved',
'quoted amount is below your slippage band',
True,
lambda ctx: (
f'Miner UID {_ctx_get(ctx, "miner_uid")} changed its rate after you got your quote. '
'Re-run the swap to get a fresh quote at the current rate, then retry.'
),
),
(
'wrong_direction',
'miner does not support this swap direction',
Expand Down
2 changes: 2 additions & 0 deletions allways/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
RECYCLE_UID = 53 # Subnet owner UID

# ─── Reservation ─────────────────────────────────────────
RESERVE_SLIPPAGE_DEFAULT_BPS = 200 # 2% — applied when the synapse omits slippage
RESERVE_SLIPPAGE_MAX_BPS = 100_000 # 1000% — clamp ceiling (mostly an integer/typo guard)
RESERVATION_COOLDOWN_BLOCKS = 150 # ~30 min base cooldown on failed reservation
RESERVATION_COOLDOWN_MULTIPLIER = 2 # 150 → 300 → 600 ...
MAX_RESERVATIONS_PER_ADDRESS = 1
Expand Down
3 changes: 3 additions & 0 deletions allways/synapses.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

import bittensor as bt

from allways.constants import RESERVE_SLIPPAGE_DEFAULT_BPS


class MinerActivateSynapse(bt.Synapse):
"""Miner broadcasts activation request to all validators.
Expand Down Expand Up @@ -45,6 +47,7 @@ class SwapReserveSynapse(bt.Synapse):
block_anchor: int
from_chain: str = '' # User's source chain (for bilateral pair support)
to_chain: str = '' # User's dest chain
slippage_bps: int = RESERVE_SLIPPAGE_DEFAULT_BPS

# Response fields (validator fills)
accepted: Optional[bool] = None
Expand Down
14 changes: 14 additions & 0 deletions allways/utils/rate.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ def derive_tao_leg(from_chain: str, from_amount: int, to_chain: str, to_amount:
return 0


def quote_within_slippage(quoted: int, recomputed: int, slippage_bps: int) -> bool:
"""True if `recomputed` is no more than `slippage_bps` below `quoted`.

One-sided (DEX 'minimum received'): a favorable move — recomputed >= quoted —
always passes. Pure integer math for determinism across validators. When
slippage_bps >= 10_000 the threshold is non-positive, so it always passes.
"""
if quoted <= 0 or recomputed <= 0:
return False
if recomputed >= quoted:
return True
return recomputed * 10_000 >= quoted * (10_000 - slippage_bps)


def check_swap_viability(
tao_amount_rao: int,
miner_collateral_rao: int,
Expand Down
51 changes: 50 additions & 1 deletion allways/validator/axon_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
from bittensor import Keypair
from Crypto.Hash import keccak

from allways.chains import canonical_pair, get_chain
from allways.classes import MinerPair
from allways.commitments import read_miner_commitment
from allways.constants import RESERVATION_COOLDOWN_BLOCKS
from allways.constants import RESERVATION_COOLDOWN_BLOCKS, RESERVE_SLIPPAGE_MAX_BPS
from allways.contract_client import AllwaysContractClient, ContractError
from allways.synapses import MinerActivateSynapse, SwapConfirmSynapse, SwapReserveSynapse
from allways.utils.proofs import reserve_proof_message, swap_proof_message
from allways.utils.rate import calculate_to_amount, derive_tao_leg, quote_within_slippage
from allways.utils.scale import encode_bytes, encode_str, encode_u128
from allways.validator.state_store import PendingConfirm

Expand Down Expand Up @@ -117,6 +119,26 @@ def resolve_swap_direction(
return from_chain, to_chain, deposit_addr, fulfillment_addr, rate, rate_str


def recompute_reserve_amounts(
commitment: MinerPair,
from_chain: str,
to_chain: str,
from_amount: int,
) -> int:
"""Recompute to_amount from the miner's commitment rate, mirroring the CLI
quote path so a correctly-quoted reservation matches the reserve-time rate."""
canon_from, canon_to = canonical_pair(from_chain, to_chain)
is_reverse = from_chain != canon_from
_, rate_str = commitment.get_rate_for_direction(from_chain)
return calculate_to_amount(
from_amount,
rate_str,
is_reverse,
get_chain(canon_to).decimals,
get_chain(canon_from).decimals,
)


def load_swap_commitment(validator: 'Validator', miner_hotkey: str) -> Optional[MinerPair]:
"""Read miner commitment and validate chains differ. Returns commitment or None.

Expand Down Expand Up @@ -325,6 +347,33 @@ async def handle_swap_reserve(
reject_synapse(synapse, 'Miner does not support this swap direction', ctx)
return synapse

# Gate the user's quote against the rate read at reserve time, and
# reject a tao_amount that doesn't match the submitted from/to legs.
expected_to_amount = recompute_reserve_amounts(
commitment,
synapse.from_chain,
synapse.to_chain,
synapse.from_amount,
)
expected_tao_amount = derive_tao_leg(
synapse.from_chain, synapse.from_amount, synapse.to_chain, synapse.to_amount
)
if synapse.tao_amount != expected_tao_amount:
reject_synapse(
synapse,
'tao_amount is inconsistent with from_amount/to_amount — re-quote and retry',
ctx,
)
return synapse
slippage_bps = max(0, min(synapse.slippage_bps, RESERVE_SLIPPAGE_MAX_BPS))
if not quote_within_slippage(synapse.to_amount, expected_to_amount, slippage_bps):
reject_synapse(
synapse,
'Quoted amount is below your slippage band — the miner rate moved, re-quote and retry',
ctx,
)
return synapse

collateral, active, has_swap, reserved_until, _ = contract.get_miner_snapshot(miner)
if not active:
reject_synapse(synapse, 'Miner not active', ctx)
Expand Down
Loading
Loading